Always permalink to Mandalas. (#99)

This addresses #61 by making mandalas permalinked.

The URL to a mandala will change whenever the user stops fiddling with it for 250 ms.  This means that the user can always reload the page to get a reasonably recent version of what they were creating, and they can use the browser's "back" and "next" buttons to effectively undo/redo recent changes.  They can also copy the URL to share it with others.

## About the serialization format

Originally, I stored the state of the user's mandala in the URL using JSON.  This had a number of drawbacks, though:

* **It was really long.**  A mandala serialization was almost 1k characters, which was a very big URL, and one that some sharing platforms might even reject.

* **It wasn't type-checked in any way.** Unless I added some kind of JSON schema validation (which I didn't), the serialization was simply deserialized and assumed to be in the proper format.  This could result in confusing exceptions during render time, rather than decisively exploding at deserialization time.

To resolve these limitations, and because I thought it would be fun, I decided to store the mandala state using a serialization format called [Apache Avro][].  I first read about this in Kleppmann's [Designing Data-Intensive Applications][kleppmann] and was intrigued by both its compactness (a serialized mandala is around 80-120 characters) and schema evolution properties.

It might be going a bit overboard, but again, I thought it would be fun and I wanted to play around with Avro.  Also, I tried architecting things in such a way that all the Avro code is in its own file, and can easily be removed (or swapped out for another serialization format) if we decide it's dumb.

[Apache Avro]: http://avro.apache.org/
[kleppmann]: https://dataintensive.net/

## Other changes

This PR also makes a few other changes:

* Tests can now import files with JSX in them (I don't think this was required for the final state of this PR, but I figured I'd leave it in there for its almost inevitable use in the future).

* The value labels for number sliders now have a fixed width, which eliminates a weird "jitter" effect that sometimes occurred when using them.
pull/108/head
Atul Varma 2021-04-24 08:46:32 -04:00 zatwierdzone przez GitHub
rodzic 5c4b2a88f8
commit d1c2ae4b02
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
24 zmienionych plików z 750 dodań i 53 usunięć

Wyświetl plik

