diff --git a/build-avro-typescript.js b/build-avro-typescript.js index fab954e..a401b4f 100644 --- a/build-avro-typescript.js +++ b/build-avro-typescript.js @@ -7,7 +7,10 @@ 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"]; +const AVSC_FILES = [ + "./lib/pages/mandala-page/mandala-design.avsc.json", + "./lib/pages/creature-page/creature-design.avsc.json", +]; /** * Convert the given Avro AVSC JSON file into its TypeScript representation, diff --git a/lib/pages/creature-page.tsx b/lib/pages/creature-page/core.tsx similarity index 80% rename from lib/pages/creature-page.tsx rename to lib/pages/creature-page/core.tsx index 80fa8a5..9fe90b6 100644 --- a/lib/pages/creature-page.tsx +++ b/lib/pages/creature-page/core.tsx @@ -1,38 +1,46 @@ -import React, { useContext, useMemo, useRef, useState } from "react"; -import { SvgVocabulary, SvgVocabularyWithBlank } from "../svg-vocabulary"; +import React, { + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; +import { SvgVocabulary, SvgVocabularyWithBlank } from "../../svg-vocabulary"; import { EMPTY_SVG_SYMBOL_DATA, noFillIfShowingSpecs, SvgSymbolData, -} from "../svg-symbol"; +} from "../../svg-symbol"; import { AttachmentPointType, ATTACHMENT_POINT_TYPES, iterAttachmentPoints, -} from "../specs"; -import { Random } from "../random"; -import { range } from "../util"; +} from "../../specs"; +import { Random } from "../../random"; +import { range } from "../../util"; -import { AutoSizingSvg } from "../auto-sizing-svg"; -import { ExportWidget } from "../export-svg"; +import { AutoSizingSvg } from "../../auto-sizing-svg"; +import { ExportWidget } from "../../export-svg"; import { CreatureContext, CreatureContextType, CreatureSymbol, NestedCreatureSymbol, -} from "../creature-symbol"; -import { HoverDebugHelper } from "../hover-debug-helper"; -import { svgScale, SvgTransform } from "../svg-transform"; -import { NumericSlider } from "../numeric-slider"; -import { Checkbox } from "../checkbox"; +} from "../../creature-symbol"; +import { HoverDebugHelper } from "../../hover-debug-helper"; +import { svgScale, SvgTransform } from "../../svg-transform"; +import { NumericSlider } from "../../numeric-slider"; +import { Checkbox } from "../../checkbox"; import { CompositionContextWidget, createSvgCompositionContext, -} from "../svg-composition-context"; -import { Page } from "../page"; -import { RandomizerWidget } from "../randomizer-widget"; -import { VocabularyWidget } from "../vocabulary-widget"; -import { createDistribution } from "../distribution"; +} from "../../svg-composition-context"; +import { Page } from "../../page"; +import { RandomizerWidget } from "../../randomizer-widget"; +import { VocabularyWidget } from "../../vocabulary-widget"; +import { createDistribution } from "../../distribution"; +import { ComponentWithShareableStateProps } from "../../page-with-shareable-state"; +import { useDebouncedEffect } from "../../use-debounced-effect"; /** * The minimum number of attachment points that any symbol used as the main body @@ -236,12 +244,24 @@ function repeatUntilSymbolIsIncluded( return createCreature(rng); } -export const CreaturePage: React.FC<{}> = () => { +export const CREATURE_DESIGN_DEFAULTS = { + randomSeed: 0, + randomlyInvert: true, + complexity: INITIAL_COMPLEXITY_LEVEL, + alwaysIncludeSymbol: EMPTY_SVG_SYMBOL_DATA, + compCtx: createSvgCompositionContext(), +}; + +export type CreatureDesign = typeof CREATURE_DESIGN_DEFAULTS; + +export const CreaturePageWithDefaults: React.FC< + ComponentWithShareableStateProps +> = ({ defaults, onChange }) => { const svgRef = useRef(null); - const [randomSeed, setRandomSeed] = useState(Date.now()); - const [randomlyInvert, setRandomlyInvert] = useState(true); - const [compCtx, setCompCtx] = useState(createSvgCompositionContext()); - const [complexity, setComplexity] = useState(INITIAL_COMPLEXITY_LEVEL); + const [randomSeed, setRandomSeed] = useState(defaults.randomSeed); + const [randomlyInvert, setRandomlyInvert] = useState(defaults.randomlyInvert); + const [compCtx, setCompCtx] = useState(defaults.compCtx); + const [complexity, setComplexity] = useState(defaults.complexity); const defaultCtx = useContext(CreatureContext); const newRandomSeed = () => setRandomSeed(Date.now()); const ctx: CreatureContextType = noFillIfShowingSpecs({ @@ -249,7 +269,7 @@ export const CreaturePage: React.FC<{}> = () => { ...compCtx, }); const [alwaysInclude, setAlwaysInclude] = useState( - EMPTY_SVG_SYMBOL_DATA + defaults.alwaysIncludeSymbol ); const creature = useMemo( () => @@ -264,6 +284,21 @@ export const CreaturePage: React.FC<{}> = () => { ), [alwaysInclude, complexity, randomSeed, randomlyInvert] ); + const design: CreatureDesign = useMemo( + () => ({ + randomSeed, + randomlyInvert, + complexity, + alwaysIncludeSymbol: alwaysInclude, + compCtx, + }), + [randomSeed, randomlyInvert, complexity, alwaysInclude, compCtx] + ); + + useDebouncedEffect( + 250, + useCallback(() => onChange(design), [onChange, design]) + ); return ( diff --git a/lib/pages/creature-page/creature-design.avsc.json b/lib/pages/creature-page/creature-design.avsc.json new file mode 100644 index 0000000..8d6f1e8 --- /dev/null +++ b/lib/pages/creature-page/creature-design.avsc.json @@ -0,0 +1,24 @@ +{ + "type": "record", + "name": "AvroCreatureDesign", + "fields": [ + { "name": "randomSeed", "type": "long" }, + { "name": "randomlyInvert", "type": "boolean" }, + { "name": "complexity", "type": "int" }, + { "name": "alwaysIncludeSymbol", "type": "string" }, + { + "name": "compCtx", + "type": { + "name": "AvroSvgCompositionContext", + "type": "record", + "fields": [ + { "name": "stroke", "type": "int" }, + { "name": "fill", "type": "int" }, + { "name": "background", "type": "int" }, + { "name": "uniformStrokeWidth", "type": "float" }, + { "name": "disableGradients", "type": "boolean", "default": true } + ] + } + } + ] +} diff --git a/lib/pages/creature-page/index.tsx b/lib/pages/creature-page/index.tsx new file mode 100644 index 0000000..508a35d --- /dev/null +++ b/lib/pages/creature-page/index.tsx @@ -0,0 +1,13 @@ +import { createPageWithShareableState } from "../../page-with-shareable-state"; +import { CreaturePageWithDefaults, CREATURE_DESIGN_DEFAULTS } from "./core"; +import { + deserializeCreatureDesign, + serializeCreatureDesign, +} from "./serialization"; + +export const CreaturePage = createPageWithShareableState({ + defaultValue: CREATURE_DESIGN_DEFAULTS, + serialize: serializeCreatureDesign, + deserialize: deserializeCreatureDesign, + component: CreaturePageWithDefaults, +}); diff --git a/lib/pages/creature-page/serialization.ts b/lib/pages/creature-page/serialization.ts new file mode 100644 index 0000000..c17e892 --- /dev/null +++ b/lib/pages/creature-page/serialization.ts @@ -0,0 +1,41 @@ +import * as avro from "avro-js"; +import { CreatureDesign } from "./core"; +import { AvroCreatureDesign } from "./creature-design.avsc"; +import { fromBase64, toBase64 } from "../../base64"; +import CreatureAvsc from "./creature-design.avsc.json"; +import { SvgVocabularyWithBlank } from "../../svg-vocabulary"; +import { Packer, SvgCompositionContextPacker } from "../../serialization"; + +const LATEST_VERSION = "v1"; + +const avroCreatureDesign = avro.parse(CreatureAvsc); + +const DesignConfigPacker: Packer = { + pack: (value) => { + return { + ...value, + alwaysIncludeSymbol: value.alwaysIncludeSymbol.name, + compCtx: SvgCompositionContextPacker.pack(value.compCtx), + }; + }, + unpack: (value) => { + return { + ...value, + alwaysIncludeSymbol: SvgVocabularyWithBlank.get( + value.alwaysIncludeSymbol + ), + compCtx: SvgCompositionContextPacker.unpack(value.compCtx), + }; + }, +}; + +export function serializeCreatureDesign(value: CreatureDesign): string { + const buf = avroCreatureDesign.toBuffer(DesignConfigPacker.pack(value)); + return `${LATEST_VERSION}.${toBase64(buf)}`; +} + +export function deserializeCreatureDesign(value: string): CreatureDesign { + const [_version, serialized] = value.split(".", 2); + const buf = fromBase64(serialized); + return DesignConfigPacker.unpack(avroCreatureDesign.fromBuffer(buf)); +} diff --git a/lib/pages/mandala-page/serialization.test.ts b/lib/pages/mandala-page/serialization.test.ts index 51aabc9..296d8e1 100644 --- a/lib/pages/mandala-page/serialization.test.ts +++ b/lib/pages/mandala-page/serialization.test.ts @@ -1,20 +1,9 @@ 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"); - }); -}); - describe("Mandala design serialization/desrialization", () => { // Helper to make it easy for us to copy/paste from URLs. const decodeAndDeserialize = (s: string) => diff --git a/lib/pages/mandala-page/serialization.ts b/lib/pages/mandala-page/serialization.ts index d59b9b3..e1f452a 100644 --- a/lib/pages/mandala-page/serialization.ts +++ b/lib/pages/mandala-page/serialization.ts @@ -1,12 +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, - AvroSvgCompositionContext, -} from "./mandala-design.avsc"; +import type { AvroCircle, AvroMandalaDesign } from "./mandala-design.avsc"; import * as avro from "avro-js"; import { MANDALA_DESIGN_DEFAULTS, @@ -15,22 +10,12 @@ import { getCirclesFromDesign, } from "./core"; import { fromBase64, toBase64 } from "../../base64"; -import { clampedBytesToRGBColor, parseHexColor } from "../../color-util"; +import { Packer, SvgCompositionContextPacker } from "../../serialization"; const LATEST_VERSION = "v2"; const avroMandalaDesign = avro.parse(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 { - pack(value: UnpackedType): PackedType; - unpack(value: PackedType): UnpackedType; -} - const CirclePacker: Packer = { pack: ({ data, ...circle }) => ({ ...circle, @@ -42,39 +27,6 @@ const CirclePacker: Packer = { }), }; -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 = { - pack: (string) => { - const [red, green, blue] = parseHexColor(string); - return (red << 16) + (green << 8) + blue; - }, - unpack: (number) => { - const red = (number >> 16) & 0xff; - const green = (number >> 8) & 0xff; - const blue = number & 0xff; - return clampedBytesToRGBColor([red, green, blue]); - }, -}; - const DesignConfigPacker: Packer = { pack: (value) => { return { diff --git a/lib/serialization.test.ts b/lib/serialization.test.ts new file mode 100644 index 0000000..2e4bbdf --- /dev/null +++ b/lib/serialization.test.ts @@ -0,0 +1,11 @@ +import { ColorPacker } from "./serialization"; + +describe("ColorPacker", () => { + it("converts strings to numbers", () => { + expect(ColorPacker.pack("#abcdef")).toEqual(0xabcdef); + }); + + it("converts numbers to strings", () => { + expect(ColorPacker.unpack(0xabcdef)).toEqual("#abcdef"); + }); +}); diff --git a/lib/serialization.ts b/lib/serialization.ts new file mode 100644 index 0000000..c49cdae --- /dev/null +++ b/lib/serialization.ts @@ -0,0 +1,46 @@ +import { clampedBytesToRGBColor, parseHexColor } from "./color-util"; +import { AvroSvgCompositionContext } from "./pages/mandala-page/mandala-design.avsc"; +import { SvgCompositionContext } from "./svg-composition-context"; + +/** + * 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). + */ +export interface Packer { + pack(value: UnpackedType): PackedType; + unpack(value: PackedType): UnpackedType; +} + +export const ColorPacker: Packer = { + pack: (string) => { + const [red, green, blue] = parseHexColor(string); + return (red << 16) + (green << 8) + blue; + }, + unpack: (number) => { + const red = (number >> 16) & 0xff; + const green = (number >> 8) & 0xff; + const blue = number & 0xff; + return clampedBytesToRGBColor([red, green, blue]); + }, +}; + +export 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, + }), +};