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.html
pull/151/head
Atul Varma 2021-06-06 17:49:49 -04:00 zatwierdzone przez GitHub
rodzic 2f82e2dc31
commit 045e96d34d
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
9 zmienionych plików z 157 dodań i 21 usunięć

Wyświetl plik

@ -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 }
]
}
},

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

@ -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);
});
});

Wyświetl plik

@ -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));
}

Wyświetl plik

@ -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>

Wyświetl plik

@ -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

Wyświetl plik

@ -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;

Wyświetl plik

@ -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]);
}

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

@ -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>;