@codincod/ornament 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ /**
2
+ * One animal, alone, with the water held close enough to see it.
3
+ *
4
+ * A sea is drawn to be a sea: an animal in it is a few percent of the box
5
+ * across, because that is what a fish at eight metres actually looks like and
6
+ * `sizes.ts` is not going to lie about it for the sake of a nice picture. That
7
+ * is right for a background and useless for a plate, and a plate is what finds
8
+ * drawing faults. At the weight ornament ships at, a wreck that is upside down
9
+ * and a wreck that is working look identical.
10
+ *
11
+ * So a specimen is not a bigger animal. It is the same animal in the same
12
+ * water, with the camera brought in until it fills the frame, and `look` is
13
+ * where to point it. Nothing about the drawing changes, which is the whole
14
+ * value: a fault seen here is a fault the sea has.
15
+ *
16
+ * ## Why the water is bigger than the box it is shown in
17
+ *
18
+ * Because the animal has to have somewhere to be. A fish given a box the size
19
+ * of the view spends its life against an edge it can feel; given room, it swims
20
+ * about its business and the camera follows. What is shown is a window on a
21
+ * water that carries on past it.
22
+ */
23
+ import type { Presence } from "../biome.ts"
24
+ import { createCephalopods } from "../cephalopods.ts"
25
+ import { createJellies, type JellyKind } from "../jellies.ts"
26
+ import { createPuffers, type Puffers } from "../puffers.ts"
27
+ import { createSeabed } from "../seabed.ts"
28
+ import { createShoal, type Kind as FishKind } from "../shoal.ts"
29
+ import { createWalkers } from "../walkers.ts"
30
+ import { paintInklings, paintJellies, paintPuffers, paintShoal, paintWalkers } from "./life.ts"
31
+ import { Pen } from "./pen.ts"
32
+ import type { Box } from "./sky.ts"
33
+
34
+ /**
35
+ * The ones the design system shows, one from each family that stays in frame.
36
+ *
37
+ * The animals that cross rather than live here are deliberately not among
38
+ * them. A shark keeps a rest between crossings measured in minutes, so a plate
39
+ * of one is a plate of empty water for most of the time somebody is looking at
40
+ * it, and a shark wound on until it is there is a shark on a treadmill.
41
+ */
42
+ export type Kind = "crab" | "jellyfish" | "octopus" | "pufferfish" | "swordfish"
43
+
44
+ export const KINDS: readonly Kind[] = ["swordfish", "jellyfish", "octopus", "crab", "pufferfish"]
45
+
46
+ /** What each one is, for a page that has to say. */
47
+ export const GLOSS: Readonly<Record<Kind, string>> = {
48
+ crab: "It goes where it decides to go, and the current has no say in it.",
49
+ jellyfish: "A moon jelly, which gets a second push out of its own refill.",
50
+ octopus: "The only thing down there with a repertoire rather than a rhythm.",
51
+ pufferfish: "It is a fish until something crowds it, and then it is a ball of spines.",
52
+ swordfish: "A billfish over a reef is a visitor from the water beyond it.",
53
+ }
54
+
55
+ /**
56
+ * How much bigger the water is than the window on it.
57
+ *
58
+ * Enough that the animal is never against a wall, and no more: everything in
59
+ * this water is stepped whether it is in the frame or not, so room is paid for
60
+ * every frame.
61
+ */
62
+ const ROOM = 5
63
+
64
+ /** How many numbers a plate's frame is given. One animal is not a sea. */
65
+ const BLOCK = 1 << 16
66
+
67
+ /** How much of the window the animal is meant to take up. */
68
+ const FILLS = 0.72
69
+
70
+ /**
71
+ * How far a drawing actually reaches, against the size the animal reports.
72
+ *
73
+ * An animal's size is the size of the animal, and a drawing of one is bigger
74
+ * than that in every case where something trails: an octopus is measured across
75
+ * its head and drawn with two head-lengths of arms under it, a crab is measured
76
+ * across its shell and stands on legs half again as wide, a pufferfish is
77
+ * measured deflated and spends its interesting seconds twice that with its
78
+ * spines out, and a moon jelly is a bell with a curtain hanging off it. `drop`
79
+ * is the other half of the same fact, because none of it trails evenly: what
80
+ * hangs, hangs downwards, so the middle of the drawing is below the middle of
81
+ * the animal.
82
+ *
83
+ * It is only ever a plate's business. A sea draws these at the size they are
84
+ * and never has to frame one.
85
+ */
86
+ const REACH: Readonly<Record<Kind, { drop: number; wide: number }>> = {
87
+ crab: { drop: 0.06, wide: 2.05 },
88
+ jellyfish: { drop: 0.34, wide: 1.6 },
89
+ octopus: { drop: 0.28, wide: 1.9 },
90
+ pufferfish: { drop: 0, wide: 2.15 },
91
+ swordfish: { drop: 0, wide: 1.15 },
92
+ }
93
+
94
+ /**
95
+ * What crowds the pufferfish, and how often.
96
+ *
97
+ * A pufferfish at rest is a slightly lumpy fish sculling about, and the drawing
98
+ * worth judging is the other one. In a sea the thing that comes near is a shark
99
+ * or a hull; on a plate it is whoever is looking, so that is what this is:
100
+ * something several times the animal's size, with its whole attention on it,
101
+ * leaning in for a few seconds out of every `every` and going away again.
102
+ *
103
+ * It is the one thing on a plate that is not in the water. It is there because
104
+ * the drawing this plate exists to show is the ball of spines, and the animal
105
+ * will not make one to order: it swells at what the water is doing, and a plate
106
+ * with nothing in it is a plate of water doing nothing.
107
+ */
108
+ const LOOKING = { every: 24, menace: 1, near: 3, size: 3 }
109
+
110
+ /** How light it is on a plate. */
111
+ const DAYLIGHT = 1
112
+
113
+ /** How many numbers stand in front of a frame's count; see `frame.ts`. */
114
+ const HEAD = 6
115
+
116
+ export interface PlateOptions {
117
+ height: number
118
+ seed?: number
119
+ width: number
120
+ }
121
+
122
+ export interface Plate {
123
+ /** The window's own size, which is not the water's. */
124
+ readonly box: Box
125
+ readonly geometry: Float32Array
126
+ /** Where to point the camera, and how far in. */
127
+ look(): { scale: number; x: number; y: number }
128
+ over(): number
129
+ step(seconds: number): void
130
+ wind(seconds: number): void
131
+ }
132
+
133
+ /**
134
+ * A plate of one animal.
135
+ *
136
+ * Everything it does not need is left out rather than made and ignored. A crab
137
+ * and an octopus need a floor, because neither of them is ever off it. Nothing
138
+ * else here has a bed at all, and a jellyfish in open water is a jellyfish.
139
+ */
140
+ export function createPlate(kind: Kind, options: PlateOptions): Plate {
141
+ const box = { height: options.height, width: options.width }
142
+ const water = { height: box.height * ROOM, width: box.width * ROOM }
143
+ const seed = options.seed ?? 1
144
+ const geometry = new Float32Array(BLOCK)
145
+
146
+ const grounded = kind === "crab" || kind === "octopus" || kind === "pufferfish"
147
+ const seabed = grounded ? createSeabed({ ...water, cliffs: 0, ranges: 1, seed, stones: 0 }) : null
148
+
149
+ const shoal =
150
+ kind === "swordfish"
151
+ ? createShoal({
152
+ count: 1,
153
+ cruise: 0.5,
154
+ height: water.height,
155
+ seed,
156
+ species: { swordfish: 1 } as Partial<Record<FishKind, number>>,
157
+ width: water.width,
158
+ })
159
+ : null
160
+
161
+ const jellies =
162
+ kind === "jellyfish"
163
+ ? createJellies({
164
+ count: 1,
165
+ height: water.height,
166
+ kinds: ["moon"] as readonly JellyKind[],
167
+ seed,
168
+ width: water.width,
169
+ })
170
+ : null
171
+
172
+ const inklings =
173
+ kind === "octopus" && seabed
174
+ ? createCephalopods({
175
+ floor: seabed.floorAt,
176
+ height: water.height,
177
+ octopuses: 1,
178
+ seed,
179
+ squids: 0,
180
+ width: water.width,
181
+ })
182
+ : null
183
+
184
+ let watched = 0
185
+
186
+ const looking = (): readonly Presence[] => {
187
+ const one = puffers?.puffers[0]
188
+ if (!one || watched % LOOKING.every > LOOKING.near) return []
189
+ return [
190
+ {
191
+ depth: one.depth,
192
+ menace: LOOKING.menace,
193
+ size: one.size * LOOKING.size,
194
+ x: one.x,
195
+ y: one.y,
196
+ },
197
+ ]
198
+ }
199
+
200
+ const puffers: Puffers | null =
201
+ kind === "pufferfish" && seabed
202
+ ? createPuffers({
203
+ about: looking,
204
+ count: 1,
205
+ floor: seabed.floorAt,
206
+ height: water.height,
207
+ seed,
208
+ width: water.width,
209
+ })
210
+ : null
211
+
212
+ const walkers =
213
+ kind === "crab" && seabed
214
+ ? createWalkers({
215
+ crabs: 1,
216
+ floor: seabed.floorAt,
217
+ seed,
218
+ starfish: 0,
219
+ width: water.width,
220
+ })
221
+ : null
222
+
223
+ /**
224
+ * Where the animal is, kept from the last frame it was in.
225
+ *
226
+ * A camera that snapped back to the middle of the water whenever the animal
227
+ * was between two states would be a camera nobody could watch. The last known
228
+ * place is a better answer than the middle, and it is always nearly right.
229
+ */
230
+ let held = { x: water.width / 2, y: water.height / 2 }
231
+ let across = box.width / 6
232
+
233
+ const find = () => {
234
+ const one =
235
+ shoal?.fish[0] ??
236
+ jellies?.jellies[0] ??
237
+ inklings?.octopuses[0] ??
238
+ puffers?.puffers[0] ??
239
+ walkers?.walkers[0]
240
+ if (!one) return
241
+
242
+ held = { x: one.x, y: one.y }
243
+ if (one.size > 0) across = one.size
244
+ }
245
+
246
+ return {
247
+ box,
248
+
249
+ geometry,
250
+
251
+ look() {
252
+ find()
253
+ const reach = REACH[kind]
254
+ const drawn = Math.max(1, across * reach.wide)
255
+ return {
256
+ scale: (Math.min(box.height, box.width) * FILLS) / drawn,
257
+ x: held.x,
258
+ y: held.y + across * reach.drop,
259
+ }
260
+ },
261
+
262
+ over(): number {
263
+ // The same six numbers a sea puts in front of its frame, so one reader
264
+ // walks either. A plate is not held by anybody: it is a drawing being
265
+ // looked at, and a drawing that wandered would be a drawing on a boat.
266
+ geometry[0] = 0
267
+ geometry[1] = 0
268
+ geometry[2] = 0
269
+ geometry[3] = 1
270
+ geometry[4] = 0
271
+ geometry[5] = DAYLIGHT
272
+
273
+ const pen = new Pen(geometry, HEAD)
274
+ if (shoal) paintShoal(pen, shoal)
275
+ // A plate is looked at rather than glanced past, so it is drawn in full
276
+ // daylight whatever the hour: everything in this water is the theme's own
277
+ // ink either way, and what the hour changes is a wash over the top, which
278
+ // on a plate is something to squint through.
279
+ if (jellies) paintJellies(pen, jellies, DAYLIGHT)
280
+ if (inklings) paintInklings(pen, inklings)
281
+ if (puffers) paintPuffers(pen, puffers)
282
+ if (walkers) paintWalkers(pen, walkers)
283
+ return pen.close()
284
+ },
285
+
286
+ step(seconds: number): void {
287
+ shoal?.step(seconds, null, null)
288
+ jellies?.step(seconds, null)
289
+ inklings?.step(seconds, null)
290
+ watched += seconds
291
+ puffers?.step(seconds)
292
+ walkers?.step(seconds)
293
+ },
294
+
295
+ wind(seconds: number): void {
296
+ const step = 1 / 10
297
+ for (let at = 0; at < Math.round(Math.max(0, seconds) / step); at++) this.step(step)
298
+ },
299
+ }
300
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * A frame of the water as SVG markup, for somewhere nothing runs.
3
+ *
4
+ * The same picture `canvas/flat.ts` puts on a canvas, written out as text
5
+ * instead: a page that has to look right with scripts off, or a file somebody
6
+ * asked for. It makes the same decisions in the same order (the camera, the
7
+ * stable sort by lane, one reading of the water for a small drawing and a
8
+ * gradient down a tall one), so a still and a canvas of one frame are the same
9
+ * picture.
10
+ *
11
+ * Nothing here touches a browser. The answer is a string, and building it
12
+ * needs no DOM.
13
+ *
14
+ * ## Coloured by the page, not here
15
+ *
16
+ * Every colour in the water apart from the three lights is a mix of the
17
+ * theme's ink and its surface, and `ink.ts` only ever mixes those two in a
18
+ * straight line, so each one is a single number: how much ink. That number is
19
+ * what is written down, as a class whose colour is a `color-mix()` of two CSS
20
+ * colours the caller names. Given `var(--primary)` and `var(--base-100)`, one
21
+ * still serves every theme a page has, and a theme switch recolours it with
22
+ * nothing redrawn.
23
+ */
24
+ import { FORM, held, OWN, Reader } from "./frame.ts"
25
+ import { hueOf, leftAt, type Palette } from "./ink.ts"
26
+ import { TONE } from "./pen.ts"
27
+
28
+ /** How tall a drawing has to be before it is worth a gradient; see `canvas/flat.ts`. */
29
+ const TALL = 0.08
30
+
31
+ /** How many places a gradient is sampled at; see `canvas/flat.ts`. */
32
+ const STOPS = 7
33
+
34
+ /**
35
+ * How finely the share of ink is written, in steps of the way from surface to
36
+ * ink. Finer than a channel of eight bits can tell apart on any theme whose two
37
+ * colours are not black and white.
38
+ */
39
+ const SHADES = 400
40
+
41
+ /**
42
+ * Ink as white and surface as black, so that the colour `ink.ts` answers is
43
+ * the share of ink itself, in every channel.
44
+ */
45
+ const UNIT: Palette = { ink: [1, 1, 1], surface: [0, 0, 0] }
46
+
47
+ const LIGHTS: ReadonlySet<number> = new Set([TONE.dusk, TONE.moon, TONE.sun])
48
+
49
+ export interface Box {
50
+ height: number
51
+ width: number
52
+ }
53
+
54
+ /** The two colours the water is mixed from, as CSS: any colour, a `var()`, a keyword. */
55
+ export interface Theme {
56
+ ink: string
57
+ surface: string
58
+ }
59
+
60
+ export interface SvgOptions {
61
+ /**
62
+ * Attributes for the `<svg>` itself, written as given: a class, a role, how
63
+ * it should be fitted to its box.
64
+ */
65
+ attributes?: Record<string, string>
66
+ /** How far past the box a drawing may stand before it is skipped, as a share of the box. */
67
+ bleed?: number
68
+ /**
69
+ * How many places after the point a coordinate keeps. A still the width of
70
+ * a screen needs none; a plate a few hundred pixels across wants one.
71
+ */
72
+ digits?: number
73
+ /**
74
+ * What every class and gradient id starts with. Two stills on one page need
75
+ * two, or the second one is coloured and filled from the first one's.
76
+ */
77
+ prefix?: string
78
+ }
79
+
80
+ /** What one reading of the water comes to: a share of ink, or a light of its own. */
81
+ interface Tint {
82
+ alpha: number
83
+ light: string | null
84
+ share: number
85
+ }
86
+
87
+ const hex = (rgb: readonly number[]): string =>
88
+ `#${rgb
89
+ .map((channel) =>
90
+ Math.round(Math.min(1, Math.max(0, channel)) * 255)
91
+ .toString(16)
92
+ .padStart(2, "0"),
93
+ )
94
+ .join("")}`
95
+
96
+ /** A number in as few characters as it will go: `.5`, `-.25`, `3`. */
97
+ const short = (value: number, digits: number): string => {
98
+ const written = Number(value.toFixed(digits))
99
+ if (written === 0) return "0"
100
+ return String(written).replace(/^(-?)0\./, "$1.")
101
+ }
102
+
103
+ const escape = (value: string): string =>
104
+ value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;")
105
+
106
+ /** Numbers one after another, with a space only where a minus sign is not one. */
107
+ const run = (numbers: string[]): string =>
108
+ numbers.reduce(
109
+ (line, next) => (line === "" || next.startsWith("-") ? line + next : `${line} ${next}`),
110
+ "",
111
+ )
112
+
113
+ /** One frame, as an `<svg>` element whose user space is the water's own box. */
114
+ export function frameToSvg(
115
+ floats: Float32Array,
116
+ theme: Theme,
117
+ box: Box,
118
+ options: SvgOptions = {},
119
+ ): string {
120
+ const bleed = options.bleed ?? 0.25
121
+ const digits = options.digits ?? 1
122
+ const prefix = options.prefix ?? "sea"
123
+ const scale = 10 ** digits
124
+ const camera = held(floats)
125
+ const n = (value: number) => short(value, digits)
126
+ const alpha = (value: number) => short(value, 3)
127
+
128
+ const defs: string[] = []
129
+ const body: string[] = []
130
+ const shares = new Set<number>()
131
+ let made = 0
132
+
133
+ const tint = (mark: Reader, y: number, strength: number): Tint => {
134
+ const hue = hueOf(UNIT, mark.tone, mark.weight, y, box.height)
135
+ const light = LIGHTS.has(mark.tone & 0xff) ? hex(hue) : null
136
+ return { alpha: strength, light, share: Math.round((hue[0] ?? 0) * SHADES) }
137
+ }
138
+
139
+ /** How a drawing names its colour: a class for a share of ink, the light itself otherwise. */
140
+ const named = (colour: Tint, property: "fill" | "stop-color" | "stroke"): string => {
141
+ if (colour.light) return ` ${property}="${colour.light}"`
142
+ shares.add(colour.share)
143
+ const own = property === "fill" ? "" : ` ${property}="currentColor"`
144
+ return ` class="${prefix}${colour.share}"${own}`
145
+ }
146
+
147
+ const opacity = (value: number, property: string): string =>
148
+ value >= 0.999 ? "" : ` ${property}="${alpha(value)}"`
149
+
150
+ /** One colour, read at one height. */
151
+ const reading = (mark: Reader, y: number): Tint =>
152
+ tint(mark, y, leftAt(mark.alpha, mark.fadeTop, mark.fadeSpan, mark.fall, mark.spent, y))
153
+
154
+ const gradient = (open: string, close: string, stops: Tint[]): string => {
155
+ const id = `${prefix}-${made++}`
156
+ const written = stops.map(
157
+ (colour, at) =>
158
+ `<stop offset="${short(at / (stops.length - 1), 3)}"${named(colour, "stop-color")}${opacity(colour.alpha, "stop-opacity")}/>`,
159
+ )
160
+ defs.push(`<${open} id="${id}">${written.join("")}</${close}>`)
161
+ return `url(#${id})`
162
+ }
163
+
164
+ /** The same reading, sampled down the drawing's own height. */
165
+ const down = (mark: Reader, top: number, foot: number): string => {
166
+ const stops: Tint[] = []
167
+ for (let at = 0; at < STOPS; at++) {
168
+ const y = top + ((foot - top) * at) / (STOPS - 1)
169
+ stops.push(reading(mark, mark.shade === OWN ? y : mark.shade))
170
+ }
171
+ const bottom = Math.max(top + 1, foot)
172
+ return gradient(
173
+ `linearGradient gradientUnits="userSpaceOnUse" x1="0" y1="${n(top)}" x2="0" y2="${n(bottom)}"`,
174
+ "linearGradient",
175
+ stops,
176
+ )
177
+ }
178
+
179
+ const light = (mark: Reader) => {
180
+ const cx = floats[mark.first + 1] ?? 0
181
+ const cy = floats[mark.first + 2] ?? 0
182
+ const across = mark.width
183
+ if (across <= 0) return
184
+
185
+ const y = mark.shade === OWN ? cy : mark.shade
186
+ const stops: Tint[] = []
187
+ for (let at = 0; at < STOPS; at++) {
188
+ stops.push(tint(mark, y, mark.alpha * (1 - at / (STOPS - 1)) ** mark.fall))
189
+ }
190
+ const glow = gradient(
191
+ `radialGradient gradientUnits="userSpaceOnUse" cx="0" cy="0" r="${n(across)}"`,
192
+ "radialGradient",
193
+ stops,
194
+ )
195
+ // Squashed under 1, the way `canvas/flat.ts` squashes it: the gradient is
196
+ // read in the same space the circle is drawn in.
197
+ const thin = Math.max(0.001, mark.thin)
198
+ body.push(
199
+ `<circle r="${n(across)}" fill="${glow}" transform="translate(${n(cx)} ${n(cy)}) scale(1 ${short(thin, 3)})"/>`,
200
+ )
201
+ }
202
+
203
+ const shape = (mark: Reader) => {
204
+ let path = ""
205
+ let top = Number.POSITIVE_INFINITY
206
+ let foot = Number.NEGATIVE_INFINITY
207
+ let left = Number.POSITIVE_INFINITY
208
+ let right = Number.NEGATIVE_INFINITY
209
+
210
+ // Whole units of the precision asked for, each point written as the step
211
+ // from the one before, and a step of nothing left out.
212
+ mark.each((from, points) => {
213
+ if (points < 2) return
214
+ let px = 0
215
+ let py = 0
216
+ const steps: string[] = []
217
+ for (let i = 0; i < points; i++) {
218
+ const x = floats[from + i * 2] ?? 0
219
+ const y = floats[from + i * 2 + 1] ?? 0
220
+ if (y < top) top = y
221
+ if (y > foot) foot = y
222
+ if (x < left) left = x
223
+ if (x > right) right = x
224
+
225
+ const qx = Math.round(x * scale)
226
+ const qy = Math.round(y * scale)
227
+ if (i === 0) {
228
+ path += `M${run([n(qx / scale), n(qy / scale)])}`
229
+ } else if (qx !== px || qy !== py) {
230
+ steps.push(n((qx - px) / scale), n((qy - py) / scale))
231
+ }
232
+ px = qx
233
+ py = qy
234
+ }
235
+ if (steps.length > 0) path += `l${run(steps)}`
236
+ if (mark.form !== FORM.stroke) path += "z"
237
+ })
238
+
239
+ if (top > foot) return
240
+
241
+ const margin = Math.max(box.width, box.height) * bleed
242
+ if (right < -margin || left > box.width + margin) return
243
+ if (foot < -margin || top > box.height + margin) return
244
+
245
+ const stroked = mark.form === FORM.stroke
246
+ const property = stroked ? "stroke" : "fill"
247
+ let paint: string
248
+ if (foot - top > box.height * TALL) {
249
+ paint = ` ${property}="${down(mark, top, foot)}"`
250
+ } else {
251
+ const colour = reading(mark, mark.shade === OWN ? (top + foot) / 2 : mark.shade)
252
+ paint = named(colour, property) + opacity(colour.alpha, `${property}-opacity`)
253
+ }
254
+
255
+ if (stroked) {
256
+ body.push(
257
+ `<path d="${path}" fill="none"${paint} stroke-width="${n(Math.max(0.1, mark.width))}" stroke-linecap="round" stroke-linejoin="round"/>`,
258
+ )
259
+ return
260
+ }
261
+
262
+ body.push(`<path d="${path}"${paint}/>`)
263
+ }
264
+
265
+ // Where everything is and how far back it stands, then the same stable sort
266
+ // `canvas/flat.ts` makes: anything you can see the water through keeps the
267
+ // order it arrived in, over everything solid.
268
+ const reader = new Reader(floats)
269
+ const places: number[] = []
270
+ const lanes: number[] = []
271
+ while (reader.next()) {
272
+ const through = reader.alpha < 0.999 || reader.form === FORM.light || reader.form === FORM.wash
273
+ places.push(reader.offset)
274
+ lanes.push(through ? Number.POSITIVE_INFINITY : reader.lane)
275
+ }
276
+ const order = places.map((_, at) => at)
277
+ order.sort((a, b) => {
278
+ const one = lanes[a] ?? 0
279
+ const two = lanes[b] ?? 0
280
+ return one === two ? a - b : one - two
281
+ })
282
+
283
+ const paint = new Reader(floats)
284
+ for (const which of order) {
285
+ paint.stand(places[which] ?? 0)
286
+ if (paint.alpha <= 0.002) continue
287
+ if (paint.form === FORM.wash) {
288
+ body.push(
289
+ `<rect width="${n(box.width)}" height="${n(box.height)}" fill="${down(paint, 0, box.height)}"/>`,
290
+ )
291
+ } else if (paint.form === FORM.light) {
292
+ light(paint)
293
+ } else {
294
+ shape(paint)
295
+ }
296
+ }
297
+
298
+ // A class per share of ink in use, setting `color`, which a fill takes from
299
+ // the group and a stroke or a stop takes as `currentColor`.
300
+ const classes = [...shares]
301
+ .sort((a, b) => a - b)
302
+ .map(
303
+ (share) =>
304
+ `.${prefix}${share}{color:color-mix(in srgb,${theme.ink} ${short((share * 100) / SHADES, 2)}%,${theme.surface})}`,
305
+ )
306
+
307
+ // The frame as the camera holds it, a little larger than its box to pay for
308
+ // the wander, exactly as `canvas/flat.ts` holds it.
309
+ const mx = box.width / 2
310
+ const my = box.height / 2
311
+ const hold =
312
+ `translate(${n(mx + camera.swayX)} ${n(my + camera.swayY)}) ` +
313
+ `rotate(${short((camera.tilt * 180) / Math.PI, 3)}) ` +
314
+ `scale(${short(camera.overscan, 4)}) translate(${n(-mx)} ${n(-my)})`
315
+
316
+ const attributes = Object.entries(options.attributes ?? {})
317
+ .map(([name, value]) => ` ${name}="${escape(value)}"`)
318
+ .join("")
319
+
320
+ return (
321
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n(box.width)} ${n(box.height)}"${attributes}>` +
322
+ `<style>${escape(classes.join(""))}</style>` +
323
+ `<defs>${defs.join("")}</defs>` +
324
+ `<g fill="currentColor" transform="${hold}">${body.join("")}</g></svg>`
325
+ )
326
+ }