diff --git a/lib/pages/mandala-page/mandala-design.avsc.json b/lib/pages/mandala-page/mandala-design.avsc.json index 78fd8fa..a2f5dbe 100644 --- a/lib/pages/mandala-page/mandala-design.avsc.json +++ b/lib/pages/mandala-page/mandala-design.avsc.json @@ -32,7 +32,8 @@ { "name": "stroke", "type": "int" }, { "name": "fill", "type": "int" }, { "name": "background", "type": "int" }, - { "name": "uniformStrokeWidth", "type": "float" } + { "name": "uniformStrokeWidth", "type": "float" }, + { "name": "disableGradients", "type": "boolean", "default": false } ] } }, diff --git a/lib/pages/mandala-page/mandala-design.v1.avsc.json b/lib/pages/mandala-page/mandala-design.v1.avsc.json new file mode 100644 index 0000000..78fd8fa --- /dev/null +++ b/lib/pages/mandala-page/mandala-design.v1.avsc.json @@ -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" } + ] +} diff --git a/lib/pages/mandala-page/serialization.test.ts b/lib/pages/mandala-page/serialization.test.ts index a982ca3..29003c5 100644 --- a/lib/pages/mandala-page/serialization.test.ts +++ b/lib/pages/mandala-page/serialization.test.ts @@ -15,7 +15,21 @@ describe("AvroColorConverter", () => { }); }); -test("Mandala design serialization/desrialization works", () => { - const s = serializeMandalaDesign(MANDALA_DESIGN_DEFAULTS); - expect(deserializeMandalaDesign(s)).toEqual(MANDALA_DESIGN_DEFAULTS); +describe("Mandala design serialization/desrialization", () => { + // Helper to make it easy for us to copy/paste from URLs. + const decodeAndDeserialize = (s: string) => + deserializeMandalaDesign(decodeURIComponent(s)); + + it("deserializes from v1", () => { + const design = decodeAndDeserialize( + "AgZleWUAAB9DCAEAAIA%2FAAAAAAAAgD8AAAAAAADQlAKCjj3Ij%2F4PAACAPwAAQEABAA%3D%3D" + ); + expect(design.baseCompCtx.disableGradients).toBe(false); + expect(design.circle1.numSymbols).toBe(4); + }); + + it("works", () => { + const s = serializeMandalaDesign(MANDALA_DESIGN_DEFAULTS); + expect(deserializeMandalaDesign(s)).toEqual(MANDALA_DESIGN_DEFAULTS); + }); }); diff --git a/lib/pages/mandala-page/serialization.ts b/lib/pages/mandala-page/serialization.ts index ff509c4..c55ec2f 100644 --- a/lib/pages/mandala-page/serialization.ts +++ b/lib/pages/mandala-page/serialization.ts @@ -1,6 +1,7 @@ import { SvgVocabulary } from "../../svg-vocabulary"; import { SvgCompositionContext } from "../../svg-composition-context"; import MandalaAvsc from "./mandala-design.avsc.json"; +import MandalaAvscV1 from "./mandala-design.v1.avsc.json"; import type { AvroCircle, AvroMandalaDesign, @@ -16,6 +17,8 @@ import { import { fromBase64, toBase64 } from "../../base64"; import { clampedBytesToRGBColor } from "../../color-util"; +const LATEST_VERSION = "v2"; + const avroMandalaDesign = avro.parse(MandalaAvsc); /** @@ -101,12 +104,30 @@ const DesignConfigPacker: Packer = { }, }; +function loadSchemaVersion(version: string, buf: Buffer): AvroMandalaDesign { + switch (version) { + case "v1": + const res = avroMandalaDesign.createResolver(avro.parse(MandalaAvscV1)); + return avroMandalaDesign.fromBuffer(buf, res); + + case LATEST_VERSION: + return avroMandalaDesign.fromBuffer(buf); + + default: + throw new Error(`Don't know how to load schema version ${version}`); + } +} + export function serializeMandalaDesign(value: MandalaDesign): string { const buf = avroMandalaDesign.toBuffer(DesignConfigPacker.pack(value)); - return toBase64(buf); + return `${LATEST_VERSION}.${toBase64(buf)}`; } export function deserializeMandalaDesign(value: string): MandalaDesign { + let version = "v1"; + if (value.indexOf(".") !== -1) { + [version, value] = value.split(".", 2); + } const buf = fromBase64(value); - return DesignConfigPacker.unpack(avroMandalaDesign.fromBuffer(buf)); + return DesignConfigPacker.unpack(loadSchemaVersion(version, buf)); } diff --git a/lib/svg-symbol.tsx b/lib/svg-symbol.tsx index f9a7e62..266313a 100644 --- a/lib/svg-symbol.tsx +++ b/lib/svg-symbol.tsx @@ -4,7 +4,11 @@ import { BBox } from "../vendor/bezier-js"; import { FILL_REPLACEMENT_COLOR, STROKE_REPLACEMENT_COLOR } from "./colors"; import { AttachmentPointType, PointWithNormal, Specs } from "./specs"; import type { SvgSymbolMetadata } from "./svg-symbol-metadata"; -import { UniqueIdMap, useUniqueIdMap } from "./unique-id"; +import { + UniqueIdMap, + URL_FUNC_TO_ANCHOR_RE, + useUniqueIdMap, +} from "./unique-id"; import { VisibleSpecs } from "./visible-specs"; const DEFAULT_UNIFORM_STROKE_WIDTH = 1; @@ -87,6 +91,12 @@ export type SvgSymbolContext = { * *not* vary as the symbol is scaled. */ uniformStrokeWidth?: number; + + /** + * Whether or not to disable any gradients in the symbol. Defaults + * to `false`. + */ + disableGradients: boolean; }; const DEFAULT_CONTEXT: SvgSymbolContext = { @@ -94,6 +104,7 @@ const DEFAULT_CONTEXT: SvgSymbolContext = { fill: "#ffffff", showSpecs: false, uniformStrokeWidth: DEFAULT_UNIFORM_STROKE_WIDTH, + disableGradients: false, }; /** @@ -141,6 +152,24 @@ function getColor( return color; } +function getFill( + ctx: SvgSymbolContext, + fill: string | undefined, + uidMap: UniqueIdMap +): string | undefined { + fill = getColor(ctx, fill); + if (fill) { + if (URL_FUNC_TO_ANCHOR_RE.test(fill)) { + if (ctx.disableGradients) { + fill = ctx.fill; + } else { + fill = uidMap.rewriteUrl(fill); + } + } + } + return fill; +} + function reactifySvgSymbolElement( ctx: SvgSymbolContext, uidMap: UniqueIdMap, @@ -149,11 +178,8 @@ function reactifySvgSymbolElement( ): JSX.Element { let { fill, stroke, strokeWidth } = el.props; let vectorEffect; - fill = getColor(ctx, fill); + fill = getFill(ctx, fill, uidMap); stroke = getColor(ctx, stroke); - if (fill) { - fill = uidMap.rewriteUrl(fill); - } if (strokeWidth !== undefined && typeof ctx.uniformStrokeWidth === "number") { strokeWidth = ctx.uniformStrokeWidth; vectorEffect = "non-scaling-stroke"; @@ -183,13 +209,13 @@ const SvgSymbolDef: React.FC< )); switch (def.type) { case "radialGradient": - return ( + return ctx.disableGradients ? null : ( {stops} ); case "linearGradient": - return ( + return ctx.disableGradients ? null : ( {stops} diff --git a/lib/symbol-context-widget.tsx b/lib/symbol-context-widget.tsx index 00a4293..659668d 100644 --- a/lib/symbol-context-widget.tsx +++ b/lib/symbol-context-widget.tsx @@ -43,6 +43,11 @@ export function SymbolContextWidget({ value={ctx.showSpecs} onChange={(showSpecs) => updateCtx({ showSpecs })} /> + updateCtx({ disableGradients })} + /> {ctx.uniformStrokeWidth !== undefined && (
{ /** * Returns the globally-unique identifier for the given @@ -56,7 +63,7 @@ export class UniqueIdMap extends Map { * that may refer to locally-unique identifiers. */ rewriteUrl(value: string): string { - const match = value.match(/^url\(\#(.+)\)$/); + const match = value.match(URL_FUNC_TO_ANCHOR_RE); if (!match) { return value; diff --git a/lib/use-debounced-effect.ts b/lib/use-debounced-effect.ts index 5732c54..a99a86e 100644 --- a/lib/use-debounced-effect.ts +++ b/lib/use-debounced-effect.ts @@ -1,18 +1,27 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; /** * Like useEffect(), but ensures that the effect is only * called when the callback hasn't changed for the - * given number of milliseconds. + * given number of milliseconds. It also doesn't trigger + * on initial mount--only when the callback *changes* from + * its value on initial mount. * * Note that this means that the callback itself needs * to be wrapped in something like `useCallback()`, or * else it may never be called! */ export function useDebouncedEffect(ms: number, effect: React.EffectCallback) { - useEffect(() => { - const timeout = setTimeout(effect, ms); + // https://stackoverflow.com/a/53180013/2422398 + const didMountRef = useRef(false); - return () => clearTimeout(timeout); - }, [effect, ms]); + useEffect(() => { + if (didMountRef.current) { + const timeout = setTimeout(effect, ms); + + return () => clearTimeout(timeout); + } else { + didMountRef.current = true; + } + }, [effect, ms, didMountRef]); } diff --git a/vendor/avro-js.d.ts b/vendor/avro-js.d.ts index 1235bef..4ee8f2f 100644 --- a/vendor/avro-js.d.ts +++ b/vendor/avro-js.d.ts @@ -3,9 +3,19 @@ // https://github.com/apache/avro/blob/master/lang/js/doc/API.md declare module "avro-js" { + /** + * Opaque type that represents an Avro resolver. For more details, see: + * + * https://github.com/apache/avro/blob/master/lang/js/doc/Advanced-usage.md + */ + export type Resolver = { + private _type: "resolver"; + }; + export type AvroType = { toBuffer(value: T): Buffer; - fromBuffer(value: Buffer): T; + fromBuffer(value: Buffer, resolver?: Resolver, noCheck?: boolean): T; + createResolver(otherType: AvroType): Resolver; }; export function parse(schema: any): AvroType;