From 29ee6b70dd628987133dbc403dc2d8afecbc8e44 Mon Sep 17 00:00:00 2001 From: Glen Whitney Date: Wed, 15 Jan 2025 14:31:16 -0800 Subject: [PATCH 01/43] feat: add chroma.js colors as data type to mathjs --- package-lock.json | 26 ++++++++++++ package.json | 2 + src/shared/Chroma.ts | 69 +++++++++++++++++++++++++++++++ src/shared/__tests__/math.spec.ts | 64 ++++++++++++++++++++++++++++ src/shared/math.ts | 33 ++++++++++++++- 5 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 src/shared/Chroma.ts diff --git a/package-lock.json b/package-lock.json index 50b51ef8..3d9fb605 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "axios": "^1.6.8", "bigint-isqrt": "^0.3.2", "bigint-mod-arith": "^3.3.1", + "chroma-js": "^3.1.2", "dompurify": "^3.2.1", "interactjs": "^1.10.27", "p5": "^1.11.0", @@ -25,6 +26,7 @@ "@playwright/browser-firefox": "^1.46.1", "@playwright/test": "^1.46.1", "@tsconfig/node20": "^20.1.4", + "@types/chroma-js": "^3.1.0", "@types/jsdom": "^21.1.7", "@types/node": "^20.14.10", "@types/p5": "^1.7.6", @@ -1170,6 +1172,13 @@ "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", "dev": true }, + "node_modules/@types/chroma-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-3.1.0.tgz", + "integrity": "sha512-Uwl3SOtUkbQ6Ye6ZYu4q4xdLGBzmY839sEHYtOT7i691neeyd+7fXWT5VIkcUSfNwIFrIjQutNYQn9h4q5HFvg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -2350,6 +2359,12 @@ "node": ">= 6" } }, + "node_modules/chroma-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.1.2.tgz", + "integrity": "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -7812,6 +7827,12 @@ "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", "dev": true }, + "@types/chroma-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-3.1.0.tgz", + "integrity": "sha512-Uwl3SOtUkbQ6Ye6ZYu4q4xdLGBzmY839sEHYtOT7i691neeyd+7fXWT5VIkcUSfNwIFrIjQutNYQn9h4q5HFvg==", + "dev": true + }, "@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -8619,6 +8640,11 @@ } } }, + "chroma-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.1.2.tgz", + "integrity": "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==" + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", diff --git a/package.json b/package.json index 04aedb65..bebc3847 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "axios": "^1.6.8", "bigint-isqrt": "^0.3.2", "bigint-mod-arith": "^3.3.1", + "chroma-js": "^3.1.2", "dompurify": "^3.2.1", "interactjs": "^1.10.27", "p5": "^1.11.0", @@ -102,6 +103,7 @@ "@playwright/browser-firefox": "^1.46.1", "@playwright/test": "^1.46.1", "@tsconfig/node20": "^20.1.4", + "@types/chroma-js": "^3.1.0", "@types/jsdom": "^21.1.7", "@types/node": "^20.14.10", "@types/p5": "^1.7.6", diff --git a/src/shared/Chroma.ts b/src/shared/Chroma.ts new file mode 100644 index 00000000..c12d97e6 --- /dev/null +++ b/src/shared/Chroma.ts @@ -0,0 +1,69 @@ +import type {Color as Chroma} from 'chroma-js' +import chromaRaw from 'chroma-js' +export type {Color as Chroma} from 'chroma-js' + +const ALPHA = 3 +export function overlay(bot: Chroma, top: Chroma): Chroma { + const retgl = top.gl() + if (retgl.length < 4 || isNaN(retgl[ALPHA]) || retgl[ALPHA] >= 1.0) { + return chromaRaw(top) + } + const topa = retgl[ALPHA] + const botgl = bot.gl() + const bota = botgl[ALPHA] ?? 1.0 + for (let c = 0; c <= ALPHA; ++c) { + let botval = botgl[c] + if (c < ALPHA) { + retgl[c] *= topa + botval *= bota + } + retgl[c] += botval * (1 - topa) + } + return chromaRaw(...retgl, 'gl') +} + +type Quad = [number, number, number, number] +export const chroma = function (...args: unknown[]) { + if (args.length === 0) return chromaRaw('black') + if (args.length === 1) { + if (args[0] instanceof Array && args[0].length === 4) { + return chromaRaw(...(args[0] as Quad), 'gl') + } + if (typeof args[0] === 'number' && args[0] <= 1.0) { + return chromaRaw(args[0], args[0], args[0], 1, 'gl') + } + } + if ( + args.length === 2 + && typeof args[0] === 'string' + && typeof args[1] === 'number' + ) { + return chromaRaw(args[0]).alpha(args[1]) + } + if ( + args.length >= 3 + && typeof args[0] === 'number' + && typeof args[1] === 'number' + && typeof args[2] === 'number' + && args[0] <= 1.0 + && args[1] <= 1.0 + && args[2] <= 1.0 + ) { + if (args.length === 3) { + return chromaRaw(args[0], args[1], args[2], 1, 'gl') + } + if ( + args.length === 4 + && typeof args[3] === 'number' + && args[3] <= 1.0 + ) { + args.push('gl') + } + } + return chromaRaw(...(args as Parameters)) +} as typeof chromaRaw & + ((col: string, alpha: number) => Chroma) & + ((quad: Quad) => Chroma) & + (() => Chroma) + +Object.assign(chroma, chromaRaw) diff --git a/src/shared/__tests__/math.spec.ts b/src/shared/__tests__/math.spec.ts index 78e6f90a..16a4a1c8 100644 --- a/src/shared/__tests__/math.spec.ts +++ b/src/shared/__tests__/math.spec.ts @@ -93,3 +93,67 @@ describe('natlog', () => { ).toBeCloseTo(204.93007327647007, 15) }) }) + +describe('colors', () => { + const chroma = math.chroma + it('constructs a color from a string', () => { + expect(chroma('green').gl()).toStrictEqual([0, 128 / 255, 0, 1]) + }) + it('constructs a color from a string with alpha', () => { + expect(chroma('blue', 0.6).gl()).toStrictEqual([0, 0, 1, 0.6]) + }) + it('constructs a color with no arguments', () => { + expect(chroma().gl()).toStrictEqual([0, 0, 0, 1]) + }) + it('constructs a color from a quad', () => { + const quad: [number, number, number, number] = [0.5, 0.2, 0.7, 0.8] + expect(chroma(quad).gl()).toStrictEqual(quad) + }) + it('constructs a color from a grey level', () => { + expect(chroma(0.7).gl()).toStrictEqual([0.7, 0.7, 0.7, 1]) + }) + it('constructs a color from three numbers', () => { + expect(chroma(0.5, 0.2, 0.7).gl()).toStrictEqual([0.5, 0.2, 0.7, 1]) + }) + it('constructs a color from four numbers', () => { + expect(chroma(0.4, 0.8, 0.6, 0.9).gl()).toStrictEqual([ + 0.4, 0.8, 0.6, 0.9, + ]) + }) + it('allows chroma construction in expressions', () => { + expect(math.evaluate('chroma("magenta")').gl()).toStrictEqual([ + 1, 0, 1, 1, + ]) + }) + it('adds colors as overlay', () => { + expect( + math.add(chroma('blue'), chroma('yellow', 0.5)).gl() + ).toStrictEqual(chroma(0.5).gl()) + }) + it('adds colors in expressions', () => { + expect( + math.evaluate('chroma("blue") + chroma("yellow", 0.5)') + ).toStrictEqual(chroma(0.5)) + }) + it('scalar multiplies a color via alpha', () => { + const g = chroma('lime') + expect(math.multiply(g, 0.5)).toStrictEqual( + chroma(0, 1, 0, 0.5, 'gl') + ) + // make sure g is not modified + expect(g.gl()).toStrictEqual([0, 1, 0, 1]) + }) + it('takes linear combinations in expressions', () => { + expect( + math.evaluate('chroma("blue") + 0.5*chroma("yellow")') + ).toStrictEqual(chroma(0.5)) + expect( + math.evaluate('0.5*chroma("blue") + 0.5*chroma("yellow")').gl() + ).toStrictEqual([0.5, 0.5, 0.25, 0.75]) + }) + it('allows direct use of color names in expressions', () => { + expect(math.evaluate('0.5*blue + 0.5*yellow').gl()).toStrictEqual([ + 0.5, 0.5, 0.25, 0.75, + ]) + }) +}) diff --git a/src/shared/math.ts b/src/shared/math.ts index c4328b07..7c085caa 100644 --- a/src/shared/math.ts +++ b/src/shared/math.ts @@ -79,11 +79,13 @@ const anotherNegInf = math.bigmin(5n, math.negInfinity, -3) import isqrt from 'bigint-isqrt' import {modPow} from 'bigint-mod-arith' -import {create, all} from 'mathjs' +import {all, create, factory} from 'mathjs' import type {EvalFunction, MathJsInstance, MathType, SymbolNode} from 'mathjs' import temml from 'temml' import type {ValidationStatus} from './ValidationStatus' +import {chroma, overlay} from './Chroma' +import type {Chroma} from './Chroma' export type {MathNode, SymbolNode} from 'mathjs' type Integer = number | bigint @@ -109,10 +111,39 @@ type ExtendedMathJs = MathJsInstance & { bigabs(a: Integer): bigint bigmax(...args: Integer[]): ExtendedBigint bigmin(...args: Integer[]): ExtendedBigint + chroma: typeof chroma + add: MathJsInstance['add'] & ((c: Chroma, d: Chroma) => Chroma) + multiply: MathJsInstance['multiply'] & + ((s: number, c: Chroma) => Chroma) & + ((c: Chroma, s: number) => Chroma) } export const math = create(all) as ExtendedMathJs +/** Add colors to mathjs **/ +// @ts-expect-error: not in mathjs type declarations +math.typed.addType({ + name: 'Chroma', + test: (c: unknown) => + typeof c === 'object' && c && c.constructor.name === 'Color', +}) + +const colorStuff: Record = { + chroma, + add: math.typed('add', {'Chroma, Chroma': (c, d) => overlay(c, d)}), + multiply: math.typed('multiply', { + 'number, Chroma': (s, c) => chroma(c).alpha(s * c.alpha()), + 'Chroma, number': (c, s) => chroma(c).alpha(s * c.alpha()), + }), +} +// work around omission of `colors` property in @types/chroma-js +for (const name in (chroma as unknown as {colors: Record}) + .colors) { + colorStuff[name] = factory(name, [], () => chroma(name)) +} + +math.import(colorStuff) + math.negInfinity = -Infinity as TnegInfinity math.posInfinity = Infinity as TposInfinity From 8e964f05c9ad1fd840a1bc6af0004c3f482bb658 Mon Sep 17 00:00:00 2001 From: Glen Whitney Date: Sat, 18 Jan 2025 15:15:01 -0800 Subject: [PATCH 02/43] feat: allow turtle stroke widths and color to vary --- src/shared/__tests__/math.spec.ts | 15 ++ src/shared/math.ts | 6 + src/visualizers/Turtle.ts | 332 ++++++++++++++++++------------ 3 files changed, 217 insertions(+), 136 deletions(-) diff --git a/src/shared/__tests__/math.spec.ts b/src/shared/__tests__/math.spec.ts index 16a4a1c8..22483e4b 100644 --- a/src/shared/__tests__/math.spec.ts +++ b/src/shared/__tests__/math.spec.ts @@ -156,4 +156,19 @@ describe('colors', () => { 0.5, 0.5, 0.25, 0.75, ]) }) + it('allows chroma.js operations', () => { + // examples from https://gka.github.io/chroma.js/ + expect(math.evaluate('hotpink.darken(2).hex()')).toBe('#930058') + expect(math.evaluate('hotpink.brighten(2).hex()')).toBe('#ffd1ff') + expect(math.evaluate('slategray.saturate(2).hex()')).toBe('#0087cd') + expect(math.evaluate('hotpink.desaturate(2).hex()')).toBe('#cd8ca8') + expect(math.evaluate('hotpink.shade(0.25).hex()')).toBe('#dd5b9c') + expect(math.evaluate('hotpink.tint(0.25).hex()')).toBe('#ff9dc9') + expect(math.evaluate('hotpink.mix(blue, 0.75, "lab").hex()')).toBe( + '#811ced' + ) + expect(math.evaluate('red.mix(blue, 0.5, "oklch").hex()')).toBe( + '#ba00c2' + ) + }) }) diff --git a/src/shared/math.ts b/src/shared/math.ts index 7c085caa..667ad7ed 100644 --- a/src/shared/math.ts +++ b/src/shared/math.ts @@ -321,6 +321,12 @@ export class MathFormula { mathml: string freevars: string[] constructor(fmla: string, inputs?: string[]) { + // Preprocess formula to interpret "bare" color constants #hhhhhh + const prepfmla = fmla.replaceAll( + /(? math.isSymbolNode(node) && path !== 'fn') diff --git a/src/visualizers/Turtle.ts b/src/visualizers/Turtle.ts index f3c64cfa..b1da74f4 100644 --- a/src/visualizers/Turtle.ts +++ b/src/visualizers/Turtle.ts @@ -1,6 +1,7 @@ import p5 from 'p5' import {markRaw} from 'vue' +import {INVALID_COLOR} from './P5Visualizer' import {P5GLVisualizer} from './P5GLVisualizer' import {VisualizerExportModule} from './VisualizerInterface' import type {ViewSize} from './VisualizerInterface' @@ -40,6 +41,11 @@ of the path are re-drawn in every frame. ## Parameters **/ +enum RuleMode { + List, + Formula, +} + const paramDesc = { /** md - Domain: a list of numbers. These are the values that @@ -76,17 +82,18 @@ have a small domain.) }, /** md -The next four parameters give the instructions for the turtle's path (and how -it changes from frame to frame). Each one can be a single number, in which -case it is used for every element of the domain. Or it can be a list of +The following set of parameters give the instructions for the turtle's path +(and how it changes from frame to frame). Each one can be a single number, in +which case it is used for every element of the domain. Or it can be a list of numbers the same length as the domain, in which case the numbers correspond in order: the first number is used when an entry is equal to the first value in the domain, the second number is used for entries equal to the second value, -and so on. For example, if the "Steps" parameter below is "10", then a segment -10 pixels long will be drawn for every sequence entry in the domain, whereas if -the domain is "0 1 2" and "Steps" is "20 10 0", then the turtle will move 20 -pixels each time it sees a sequence entry of 0, 10 pixels for each 1, and it -won't draw anything when the entry is equal to 2 (but it might turn). +and so on. For example, if the "Step length(s)" parameter below is "10", +then a segment 10 pixels long will be drawn for every sequence entry in the +domain, whereas if the domain is "0 1 2" and "Step length(s)" is "20 10 0", +then the turtle will move 20 pixels each time it sees a sequence entry of 0, +10 pixels for each 1, and it won't draw anything when an entry is equal to 2 +(but it might turn). - Turn angle(s): Specifies (in degrees) how much the turtle should turn for each sequence entry in the domain. Positive values turn counterclockwise, @@ -170,26 +177,61 @@ changes from one frame to the next. visibleDependency: 'animationControls', visibleValue: true, }, - /** -- pathLook: boolean. If true, show path style controls + /** md +- Stroke width(s): Gives the width of the segment drawn for each entry, + in pixels. **/ - pathLook: { - default: true, - type: ParamType.BOOLEAN, - displayName: 'Path speed/styling ↴', + widths: { + default: [1], + type: ParamType.NUMBER_ARRAY, + displayName: 'Stroke width(s)', required: false, + validate: function (widths: number[], status: ValidationStatus) { + if (widths.some(n => n <= 0)) status.addError('must be positive') + }, + }, + /** md +- Stroke color(s): One or more strings specifying colors, separated by + spaces or commas. These colors correspond to the elements in the domain + just as all of the above parameters do. Each one may either be a CSS color + name (e.g., 'teal' or 'khaki'), or a standard hex color string (e.g. + '#88aa3c'). + **/ + strokeColor: { + default: '#c98787', + type: ParamType.STRING, + displayName: 'Stroke color(s)', + required: true, }, /** md (Note that as an advanced convenience feature, useful mainly when the domain is -large, you may specify fewer entries in one of these lists than there are +large, you may specify fewer entries in any of the above lists than there are elements in the domain, and the last one will be re-used as many times as necessary. Using this feature will display a warning, in case you inadvertently left out a value.) -The remaining parameters control the speed and style of the turtle path -display. - +- Color chooser: This color picker does not directly control the display. + Instead, whenever you select a color with it, the corresponding color + string is inserted in the Stroke color parameter box. + **/ + colorChooser: { + default: '#00d0d0', + type: ParamType.COLOR, + displayName: 'Color chooser:', + required: true, + description: 'Inserts choice into the stroke color.', + }, + /** md +- Background color: The color of the visualizer canvas. + **/ + bgColor: { + default: '#6b1a1a', + type: ParamType.COLOR, + displayName: 'Background color', + required: true, + }, + /** md - Turtle speed: a number. If zero (or if any of the fold or stretch rates are nonzero), the full path is drawn all at once. Otherwise, the path drawing will be animated, and the Turtle speed specifies the number of steps of the @@ -204,53 +246,61 @@ to prevent lag: this speed cannot exceed 1000 steps per frame. required: false, description: 'Steps added per frame.', hideDescription: false, - visibleDependency: 'pathLook', - visibleValue: true, validate: function (n: number, status: ValidationStatus) { if (n < 0) status.addError('must non-negative') if (n > 1000) status.addError('the speed is capped at 1000') }, }, /** md -- Stroke width: a number. Gives the width of the segment drawn for each entry, -in pixels. - **/ - strokeWeight: { - default: 1, - type: ParamType.INTEGER, - displayName: 'Stroke width', - required: false, - validate: function (n: number, status: ValidationStatus) { - if (n <= 0) status.addError('must be positive') - }, - visibleDependency: 'pathLook', - visibleValue: true, - }, - /** md -- Background color: The color of the visualizer canvas. +- Rule mode: You may select "List" or "Formula". Generally speaking, the + parameters above are used to control the Turtle visualization when this + Rule mode parameter has its default value of "List". The parameters below + are used when the Rule mode is "Formula". (Note that Background color + and Turtle speed are used in either case.) Broadly, in Formula mode, + you can enter expressions that calculate the different aspects of each + segment of the turtle's path (turn angle, length, color, etc.) directly + from the sequence entries. In each of these formulas, you may use the + following variables, the values of which will be filled in for you: + + `i` The index of the entry in the sequence being visualized. + + `a` The value of the entry. + + `f` The frame number of this drawing pass. If you use this variable, the + visualization will be redrawn from the beginning on every frame, + animating the shape of the path. + + `h` The current heading of the turtle, in degrees counterclockwise from its + initial heading. + + `x` The current x-coordinate of the turtle. + + `y` The current y-coordinate of the turtle. **/ - bgColor: { - default: '#6b1a1a', - type: ParamType.COLOR, - displayName: 'Background color', + ruleMode: { + default: RuleMode.List, + type: ParamType.ENUM, + from: RuleMode, + displayName: 'Rule mode', required: true, - visibleDependency: 'pathLook', - visibleValue: true, - }, - /** md -- Stroke color: The color used for drawing the path. - **/ - strokeColor: { - default: '#c98787', - type: ParamType.COLOR, - displayName: 'Stroke color', - required: true, - visibleDependency: 'pathLook', - visibleValue: true, }, } satisfies GenericParamDescription -const ruleParams = ['turns', 'steps', 'folds', 'stretches'] as const +const ruleParamNames = [ + 'turns', + 'steps', + 'folds', + 'stretches', + 'widths', + 'strokeColor', +] as const + +type RuleParam = (typeof ruleParamNames)[number] +type RuleParamInternal = `${RuleParam}Internal` + +const ruleParamInternal = Object.fromEntries( + ruleParamNames.map(name => [name, `${name}Internal`]) +) as Record // How many segments to gather into a reusable Geometry object // Might need tuning @@ -261,19 +311,29 @@ class Turtle extends P5GLVisualizer(paramDesc) { static description = 'Use a sequence to steer a virtual turtle that leaves a visible trail' - // maps from domain to rotations and steps + // maps from domain to rotations and steps etc private rotMap = markRaw(new Map()) private stepMap = markRaw(new Map()) private foldMap = markRaw(new Map()) private stretchMap = markRaw(new Map()) + private widthMap = markRaw(new Map()) + private colorMap = markRaw(new Map()) + // private copies of rule arrays private turnsInternal: number[] = [] private stepsInternal: number[] = [] private foldsInternal: number[] = [] private stretchesInternal: number[] = [] + private widthsInternal: number[] = [] + private strokeColorInternal: string[] = [] // variables recording the path private vertices = markRaw([new p5.Vector()]) // nodes of path + private pathWidths = markRaw([1]) + private pathLengths = markRaw([0]) + private pathTurns = markRaw([0]) + private pathBearings = markRaw([0]) + private pathColors = markRaw([INVALID_COLOR]) private chunks: p5.Geometry[] = markRaw([]) // "frozen" chunks of path private bearing = 0 // heading at tip of path private cursor = 0 // vertices up to this one have already been drawn @@ -302,8 +362,12 @@ class Turtle extends P5GLVisualizer(paramDesc) { const status = super.checkParameters(params) // lengths of rulesets should match length of domain - for (const rule of ruleParams) { - const entries = params[rule].length + for (const rule of ruleParamNames) { + let ruleList: string | (string | number)[] = params[rule] + if (rule === 'strokeColor' && typeof ruleList === 'string') { + ruleList = ruleList.split(/[\s,]+/) + } + const entries = ruleList.length if (entries > 1 && entries < params.domain.length) { this.statusOf[rule].addWarning( `fewer entries than the ${params.domain.length}-element ` @@ -369,81 +433,60 @@ class Turtle extends P5GLVisualizer(paramDesc) { return status } + /** + * Here, we implement selecting a color with the chooser inserting it into + * the strokeColor: + */ + async parametersChanged(nameList: string[]) { + if (nameList.includes('colorChooser')) { + this.strokeColor += this.colorChooser + this.refreshParams() + nameList.splice(nameList.indexOf('colorChooser'), 1) + nameList.push('strokeColor') + } + super.parametersChanged(nameList) + } + storeRules() { - // this function creates the internal rule maps from user input - - // create an adjusted internal copy of the rules - const ruleParams = [ - { - param: this.turns, - local: [0], - }, - { - param: this.steps, - local: [0], - }, - { - param: this.folds, - local: [0], - }, - { - param: this.stretches, - local: [0], - }, - ] - ruleParams.forEach(rule => { - rule.local = [...rule.param] - }) - // ignore (remove) or add extra rules for excess/missing - // terms compared to domain length - ruleParams.forEach(rule => { - while (rule.local.length < this.domain.length) { - rule.local.push(rule.local[rule.local.length - 1]) + const dLength = this.domain.length + // Copy each rule parameter into the internal property, fixing its + // length: + for (const name of ruleParamNames) { + let specd: string | (string | number)[] = this[name] + if (name === 'strokeColor' && typeof specd === 'string') { + specd = specd.split(/[\s,]+/) } - while (rule.local.length > this.domain.length) { - rule.local.pop() + const fixed: (string | number)[] = [] + for (let i = 0; i < dLength; ++i) { + const useix = Math.min(specd.length - 1, i) + fixed.push(specd[useix]) } - }) - this.turnsInternal = ruleParams[0].local - this.stepsInternal = ruleParams[1].local - this.foldsInternal = ruleParams[2].local - this.stretchesInternal = ruleParams[3].local - - // create a map from sequence values to rotations - for (let i = 0; i < this.domain.length; i++) { - this.rotMap.set( - this.domain[i].toString(), - (Math.PI / 180) * this.turnsInternal[i] - ) - } - - // create a map from sequence values to step lengths - for (let i = 0; i < this.domain.length; i++) { - this.stepMap.set(this.domain[i].toString(), this.stepsInternal[i]) + this[ruleParamInternal[name]] = fixed as string[] & number[] } - // create a map from sequence values to turn increments - // notice if path is static or we are folding + // create map from sequence values to rotations, step lengths, + // folds, stretches, and weights this.animating = false - for (let i = 0; i < this.domain.length; i++) { - // cumulative effect of two ways to turn on folding + for (let i = 0; i < dLength; i++) { + const key = this.domain[i].toString() + this.rotMap.set(key, (Math.PI / 180) * this.turnsInternal[i]) + this.stepMap.set(key, this.stepsInternal[i]) const thisFold = this.foldsInternal[i] if (thisFold != 0) this.animating = true this.foldMap.set( - this.domain[i].toString(), + key, (Math.PI / 180) * (thisFold / this.foldDenom) ) - } - - // create a map from sequence values to stretch increments - // notice if path is static or we are animating - // rename folding to animating? - for (let i = 0; i < this.domain.length; i++) { if (this.stretchesInternal[i] != 0) this.animating = true this.stretchMap.set( - this.domain[i].toString(), + key, this.stretchesInternal[i] / this.stretchDenom ) + this.widthMap.set(key, this.widthsInternal[i]) + this.colorMap.set( + key, + this.sketch.color(this.strokeColorInternal[i]) + ) } } @@ -471,6 +514,11 @@ class Turtle extends P5GLVisualizer(paramDesc) { refresh() { // eliminates the path so it will be recomputed, and redraws this.vertices = markRaw([new p5.Vector()]) // nodes of path + this.pathWidths = markRaw([1]) + this.pathLengths = markRaw([0]) + this.pathTurns = markRaw([0]) + this.pathBearings = markRaw([0]) + this.pathColors = markRaw([INVALID_COLOR]) this.chunks = markRaw([]) this.bearing = 0 this.redraw() @@ -480,21 +528,29 @@ class Turtle extends P5GLVisualizer(paramDesc) { // blanks the screen and sets up to redraw the path this.cursor = 0 // prepare sketch - this.sketch - .background(this.bgColor) - .noFill() - .stroke(this.strokeColor) - .strokeWeight(this.strokeWeight) - .frameRate(30) + this.sketch.background(this.bgColor).noStroke().frameRate(30) } - // Adds the vertices between start and end INCLUSIVE to the current shape - addVertices(start: number, end: number) { - let lastPos: undefined | p5.Vector = undefined - for (let i = start; i <= end; ++i) { + // Draws the vertices between start and end INCLUSIVE + drawVertices(start: number, end: number) { + const sketch = this.sketch + let lastPos = this.vertices[start] + for (let i = start + 1; i <= end; ++i) { const pos = this.vertices[i] - if (pos.x !== lastPos?.x || pos.y !== lastPos?.y) { - this.sketch.vertex(pos.x, pos.y) + const width = this.pathWidths[i] + const length = this.pathLengths[i] + if (pos.x !== lastPos.x || pos.y !== lastPos.y) { + sketch.fill(this.pathColors[i]) + sketch.push() + sketch + .translate(lastPos.x, lastPos.y) + .rotateZ(this.pathBearings[i]) + if (length > width / 3) { + sketch.rect(0, -width / 2, length - width / 3, width) + sketch.circle(length - width / 3, 0, width) + } + sketch.circle(0, 0, width) + sketch.pop() } lastPos = pos } @@ -524,9 +580,7 @@ class Turtle extends P5GLVisualizer(paramDesc) { } if (drewSome) this.cursor = this.chunks.length * CHUNK_SIZE if (this.cursor < newCursor) { - sketch.beginShape() - this.addVertices(this.cursor, newCursor) - sketch.endShape() + this.drawVertices(this.cursor, newCursor) this.cursor = newCursor } @@ -535,12 +589,10 @@ class Turtle extends P5GLVisualizer(paramDesc) { if (!this.animating && fullChunks > this.chunks.length) { // @ts-expect-error The @types/p5 package omitted this function sketch.beginGeometry() - sketch.beginShape() - this.addVertices( + this.drawVertices( (fullChunks - 1) * CHUNK_SIZE, fullChunks * CHUNK_SIZE ) - sketch.endShape() // @ts-expect-error Ditto :-( this.chunks.push(sketch.endGeometry()) } @@ -603,12 +655,20 @@ class Turtle extends P5GLVisualizer(paramDesc) { } const currElementString = currElement.toString() const turnAngle = rotMap.get(currElementString) + let stepLength = 0 if (turnAngle !== undefined) { - const stepLength = stepMap.get(currElementString) ?? 0 + stepLength = stepMap.get(currElementString) ?? 0 this.bearing += turnAngle position.x += Math.cos(this.bearing) * stepLength position.y += Math.sin(this.bearing) * stepLength } + this.pathWidths.push(this.widthMap.get(currElementString) ?? 1) + this.pathLengths.push(stepLength) + this.pathTurns.push(turnAngle ?? 0) + this.pathBearings.push(this.bearing) + this.pathColors.push( + this.colorMap.get(currElementString) ?? INVALID_COLOR + ) this.vertices.push(position.copy()) } } From d0470fd7b4b6fd949469864052b1fc956d5a58a8 Mon Sep 17 00:00:00 2001 From: Glen Whitney Date: Sun, 19 Jan 2025 17:07:14 -0800 Subject: [PATCH 03/43] feat: introduce formula parameters to Turtle --- src/visualizers/Turtle.ts | 73 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/visualizers/Turtle.ts b/src/visualizers/Turtle.ts index b1da74f4..22135cff 100644 --- a/src/visualizers/Turtle.ts +++ b/src/visualizers/Turtle.ts @@ -7,6 +7,7 @@ import {VisualizerExportModule} from './VisualizerInterface' import type {ViewSize} from './VisualizerInterface' import {CachingError} from '@/sequences/Cached' +import {MathFormula} from '@/shared/math' import type {GenericParamDescription, ParamValues} from '@/shared/Paramable' import {ParamType} from '@/shared/ParamType' import {ValidationStatus} from '@/shared/ValidationStatus' @@ -66,6 +67,8 @@ have a small domain.) 'Sequence values to interpret as rules; entries not ' + 'matching any value here are skipped.', hideDescription: false, + visibleDependency: 'ruleMode', + visibleValue: RuleMode.List, validate: function (dom: bigint[], status: ValidationStatus) { const seen = new Set() for (const element of dom) { @@ -108,6 +111,8 @@ and negative values clockwise. 'An angle (in degrees) or a list of angles, in order ' + 'corresponding to the sequence values listed in Domain.', hideDescription: false, + visibleDependency: 'ruleMode', + visibleValue: RuleMode.List, }, /** md - Step length(s): Specifies (in pixels) how far the turtle should move (and @@ -123,6 +128,8 @@ negative values (for moving backward) are allowed. 'A length (in pixels), or a list of lengths, in order ' + 'corresponding to the sequence values listed in Domain.', hideDescription: false, + visibleDependency: 'ruleMode', + visibleValue: RuleMode.List, }, /** - animationControls: boolean. If true, show folding controls @@ -132,6 +139,8 @@ negative values (for moving backward) are allowed. type: ParamType.BOOLEAN, displayName: 'Animation ↴', required: false, + visibleDependency: 'ruleMode', + visibleValue: RuleMode.List, }, /** md - Fold rate(s): Specifies (in units of 0.00001 degree) how each turn angle @@ -186,6 +195,8 @@ changes from one frame to the next. type: ParamType.NUMBER_ARRAY, displayName: 'Stroke width(s)', required: false, + visibleDependency: 'ruleMode', + visibleValue: RuleMode.List, validate: function (widths: number[], status: ValidationStatus) { if (widths.some(n => n <= 0)) status.addError('must be positive') }, @@ -202,6 +213,8 @@ changes from one frame to the next. type: ParamType.STRING, displayName: 'Stroke color(s)', required: true, + visibleDependency: 'ruleMode', + visibleValue: RuleMode.List, }, /** md @@ -262,7 +275,7 @@ to prevent lag: this speed cannot exceed 1000 steps per frame. from the sequence entries. In each of these formulas, you may use the following variables, the values of which will be filled in for you: - `i` The index of the entry in the sequence being visualized. + `n` The index of the entry in the sequence being visualized. `a` The value of the entry. @@ -284,6 +297,64 @@ to prevent lag: this speed cannot exceed 1000 steps per frame. displayName: 'Rule mode', required: true, }, + /** md +- Turn formula: an expression to compute the turn angle in degrees for a + given step of the turtle's path. + **/ + turnFormula: { + default: new MathFormula('30+15a', ['n', 'a']), + type: ParamType.FORMULA, + inputs: ['n', 'a', 'f', 'h', 'x', 'y'], + displayName: 'Turn formula', + description: + 'Computes how many degrees to turn counterclockwise ' + + 'before each turtle step.', + required: false, + visibleDependency: 'ruleMode', + visibleValue: RuleMode.Formula, + }, + /** md +- Step formula: an expression to compute the pixel length of each step of the + turtle's path. + **/ + stepFormula: { + default: new MathFormula('20'), + type: ParamType.FORMULA, + inputs: ['n', 'a', 'f', 'h', 'x', 'y'], + displayName: 'Step formula', + description: 'Computes the pixel length of each turtle step', + required: false, + visibleDependency: 'ruleMode', + visibleValue: RuleMode.Formula, + }, + /** md +- Width formula: an expression to compute the pixel width of each step of the + turtle's path. + **/ + widthFormula: { + default: new MathFormula('1'), + type: ParamType.FORMULA, + inputs: ['n', 'a', 'f', 'h', 'x', 'y'], + displayName: 'Width formula', + description: 'Computes the pixel width of each turtle step', + required: false, + visibleDependency: 'ruleMode', + visibleValue: RuleMode.Formula, + }, + /** md +- Color formula: an expression to compute the color of each step of the + turtle's path. + **/ + colorFormula: { + default: new MathFormula('#c98787'), + type: ParamType.FORMULA, + inputs: ['n', 'a', 'f', 'h', 'x', 'y'], + displayName: 'Color formula', + description: 'Computes the color of each turtle step', + required: false, + visibleDependency: 'ruleMode', + visibleValue: RuleMode.Formula, + }, } satisfies GenericParamDescription const ruleParamNames = [ From 062b897e1fdf243ae20825a68a56de2e35511b33 Mon Sep 17 00:00:00 2001 From: Glen Whitney Date: Sun, 19 Jan 2025 18:04:09 -0800 Subject: [PATCH 04/43] feat: Reconfigure display of dependent parameters --- e2e/tests/gallery.spec.ts | 2 + src/components/ParamEditor.vue | 71 +++++++++++++++++-------------- src/components/ParamField.vue | 5 +-- src/components/SpecimenBar.vue | 6 --- src/visualizers-workbench/Grid.ts | 37 ++++++++-------- 5 files changed, 62 insertions(+), 59 deletions(-) diff --git a/e2e/tests/gallery.spec.ts b/e2e/tests/gallery.spec.ts index d0524ddb..3695b7f4 100644 --- a/e2e/tests/gallery.spec.ts +++ b/e2e/tests/gallery.spec.ts @@ -30,6 +30,8 @@ test.describe('Gallery', () => { ) }) test('clicking on a featured item', async ({page}) => { + const danceCard = await page.locator('.card-body >> nth=2') + await expect(danceCard.locator('.titlespan')).toContainText(/Dance/) await page.locator('.card-body >> nth=2').click() await expect(page.url()).not.toContain('gallery') await expect( diff --git a/src/components/ParamEditor.vue b/src/components/ParamEditor.vue index 8a010cc7..f7bf6c07 100644 --- a/src/components/ParamEditor.vue +++ b/src/components/ParamEditor.vue @@ -49,28 +49,16 @@

{{ paramable.description }}

-
+
-
-
- -
-
@@ -85,11 +73,6 @@ import MageExchangeA from './MageExchangeA.vue' import ParamField from './ParamField.vue' - interface ParamHierarchy { - param: ParamInterface - children: {[key: string]: ParamInterface} - } - type Paramable = () => ParamableInterface export default defineComponent({ @@ -107,20 +90,13 @@ }, emits: ['changed', 'openSwitcher'], computed: { - sortedParams() { - const sortedParams: {[key: string]: ParamHierarchy} = {} + visibleParams() { + const visParams: typeof this.paramable.params = {} Object.keys(this.paramable.params).forEach(key => { const param = this.paramable.params[key] - if (!param.visibleDependency) - sortedParams[key] = {param, children: {}} + if (this.checkDependency(param)) visParams[key] = param }) - Object.keys(this.paramable.params).forEach(key => { - const param = this.paramable.params[key] - if (param.visibleDependency) - sortedParams[param.visibleDependency].children[key] = - param - }) - return sortedParams + return visParams }, }, methods: { @@ -147,6 +123,17 @@ return param.visiblePredicate(v as never) } else return param.visibleValue! === v }, + paramClass(param: ParamInterface): string { + let klass = '' + if (param.visibleDependency) klass = 'sub-' + if ( + param.type === ParamType.BOOLEAN + && (param.hideDescription || !param.description) + ) { + klass += 'check' + } else klass += 'param' + return klass + '-box' + }, openSwitcher() { this.$emit('openSwitcher') }, @@ -203,10 +190,28 @@ margin-bottom: 24px; } + .param-box { + margin-top: 16px; + margin-bottom: 20px; + } + + .check-box { + margin-top: 8px; + margin-bottom: 12px; + } + .sub-param-box { border-left: 1px solid var(--ns-color-black); margin-left: 8px; padding-left: 8px; + padding-bottom: 16px; + } + + .sub-check-box { + border-left: 1px solid var(--ns-color-black); + margin-left: 8px; + padding-left: 8px; + padding-bottom: 8px; } .error-box { diff --git a/src/components/ParamField.vue b/src/components/ParamField.vue index f14f772f..2b8a4773 100644 --- a/src/components/ParamField.vue +++ b/src/components/ParamField.vue @@ -1,5 +1,5 @@