@ -1,6 +1,7 @@
{
"presets": [
"@babel/preset-typescript",
"@babel/preset-react",
[
"@babel/preset-env",
{

1
.gitignore vendored
Wyświetl plik

@ -4,3 +4,4 @@ dist-watch
.cache
lib/_svg-vocabulary-pretty-printed.json
lib/_svg-vocabulary.ts
lib/**/*.avsc.ts

Wyświetl plik

@ -0,0 +1,36 @@
const path = require("path");
const fs = require("fs");
const prettier = require("prettier");
const { avroToTypeScript } = require("avro-typescript");
/**
* These are all the Avro AVSC JSON files we want to have
* TypeScript representations for.
*/
const AVSC_FILES = ["./lib/pages/mandala-page/mandala-design.avsc.json"];
/**
* Convert the given Avro AVSC JSON file into its TypeScript representation,
* writing out the file to the same path but with a `.ts` extension
* instead of `.json`.
*
* @param {string} avscPath The path to the Avro AVSC JSON file.
*/
function createTypescriptSync(avscPath) {
const avsc = JSON.parse(fs.readFileSync(avscPath, { encoding: "utf-8" }));
const dirname = path.dirname(avscPath);
const basename = path.basename(avscPath, ".json");
const filepath = path.join(dirname, `${basename}.ts`);
const ts = prettier.format(
[
`// This file was auto-generated from ${basename}.json, please do not edit it.`,
"",
avroToTypeScript(avsc),
].join("\n"),
{ filepath }
);
console.log(`Writing ${filepath}.`);
fs.writeFileSync(filepath, ts, { encoding: "utf-8" });
}
AVSC_FILES.forEach(createTypescriptSync);

Wyświetl plik

@ -1,3 +1,5 @@
// @ts-ignore
require("@babel/register")({
extensions: [".es6", ".es", ".jsx", ".js", ".mjs", ".ts", ".tsx"],
});

Wyświetl plik

@ -145,6 +145,17 @@ ul.navbar li:last-child {
flex-basis: 90%;
}
.sidebar .numeric-slider .slider .slider-value {
/**
* We want to keep a minimum width for the value or else
* there will be a "jitter" effect where the slider will
* contract as a result of the label getting smaller,
* which will then change the value of the slider, which
* will then change the width of the label, ad infinitum.
*/
min-width: 2em;
}
.overlay-wrapper {
position: fixed;
display: flex;

Wyświetl plik

@ -0,0 +1,7 @@
import { fromBase64, toBase64 } from "./base64";
test("base64 encode/decode works", () => {
const buf = toBase64(Buffer.from([1, 2, 3]));
expect(Array.from(fromBase64(buf))).toEqual([1, 2, 3]);
});

16
lib/base64.ts 100644
Wyświetl plik

@ -0,0 +1,16 @@
import { SlowBuffer } from "buffer";
export function toBase64(buf: Buffer): string {
return btoa(String.fromCharCode(...buf));
}
export function fromBase64(value: string): Buffer {
const binaryString = atob(value);
const buf = new SlowBuffer(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
buf[i] = binaryString.charCodeAt(i);
}
return buf;
}

Wyświetl plik

@ -43,6 +43,7 @@ const App: React.FC<{}> = (props) => {
const PageComponent = Pages[currPage];
const pushState = usePushState(updateSearchFromWindow);
const ctx: PageContext = {
search,
currPage,
allPages: pageNames,
pushState,

Wyświetl plik

@ -140,10 +140,12 @@ async function exportGif(
});
}
export type AnimationRenderer = (time: number) => JSX.Element;
export type ExportableAnimation = {
duration: number;
fps?: number;
render: (time: number) => JSX.Element;
render: AnimationRenderer;
};
export const ExportWidget: React.FC<{

Wyświetl plik

@ -1,6 +1,6 @@
import classNames from "classnames";
import React from "react";
import { float, NumericRange, slugify } from "./util";
import { float, NumericRange, slugify, toFriendlyDecimal } from "./util";
export type NumericSliderProps = NumericRange & {
id?: string;
@ -32,9 +32,9 @@ export const NumericSlider: React.FC<NumericSliderProps> = (props) => {
disabled={props.disabled}
onChange={(e) => props.onChange(float(e.target.value))}
/>
<span>
<span className="slider-value">
{" "}
{props.value}
{toFriendlyDecimal(props.value)}
{props.valueSuffix}
</span>
</span>

Wyświetl plik

@ -0,0 +1,113 @@
import React, { useContext, useEffect, useMemo, useState } from "react";
import { PageContext, PAGE_QUERY_ARG } from "./page";
export type ComponentWithShareableStateProps<T> = {
/** The default state to use when the component is first rendered. */
defaults: T;
/**
* Callback to trigger whenever the shareable state changes. Note
* that each call will add a new entry to the browser's navigation history.
* As such, every call to this function will mark a boundary where the
* user can press the "back" button to go back to the previous state.
*/
onChange: (value: T) => void;
};
export type PageWithShareableStateOptions<T> = {
/** The default shareable state. */
defaultValue: T;
/**
* Deserialize the given state, throwing an exception
* if it's invalid in any way.
*/
deserialize: (value: string) => T;
/** Serialize the given state to a string. */
serialize: (value: T) => string;
/** Component to render the page. */
component: React.ComponentType<ComponentWithShareableStateProps<T>>;
};
/** The query string argument that will store the serialized state. */
export const STATE_QUERY_ARG = "s";
/**
* Create a component that represents a page which exposes some
* aspect of its state in the current URL, so that it can be
* easily shared, recorded in the browser history, etc.
*/
export function createPageWithShareableState<T>({
defaultValue,
deserialize,
serialize,
component,
}: PageWithShareableStateOptions<T>): React.FC<{}> {
const Component = component;
const PageWithShareableState: React.FC<{}> = () => {
const { search, pushState, currPage } = useContext(PageContext);
/** The current serialized state, as reflected in the URL bar. */
const state = search.get(STATE_QUERY_ARG) || serialize(defaultValue);
/**
* What we think the latest serialized state is; used to determine whether
* the user navigated in their browser history.
*/
const [latestState, setLatestState] = useState(state);
/**
* The key to use when rendering our page component. This will
* be incremented whenever the user navigates their browser
* history, to ensure that our component resets itself to the
* default state we pass in.
*/
const [key, setKey] = useState(0);
/**
* Remembers whether we're in the middle of an update triggered by
* our own component.
*/
const [isInOnChange, setIsInOnChange] = useState(false);
/** The default state from th URL, which we'll pass into our component. */
let defaults: T = defaultValue;
try {
defaults = deserialize(state || "");
} catch (e) {
console.log(`Error deserializing state: ${e}`);
}
const onChange = useMemo(
() => (value: T) => {
const newState = serialize(value);
if (state !== newState) {
const newSearch = new URLSearchParams();
newSearch.set(PAGE_QUERY_ARG, currPage);
newSearch.set(STATE_QUERY_ARG, newState);
setIsInOnChange(true);
setLatestState(newState);
pushState("?" + newSearch.toString());
setIsInOnChange(false);
}
},
[state, currPage]
);
useEffect(() => {
if (!isInOnChange && latestState !== state) {
// The user navigated in their browser.
setLatestState(state);
setKey(key + 1);
}
});
return <Component key={key} defaults={defaults} onChange={onChange} />;
};
return PageWithShareableState;
}

Wyświetl plik

@ -5,11 +5,13 @@ export type PageContext = {
currPage: PageName;
allPages: PageName[];
pushState: (href: string) => void;
search: URLSearchParams;
};
export const PageContext = React.createContext<PageContext>({
currPage: "vocabulary",
allPages: [],
search: new URLSearchParams(),
pushState: () => {
throw new Error("No page context is defined!");
},

Wyświetl plik

@ -1,29 +1,30 @@
import React, { useRef, useState } from "react";
import { AutoSizingSvg } from "../auto-sizing-svg";
import { ExportWidget } from "../export-svg";
import { HoverDebugHelper } from "../hover-debug-helper";
import { NumericSlider } from "../numeric-slider";
import React, { useMemo, useRef, useState } from "react";
import { AutoSizingSvg } from "../../auto-sizing-svg";
import { AnimationRenderer, ExportWidget } from "../../export-svg";
import { HoverDebugHelper } from "../../hover-debug-helper";
import { NumericSlider } from "../../numeric-slider";
import {
noFillIfShowingSpecs,
SvgSymbolContext,
swapColors,
} from "../svg-symbol";
import { VocabularyWidget } from "../vocabulary-widget";
import { svgRotate, svgScale, SvgTransform } from "../svg-transform";
import { SvgVocabulary } from "../svg-vocabulary";
import { isEvenNumber, NumericRange } from "../util";
import { Random } from "../random";
import { Checkbox } from "../checkbox";
} from "../../svg-symbol";
import { VocabularyWidget } from "../../vocabulary-widget";
import { svgRotate, svgScale, SvgTransform } from "../../svg-transform";
import { SvgVocabulary } from "../../svg-vocabulary";
import { isEvenNumber, NumericRange, secsToMsecs } from "../../util";
import { Random } from "../../random";
import { Checkbox } from "../../checkbox";
import {
CompositionContextWidget,
createSvgCompositionContext,
} from "../svg-composition-context";
import { Page } from "../page";
import { MandalaCircle, MandalaCircleParams } from "../mandala-circle";
import { useAnimationPct } from "../animation";
import { RandomizerWidget } from "../randomizer-widget";
} from "../../svg-composition-context";
import { Page } from "../../page";
import { MandalaCircle, MandalaCircleParams } from "../../mandala-circle";
import { useAnimationPct } from "../../animation";
import { RandomizerWidget } from "../../randomizer-widget";
import { useDebouncedEffect } from "../../use-debounced-effect";
type ExtendedMandalaCircleParams = MandalaCircleParams & {
export type ExtendedMandalaCircleParams = MandalaCircleParams & {
scaling: number;
rotation: number;
symbolScaling: number;
@ -95,7 +96,7 @@ const DEFAULT_DURATION_SECS = 3;
const ExtendedMandalaCircle: React.FC<
ExtendedMandalaCircleParams & SvgSymbolContext
> = ({ scaling, rotation, symbolScaling, symbolRotation, ...props }) => {
> = ({ scaling, rotation, symbolScaling, symbolRotation, ...props }) => {
props = {
...props,
symbolTransforms: [svgScale(symbolScaling), svgRotate(symbolRotation)],
@ -122,12 +123,6 @@ function animateMandalaCircleParams(
return value;
}
function isAnyMandalaCircleAnimated(
values: ExtendedMandalaCircleParams[]
): boolean {
return values.some((value) => value.animateSymbolRotation);
}
const ExtendedMandalaCircleParamsWidget: React.FC<{
idPrefix: string;
value: ExtendedMandalaCircleParams;
@ -213,24 +208,34 @@ function getRandomCircleParams(rng: Random): MandalaCircleParams {
};
}
export const MandalaPage: React.FC<{}> = () => {
const svgRef = useRef<SVGSVGElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const [circle1, setCircle1] = useState(CIRCLE_1_DEFAULTS);
const [circle2, setCircle2] = useState(CIRCLE_2_DEFAULTS);
const [durationSecs, setDurationSecs] = useState(DEFAULT_DURATION_SECS);
const [baseCompCtx, setBaseCompCtx] = useState(createSvgCompositionContext());
const [useTwoCircles, setUseTwoCircles] = useState(false);
const [invertCircle2, setInvertCircle2] = useState(true);
const [firstBehindSecond, setFirstBehindSecond] = useState(false);
const durationMsecs = durationSecs * 1000;
const isAnimated = isAnyMandalaCircleAnimated([circle1, circle2]);
const animPct = useAnimationPct(isAnimated ? durationMsecs : 0);
const symbolCtx = noFillIfShowingSpecs(baseCompCtx);
export const MANDALA_DESIGN_DEFAULTS = {
circle1: CIRCLE_1_DEFAULTS,
circle2: CIRCLE_2_DEFAULTS,
durationSecs: DEFAULT_DURATION_SECS,
baseCompCtx: createSvgCompositionContext(),
useTwoCircles: false,
invertCircle2: true,
firstBehind: false,
};
export type MandalaDesign = typeof MANDALA_DESIGN_DEFAULTS;
function isDesignAnimated({ circle1, circle2 }: MandalaDesign): boolean {
return [circle1, circle2].some((value) => value.animateSymbolRotation);
}
function createAnimationRenderer({
baseCompCtx,
invertCircle2,
circle1,
circle2,
useTwoCircles,
firstBehind,
}: MandalaDesign): AnimationRenderer {
const symbolCtx = noFillIfShowingSpecs(baseCompCtx);
const circle2SymbolCtx = invertCircle2 ? swapColors(symbolCtx) : symbolCtx;
const makeMandala = (animPct: number): JSX.Element => {
return (animPct) => {
const circles = [
<ExtendedMandalaCircle
key="first"
@ -247,13 +252,73 @@ export const MandalaPage: React.FC<{}> = () => {
{...circle2SymbolCtx}
/>
);
if (firstBehindSecond) {
if (firstBehind) {
circles.reverse();
}
}
return <SvgTransform transform={svgScale(0.5)}>{circles}</SvgTransform>;
};
}
const AnimatedMandala: React.FC<{
config: MandalaDesign;
render: AnimationRenderer;
}> = ({ config, render }) => {
const animPct = useAnimationPct(
isDesignAnimated(config) ? secsToMsecs(config.durationSecs) : 0
);
return <>{render(animPct)}</>;
};
/**
* A mandala page that starts with the given default mandala configuration.
*
* The given handler will be called whenever the user changes the
* configuration.
*
* Note that the default is only used to determine the initial state of
* the component at mount. Any changes to the prop once the component has
* been mounted are ignored.
*/
export const MandalaPageWithDefaults: React.FC<{
defaults: MandalaDesign;
onChange: (defaults: MandalaDesign) => void;
}> = ({ defaults, onChange }) => {
const svgRef = useRef<SVGSVGElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const [circle1, setCircle1] = useState(defaults.circle1);
const [circle2, setCircle2] = useState(defaults.circle2);
const [durationSecs, setDurationSecs] = useState(defaults.durationSecs);
const [baseCompCtx, setBaseCompCtx] = useState(defaults.baseCompCtx);
const [useTwoCircles, setUseTwoCircles] = useState(defaults.useTwoCircles);
const [invertCircle2, setInvertCircle2] = useState(defaults.invertCircle2);
const [firstBehind, setFirstBehind] = useState(defaults.firstBehind);
const design: MandalaDesign = useMemo(
() => ({
circle1,
circle2,
durationSecs,
baseCompCtx,
useTwoCircles,
invertCircle2,
firstBehind,
}),
[
circle1,
circle2,
durationSecs,
baseCompCtx,
useTwoCircles,
invertCircle2,
firstBehind,
]
);
const isAnimated = isDesignAnimated(design);
const render = useMemo(() => createAnimationRenderer(design), [design]);
useDebouncedEffect(250, () => onChange(design), [onChange, design]);
return (
<Page title="Mandala!">
@ -289,8 +354,8 @@ export const MandalaPage: React.FC<{}> = () => {
/>{" "}
<Checkbox
label="Place behind first circle"
value={firstBehindSecond}
onChange={setFirstBehindSecond}
value={firstBehind}
onChange={setFirstBehind}
/>
</fieldset>
)}
@ -317,7 +382,7 @@ export const MandalaPage: React.FC<{}> = () => {
basename="mandala"
svgRef={svgRef}
animate={
isAnimated && { duration: durationMsecs, render: makeMandala }
isAnimated && { duration: secsToMsecs(durationSecs), render }
}
/>
</div>
@ -333,7 +398,7 @@ export const MandalaPage: React.FC<{}> = () => {
bgColor={baseCompCtx.background}
sizeToElement={canvasRef}
>
{makeMandala(animPct)}
<AnimatedMandala config={defaults} render={render} />
</AutoSizingSvg>
</HoverDebugHelper>
</div>

Wyświetl plik

@ -0,0 +1,13 @@
import { createPageWithShareableState } from "../../page-with-shareable-state";
import { MANDALA_DESIGN_DEFAULTS, MandalaPageWithDefaults } from "./core";
import {
deserializeMandalaDesign,
serializeMandalaDesign,
} from "./serialization";
export const MandalaPage = createPageWithShareableState({
defaultValue: MANDALA_DESIGN_DEFAULTS,
serialize: serializeMandalaDesign,
deserialize: deserializeMandalaDesign,
component: MandalaPageWithDefaults,
});

Wyświetl plik

@ -0,0 +1,43 @@
{
"type": "record",
"name": "AvroMandalaDesign",
"fields": [
{
"name": "circles",
"type": {
"type": "array",
"items": {
"name": "AvroCircle",
"type": "record",
"fields": [
{ "name": "symbol", "type": "string" },
{ "name": "radius", "type": "float" },
{ "name": "numSymbols", "type": "int" },
{ "name": "invertEveryOtherSymbol", "type": "boolean" },
{ "name": "scaling", "type": "float" },
{ "name": "rotation", "type": "float" },
{ "name": "symbolScaling", "type": "float" },
{ "name": "symbolRotation", "type": "float" },
{ "name": "animateSymbolRotation", "type": "boolean" }
]
}
}
},
{
"name": "baseCompCtx",
"type": {
"name": "AvroSvgCompositionContext",
"type": "record",
"fields": [
{ "name": "stroke", "type": "int" },
{ "name": "fill", "type": "int" },
{ "name": "background", "type": "int" },
{ "name": "uniformStrokeWidth", "type": "float" }
]
}
},
{ "name": "durationSecs", "type": "float" },
{ "name": "invertCircle2", "type": "boolean" },
{ "name": "firstBehind", "type": "boolean" }
]
}

Wyświetl plik

@ -0,0 +1,21 @@
import {
ColorPacker,
serializeMandalaDesign,
deserializeMandalaDesign,
} from "./serialization";
import { MANDALA_DESIGN_DEFAULTS } from "./core";
describe("AvroColorConverter", () => {
it("converts strings to numbers", () => {
expect(ColorPacker.pack("#abcdef")).toEqual(0xabcdef);
});
it("converts numbers to strings", () => {
expect(ColorPacker.unpack(0xabcdef)).toEqual("#abcdef");
});
});
test("Mandala design serialization/desrialization works", () => {
const s = serializeMandalaDesign(MANDALA_DESIGN_DEFAULTS);
expect(deserializeMandalaDesign(s)).toEqual(MANDALA_DESIGN_DEFAULTS);
});

Wyświetl plik

@ -0,0 +1,115 @@
import { SvgVocabulary } from "../../svg-vocabulary";
import { SvgCompositionContext } from "../../svg-composition-context";
import MandalaAvsc from "./mandala-design.avsc.json";
import type {
AvroCircle,
AvroMandalaDesign,
AvroSvgCompositionContext,
} from "./mandala-design.avsc";
import * as avro from "avro-js";
import { clampedByteToHex } from "../../random-colors";
import {
MANDALA_DESIGN_DEFAULTS,
ExtendedMandalaCircleParams,
MandalaDesign,
} from "./core";
import { fromBase64, toBase64 } from "../../base64";
const avroMandalaDesign = avro.parse<AvroMandalaDesign>(MandalaAvsc);
/**
* A generic interface for "packing" one type to a different representation
* for the purposes of serialization, and "unpacking" the packed type
* back to its original representation (for deserialization).
*/
interface Packer<UnpackedType, PackedType> {
pack(value: UnpackedType): PackedType;
unpack(value: PackedType): UnpackedType;
}
const CirclePacker: Packer<ExtendedMandalaCircleParams, AvroCircle> = {
pack: ({ data, ...circle }) => ({
...circle,
symbol: data.name,
}),
unpack: ({ symbol, ...circle }) => ({
...circle,
data: SvgVocabulary.get(symbol),
}),
};
const SvgCompositionContextPacker: Packer<
SvgCompositionContext,
AvroSvgCompositionContext
> = {
pack: (ctx) => ({
...ctx,
fill: ColorPacker.pack(ctx.fill),
stroke: ColorPacker.pack(ctx.stroke),
background: ColorPacker.pack(ctx.background),
uniformStrokeWidth: ctx.uniformStrokeWidth || 1,
}),
unpack: (ctx) => ({
...ctx,
fill: ColorPacker.unpack(ctx.fill),
stroke: ColorPacker.unpack(ctx.stroke),
background: ColorPacker.unpack(ctx.background),
showSpecs: false,
}),
};
export const ColorPacker: Packer<string, number> = {
pack: (string) => {
const red = parseInt(string.substring(1, 3), 16);
const green = parseInt(string.substring(3, 5), 16);
const blue = parseInt(string.substring(5, 7), 16);
return (red << 16) + (green << 8) + blue;
},
unpack: (number) => {
const red = (number >> 16) & 0xff;
const green = (number >> 8) & 0xff;
const blue = number & 0xff;
return "#" + [red, green, blue].map(clampedByteToHex).join("");
},
};
const DesignConfigPacker: Packer<MandalaDesign, AvroMandalaDesign> = {
pack: (value) => {
const circles: AvroCircle[] = [CirclePacker.pack(value.circle1)];
if (value.useTwoCircles) {
circles.push(CirclePacker.pack(value.circle2));
}
return {
...value,
circles,
baseCompCtx: SvgCompositionContextPacker.pack(value.baseCompCtx),
};
},
unpack: ({ circles, ...value }) => {
if (circles.length === 0) {
throw new Error(`Circles must have at least one item!`);
}
const useTwoCircles = circles.length > 1;
const circle1 = CirclePacker.unpack(circles[0]);
const circle2 = useTwoCircles
? CirclePacker.unpack(circles[1])
: MANDALA_DESIGN_DEFAULTS.circle2;
return {
...value,
baseCompCtx: SvgCompositionContextPacker.unpack(value.baseCompCtx),
circle1,
circle2,
useTwoCircles,
};
},
};
export function serializeMandalaDesign(value: MandalaDesign): string {
const buf = avroMandalaDesign.toBuffer(DesignConfigPacker.pack(value));
return toBase64(buf);
}
export function deserializeMandalaDesign(value: string): MandalaDesign {
const buf = fromBase64(value);
return DesignConfigPacker.unpack(avroMandalaDesign.fromBuffer(buf));
}

Wyświetl plik

@ -0,0 +1,18 @@
import { useEffect } from "react";
/**
* Like useEffect(), but ensures that the effect is only
* called when its dependencies haven't changed for the
* given number of milliseconds.
*/
export function useDebouncedEffect(
ms: number,
effect: React.EffectCallback,
deps: React.DependencyList
) {
useEffect(() => {
const timeout = setTimeout(effect, ms);
return () => clearTimeout(timeout);
}, [...deps, ms]);
}

Wyświetl plik

@ -1,4 +1,11 @@
import { flatten, float, inclusiveRange, rad2deg, range } from "./util";
import {
flatten,
float,
inclusiveRange,
rad2deg,
range,
toFriendlyDecimal,
} from "./util";
describe("float", () => {
it("converts strings", () => {
@ -35,3 +42,7 @@ test("range() works", () => {
test("inclusiveRange() works", () => {
expect(inclusiveRange({ min: 0, max: 1, step: 0.5 })).toEqual([0, 0.5, 1]);
});
test("toFriendlyDecimal() works", () => {
expect(toFriendlyDecimal(1.850000000143)).toEqual("1.85");
});

Wyświetl plik

@ -78,3 +78,22 @@ export function slugify(text: string) {
export function isEvenNumber(value: number) {
return value % 2 === 0;
}
/**
* Convert the given number of seconds (float) to milliseconds (integer).
*/
export function secsToMsecs(secs: number): number {
return Math.floor(secs * 1000);
}
/**
* Returns the given number to a "friendly-looking" human
* representation that is not ridiculously long. For example,
* it will return "1.85" instead of "1.850000000143".
*/
export function toFriendlyDecimal(value: number, maxDecimalDigits = 2): string {
const str = value.toString();
const fixedStr = value.toFixed(maxDecimalDigits);
return str.length < fixedStr.length ? str : fixedStr;
}

172
package-lock.json wygenerowano
Wyświetl plik

@ -750,6 +750,21 @@
"@babel/helper-plugin-utils": "^7.10.4"
}
},
"@babel/plugin-transform-react-display-name": {
"version": "7.12.13",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.12.13.tgz",
"integrity": "sha512-MprESJzI9O5VnJZrL7gg1MpdqmiFcUv41Jc7SahxYsNP2kDkFqClxxTZq+1Qv4AFCamm+GXMRDQINNn+qrxmiA==",
"requires": {
"@babel/helper-plugin-utils": "^7.12.13"
},
"dependencies": {
"@babel/helper-plugin-utils": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.13.0.tgz",
"integrity": "sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ=="
}
}
},
"@babel/plugin-transform-react-jsx": {
"version": "7.12.12",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.12.tgz",
@ -762,6 +777,75 @@
"@babel/types": "^7.12.12"
}
},
"@babel/plugin-transform-react-jsx-development": {
"version": "7.12.17",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.12.17.tgz",
"integrity": "sha512-BPjYV86SVuOaudFhsJR1zjgxxOhJDt6JHNoD48DxWEIxUCAMjV1ys6DYw4SDYZh0b1QsS2vfIA9t/ZsQGsDOUQ==",
"requires": {
"@babel/plugin-transform-react-jsx": "^7.12.17"
},
"dependencies": {
"@babel/helper-annotate-as-pure": {
"version": "7.12.13",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.13.tgz",
"integrity": "sha512-7YXfX5wQ5aYM/BOlbSccHDbuXXFPxeoUmfWtz8le2yTkTZc+BxsiEnENFoi2SlmA8ewDkG2LgIMIVzzn2h8kfw==",
"requires": {
"@babel/types": "^7.12.13"
}
},
"@babel/helper-module-imports": {
"version": "7.13.12",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.13.12.tgz",
"integrity": "sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA==",
"requires": {
"@babel/types": "^7.13.12"
}
},
"@babel/helper-plugin-utils": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.13.0.tgz",
"integrity": "sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ=="
},
"@babel/plugin-syntax-jsx": {
"version": "7.12.13",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.13.tgz",
"integrity": "sha512-d4HM23Q1K7oq/SLNmG6mRt85l2csmQ0cHRaxRXjKW0YFdEXqlZ5kzFQKH5Uc3rDJECgu+yCRgPkG04Mm98R/1g==",
"requires": {
"@babel/helper-plugin-utils": "^7.12.13"
}
},
"@babel/plugin-transform-react-jsx": {
"version": "7.13.12",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.13.12.tgz",
"integrity": "sha512-jcEI2UqIcpCqB5U5DRxIl0tQEProI2gcu+g8VTIqxLO5Iidojb4d77q+fwGseCvd8af/lJ9masp4QWzBXFE2xA==",
"requires": {
"@babel/helper-annotate-as-pure": "^7.12.13",
"@babel/helper-module-imports": "^7.13.12",
"@babel/helper-plugin-utils": "^7.13.0",
"@babel/plugin-syntax-jsx": "^7.12.13",
"@babel/types": "^7.13.12"
}
},
"@babel/types": {
"version": "7.13.17",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.17.tgz",
"integrity": "sha512-RawydLgxbOPDlTLJNtoIypwdmAy//uQIzlKt2+iBiJaRlVuI6QLUxVAyWGNfOzp8Yu4L4lLIacoCyTNtpb4wiA==",
"requires": {
"@babel/helper-validator-identifier": "^7.12.11",
"to-fast-properties": "^2.0.0"
}
}
}
},
"@babel/plugin-transform-react-pure-annotations": {
"version": "7.12.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.12.1.tgz",
"integrity": "sha512-RqeaHiwZtphSIUZ5I85PEH19LOSzxfuEazoY7/pWASCAIBuATQzpSVD+eT6MebeeZT2F4eSL0u4vw6n4Nm0Mjg==",
"requires": {
"@babel/helper-annotate-as-pure": "^7.10.4",
"@babel/helper-plugin-utils": "^7.10.4"
}
},
"@babel/plugin-transform-regenerator": {
"version": "7.12.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.1.tgz",
@ -931,6 +1015,76 @@
"esutils": "^2.0.2"
}
},
"@babel/preset-react": {
"version": "7.13.13",
"resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.13.13.tgz",
"integrity": "sha512-gx+tDLIE06sRjKJkVtpZ/t3mzCDOnPG+ggHZG9lffUbX8+wC739x20YQc9V35Do6ZAxaUc/HhVHIiOzz5MvDmA==",
"requires": {
"@babel/helper-plugin-utils": "^7.13.0",
"@babel/helper-validator-option": "^7.12.17",
"@babel/plugin-transform-react-display-name": "^7.12.13",
"@babel/plugin-transform-react-jsx": "^7.13.12",
"@babel/plugin-transform-react-jsx-development": "^7.12.17",
"@babel/plugin-transform-react-pure-annotations": "^7.12.1"
},
"dependencies": {
"@babel/helper-annotate-as-pure": {
"version": "7.12.13",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.13.tgz",
"integrity": "sha512-7YXfX5wQ5aYM/BOlbSccHDbuXXFPxeoUmfWtz8le2yTkTZc+BxsiEnENFoi2SlmA8ewDkG2LgIMIVzzn2h8kfw==",
"requires": {
"@babel/types": "^7.12.13"
}
},
"@babel/helper-module-imports": {
"version": "7.13.12",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.13.12.tgz",
"integrity": "sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA==",
"requires": {
"@babel/types": "^7.13.12"
}
},
"@babel/helper-plugin-utils": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.13.0.tgz",
"integrity": "sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ=="
},
"@babel/helper-validator-option": {
"version": "7.12.17",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.12.17.tgz",
"integrity": "sha512-TopkMDmLzq8ngChwRlyjR6raKD6gMSae4JdYDB8bByKreQgG0RBTuKe9LRxW3wFtUnjxOPRKBDwEH6Mg5KeDfw=="
},
"@babel/plugin-syntax-jsx": {
"version": "7.12.13",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.13.tgz",
"integrity": "sha512-d4HM23Q1K7oq/SLNmG6mRt85l2csmQ0cHRaxRXjKW0YFdEXqlZ5kzFQKH5Uc3rDJECgu+yCRgPkG04Mm98R/1g==",
"requires": {
"@babel/helper-plugin-utils": "^7.12.13"
}
},
"@babel/plugin-transform-react-jsx": {
"version": "7.13.12",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.13.12.tgz",
"integrity": "sha512-jcEI2UqIcpCqB5U5DRxIl0tQEProI2gcu+g8VTIqxLO5Iidojb4d77q+fwGseCvd8af/lJ9masp4QWzBXFE2xA==",
"requires": {
"@babel/helper-annotate-as-pure": "^7.12.13",
"@babel/helper-module-imports": "^7.13.12",
"@babel/helper-plugin-utils": "^7.13.0",
"@babel/plugin-syntax-jsx": "^7.12.13",
"@babel/types": "^7.13.12"
}
},
"@babel/types": {
"version": "7.13.17",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.17.tgz",
"integrity": "sha512-RawydLgxbOPDlTLJNtoIypwdmAy//uQIzlKt2+iBiJaRlVuI6QLUxVAyWGNfOzp8Yu4L4lLIacoCyTNtpb4wiA==",
"requires": {
"@babel/helper-validator-identifier": "^7.12.11",
"to-fast-properties": "^2.0.0"
}
}
}
},
"@babel/preset-typescript": {
"version": "7.12.7",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.12.7.tgz",
@ -2074,6 +2228,19 @@
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg=="
},
"avro-js": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/avro-js/-/avro-js-1.10.2.tgz",
"integrity": "sha512-lkZAFBR54pmXQDWjDpZVZWWIJwi/3T3NED16ooiJp+sl0acFkBOOGL1OMguIP5P9A0R33s+eevy3ATIyqnxGVA==",
"requires": {
"underscore": "^1.12.0"
}
},
"avro-typescript": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/avro-typescript/-/avro-typescript-1.1.0.tgz",
"integrity": "sha512-U99M2xcaJIMAh6r2x2x1/qqw3oe4pig2iscDaCwWt/iv3uQN0TFWlJ3LqBfCstS/Zs4GDEsRBfG7qbH9I1l7eg=="
},
"aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
@ -9836,6 +10003,11 @@
}
}
},
"underscore": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
"integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g=="
},
"unicode-canonical-property-names-ecmascript": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz",

Wyświetl plik

@ -10,14 +10,23 @@
"typecheck": "tsc --noemit",
"test:watch": "jest --watch",
"test": "jest",
"build": "node build-vocabulary.js && rimraf dist && parcel build index.html -d dist --public-url .",
"watch": "node build-vocabulary.js && parcel index.html -d dist-watch"
"build-prereqs": "node build-vocabulary.js && node build-avro-typescript.js",
"build": "npm run build-prereqs && rimraf dist && parcel build index.html -d dist --public-url .",
"watch": "npm run build-prereqs && parcel index.html -d dist-watch"
},
"author": "",
"license": "ISC",
"jest": {
"testPathIgnorePatterns": [
"dist",
"dist-watch",
".cache"
]
},
"dependencies": {
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.13.13",
"@babel/preset-typescript": "^7.12.7",
"@babel/register": "^7.12.10",
"@types/cheerio": "^0.22.23",
@ -25,6 +34,8 @@
"@types/node": "^14.14.22",
"@types/react": "^17.0.1",
"@types/react-dom": "^17.0.0",
"avro-js": "^1.10.2",
"avro-typescript": "^1.1.0",
"babel-jest": "^26.6.3",
"cheerio": "^1.0.0-rc.5",
"classnames": "^2.3.1",

Wyświetl plik

@ -1,4 +1,9 @@
{
"exclude": [
"dist",
"dist-watch",
"vendor/**/*.js",
],
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
@ -8,7 +13,7 @@
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
"checkJs": true, /* Report errors in .js files. */
"jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */

12
vendor/avro-js.d.ts vendored 100644
Wyświetl plik

@ -0,0 +1,12 @@
// This is just a subset of the actual API; for full documentation, see:
//
// https://github.com/apache/avro/blob/master/lang/js/doc/API.md
declare module "avro-js" {
export type AvroType<T> = {
toBuffer(value: T): Buffer;
fromBuffer(value: Buffer): T;
};
export function parse<T>(schema: any): AvroType<T>;
}