Add a 'disable gradients' toggle. (#148)
This adds a toggle to disable gradients, fixing #140. On the Mandala page, this changes how our permalinks are structured, but does so in a backwards-compatible way, thanks to [Avro schema evolution][1]. Now the `s` querystring argument is prefixed with a schema version, the latest being `v2`. If this prefix is missing, it's assumed to be the very first schema version, `v1`, and interpreted accordingly. [1]: https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.htmlpull/151/head
rodzic
2f82e2dc31
commit
045e96d34d
|
@ -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 }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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" }
|
||||
]
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<AvroMandalaDesign>(MandalaAvsc);
|
||||
|
||||
/**
|
||||
|
@ -101,12 +104,30 @@ const DesignConfigPacker: Packer<MandalaDesign, AvroMandalaDesign> = {
|
|||
},
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
|
|
|
@ -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 : (
|
||||
<radialGradient id={id} cx={def.cx} cy={def.cy} r={def.r}>
|
||||
{stops}
|
||||
</radialGradient>
|
||||
);
|
||||
case "linearGradient":
|
||||
return (
|
||||
return ctx.disableGradients ? null : (
|
||||
<linearGradient id={id} x1={def.x1} y1={def.y1} x2={def.x2} y2={def.y2}>
|
||||
{stops}
|
||||
</linearGradient>
|
||||
|
|
|
@ -43,6 +43,11 @@ export function SymbolContextWidget<T extends SvgSymbolContext>({
|
|||
value={ctx.showSpecs}
|
||||
onChange={(showSpecs) => updateCtx({ showSpecs })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Disable gradients"
|
||||
value={ctx.disableGradients}
|
||||
onChange={(disableGradients) => updateCtx({ disableGradients })}
|
||||
/>
|
||||
{ctx.uniformStrokeWidth !== undefined && (
|
||||
<div className="thingy">
|
||||
<NumericSlider
|
||||
|
|
|
@ -26,6 +26,13 @@ function useUniqueIds(count: number): string[] {
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A regular expression to match a `url()` function expression
|
||||
* that points to an anchor on the current document. For example,
|
||||
* when passed the string `url(#boop)`, it will match to `boop`.
|
||||
*/
|
||||
export const URL_FUNC_TO_ANCHOR_RE = /^url\(\#(.+)\)$/;
|
||||
|
||||
export class UniqueIdMap extends Map<string, string> {
|
||||
/**
|
||||
* Returns the globally-unique identifier for the given
|
||||
|
@ -56,7 +63,7 @@ export class UniqueIdMap extends Map<string, string> {
|
|||
* 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;
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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<T> = {
|
||||
toBuffer(value: T): Buffer;
|
||||
fromBuffer(value: Buffer): T;
|
||||
fromBuffer(value: Buffer, resolver?: Resolver, noCheck?: boolean): T;
|
||||
createResolver(otherType: AvroType<any>): Resolver;
|
||||
};
|
||||
|
||||
export function parse<T>(schema: any): AvroType<T>;
|
||||
|
|
Ładowanie…
Reference in New Issue