@codincod/ornament 0.1.0 → 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.
package/README.md CHANGED
@@ -3,26 +3,36 @@
3
3
  The sea behind [CodinCod](https://codincod.com): what lives in it, the weather
4
4
  over it, and how each thing is drawn. Published as `@codincod/ornament`.
5
5
 
6
- Nothing here touches a browser. The modules decide where a fish goes, what the
7
- sky is doing at this hour, and what each drawing looks like, and hand that to a
8
- renderer. Two renderers read it:
9
-
10
- - CodinCod's site, whose `assets/js/ornament/` holds the canvas, SVG and WebGPU
11
- halves and the LiveView hooks that start them.
6
+ Outside `src/canvas/`, nothing here touches a browser. The modules decide where
7
+ a fish goes, what the sky is doing at this hour, and what each drawing looks
8
+ like, and hand that to a renderer. Three read it:
9
+
10
+ - CodinCod's site, whose `assets/js/ornament/` holds the SVG and WebGPU halves
11
+ and the LiveView hooks that start them, and draws with `src/canvas/` where
12
+ there is no graphics card.
13
+ - [codin-cod-maintenance](https://codeberg.org/reeven/codin-cod-maintenance),
14
+ the page codincod.com shows while it is down, which draws with `src/canvas/`
15
+ and bakes a still with `src/paint/svg.ts`.
12
16
  - [seascape](https://github.com/reeveng/seascape), the desktop background, in a
13
17
  bare V8 and in Qt's JavaScript engine.
14
18
 
15
19
  `tsconfig.json` leaves the DOM out of `lib`, so `pnpm check` fails on any
16
- module that reaches for one.
20
+ module that reaches for one. `src/canvas/` is checked on its own, by
21
+ `tsconfig.canvas.json`, with the DOM.
17
22
 
18
23
  ## Layout
19
24
 
20
25
  - `src/*.ts`: one simulation per kind of thing in the water or the sky, each
21
26
  with its test beside it.
22
27
  - `src/paint/`: what one drawing looks like, and `sea.ts`, which builds a whole
23
- frame out of them.
28
+ frame out of them. `ink.ts` is the colour arithmetic every renderer shares,
29
+ `colour.ts` reads a theme's colours back out of a stylesheet, and `svg.ts`
30
+ writes a frame as SVG markup, for a page that has to look right with no
31
+ script.
24
32
  - `src/sizes.ts` and `src/census.ts`: how big things are and how many of them
25
33
  there are.
34
+ - `src/canvas/flat.ts`: a frame onto a 2D canvas, the one module here that
35
+ needs the DOM.
26
36
 
27
37
  The package ships its TypeScript source. Import a module by its path:
28
38
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@codincod/ornament",
3
- "version": "0.1.0",
4
- "description": "The sea behind CodinCod: what lives in it, the weather over it, and how each thing is drawn, with no DOM",
3
+ "version": "0.2.0",
4
+ "description": "The sea behind CodinCod: what lives in it, the weather over it, how each thing is drawn, and a canvas to draw it on",
5
5
  "keywords": [
6
6
  "codincod",
7
7
  "generative",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "devDependencies": {
23
23
  "@biomejs/biome": "^2.5.15",
24
- "@types/node": "^26.6.4",
24
+ "@types/node": "^26.6.5",
25
25
  "typescript": "^7.0.2"
26
26
  },
27
27
  "homepage": "https://codincod.com/",
@@ -30,7 +30,7 @@
30
30
  "url": "https://codeberg.org/reeven/codin-cod-ornament"
31
31
  },
32
32
  "scripts": {
33
- "check": "tsc --noEmit -p tsconfig.json",
33
+ "check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.canvas.json",
34
34
  "fmt": "biome check --write .",
35
35
  "fmt:check": "biome check .",
36
36
  "test": "node --test \"src/**/*.test.ts\""
@@ -0,0 +1,336 @@
1
+ /**
2
+ * The sea, onto a canvas, with nothing but the browser's own rasteriser.
3
+ *
4
+ * The renderer every machine has. `card.ts` is faster wherever there is a card
5
+ * to talk to, and this is what draws the water when there is not: an old
6
+ * browser, a machine without WebGPU, a device that answered the adapter request
7
+ * with nothing. The picture is the same picture.
8
+ *
9
+ * ## Two things a card does per pixel that a canvas cannot
10
+ *
11
+ * A fragment shader reads the water's own gradient at the height of every pixel
12
+ * it fills, and works out how far a light has given out at that same height. A
13
+ * canvas fill takes one colour, or one gradient built before the fill starts.
14
+ *
15
+ * So the height a colour is read at is decided per drawing here. Most of what
16
+ * swims is small enough that one reading is the truth: a fish is a few dozen px
17
+ * tall and the water does not change over that. What is not small says so, by
18
+ * fading down the box or by covering a good share of it, and those are drawn
19
+ * through a gradient with the same arithmetic sampled along it. Both renderers
20
+ * agree on the numbers; they disagree on how often they ask.
21
+ *
22
+ * ## Why the fill rule matters more than it looks
23
+ *
24
+ * An animal is several closed shapes meant to overlap: a body, a tail swung off
25
+ * its joint, a fin rooted inside the back. Filled as one path with the non-zero
26
+ * rule they are one silhouette, which is the animal. Filled one after another
27
+ * they are three shapes painted over each other, and at the weight ornament is
28
+ * drawn at every overlap shows as a bright seam. A canvas fills non-zero by
29
+ * default, so this gets it for nothing; the card has to be told.
30
+ */
31
+ import { FORM, held, OWN, Reader, type View } from "../paint/frame.ts"
32
+ import { css, hueOf, leftAt, type Palette } from "../paint/ink.ts"
33
+
34
+ /**
35
+ * How tall a drawing has to be, as a share of the box, before it is worth a
36
+ * gradient rather than one reading of the water.
37
+ *
38
+ * Under this the error is smaller than the step between two neighbouring values
39
+ * of an eight bit channel, so a gradient would cost a fill and change nothing.
40
+ */
41
+ const TALL = 0.08
42
+
43
+ /**
44
+ * How many places a gradient is sampled at.
45
+ *
46
+ * Both the water's colour and how far a light has given out are curves, and a
47
+ * gradient between two stops is a straight line. This is where the two stop
48
+ * being told apart on a screen.
49
+ */
50
+ const STOPS = 7
51
+
52
+ export interface Box {
53
+ height: number
54
+ width: number
55
+ }
56
+
57
+ export interface FlatOptions {
58
+ /**
59
+ * How far past the box a drawing may stand before it is skipped, as a share
60
+ * of the box.
61
+ *
62
+ * Nothing in the water knows where the edge of the picture is, so a fish that
63
+ * has left is still swum and a plant behind a mass of near rock is still cut.
64
+ * A renderer is the last chance to not pay for them.
65
+ */
66
+ bleed?: number
67
+ }
68
+
69
+ /** What of the water the box is showing. */
70
+ interface Seen {
71
+ foot: number
72
+ left: number
73
+ right: number
74
+ top: number
75
+ }
76
+
77
+ export interface Flat {
78
+ draw(floats: Float32Array, palette: Palette, box: Box, density?: number, view?: View | null): void
79
+ }
80
+
81
+ /**
82
+ * A canvas that will draw a frame of this water whenever it is handed one.
83
+ *
84
+ * It keeps no clock and owns no simulation. Something else steps the sea and
85
+ * hands over the block; all this does is put it on the screen.
86
+ */
87
+ export function createFlat(canvas: HTMLCanvasElement, options: FlatOptions = {}): Flat | null {
88
+ const context = canvas.getContext("2d")
89
+ if (!context) return null
90
+
91
+ const bleed = options.bleed ?? 0.25
92
+
93
+ // Where each drawing is and how far back it stands, so the solid ones can be
94
+ // put in order. Kept between frames: a busy sea is a few thousand drawings,
95
+ // and three arrays of them rebuilt sixty times a second is the sort of
96
+ // housekeeping that costs more than the work it is for.
97
+ let places = new Int32Array(4096)
98
+ let lanes = new Float32Array(4096)
99
+ let order: number[] = []
100
+
101
+ const room = (needed: number) => {
102
+ if (needed > places.length) {
103
+ const size = 1 << Math.ceil(Math.log2(Math.max(16, needed)))
104
+ places = new Int32Array(size)
105
+ lanes = new Float32Array(size)
106
+ }
107
+ while (order.length < needed) order.push(order.length)
108
+ if (order.length > needed) order.length = needed
109
+ }
110
+
111
+ return {
112
+ /**
113
+ * One frame, onto the canvas.
114
+ *
115
+ * `box` is the water's own size in the units it was laid out in, which is
116
+ * not the canvas's size in pixels: a screen at twice the density draws the
117
+ * same sea out of four times as many of them.
118
+ */
119
+ draw(
120
+ floats: Float32Array,
121
+ palette: Palette,
122
+ box: Box,
123
+ density = 1,
124
+ view: View | null = null,
125
+ ): void {
126
+ const camera = held(floats)
127
+ const reader = new Reader(floats)
128
+
129
+ context.setTransform(density, 0, 0, density, 0, 0)
130
+ context.clearRect(0, 0, box.width, box.height)
131
+
132
+ const middle = { x: box.width / 2, y: box.height / 2 }
133
+
134
+ if (view) {
135
+ context.translate(middle.x, middle.y)
136
+ context.scale(view.scale, view.scale)
137
+ context.translate(-view.x, -view.y)
138
+ } else {
139
+ // The frame, held by somebody rather than bolted to a tripod. It is
140
+ // drawn a little larger than its box to pay for the wander, which is
141
+ // what overscan is for: a frame that has wandered must never be a frame
142
+ // with the page showing along one edge.
143
+ context.translate(middle.x + camera.swayX, middle.y + camera.swayY)
144
+ context.rotate(camera.tilt)
145
+ context.scale(camera.overscan, camera.overscan)
146
+ context.translate(-middle.x, -middle.y)
147
+ }
148
+
149
+ // What of the water is actually in the box, which a window on a larger
150
+ // water sees only a piece of. Nothing in the sea knows where the edge of
151
+ // the picture is, so this is the last chance not to pay for what is past
152
+ // it.
153
+ const seen: Seen = view
154
+ ? {
155
+ foot: view.y + box.height / (2 * view.scale),
156
+ left: view.x - box.width / (2 * view.scale),
157
+ right: view.x + box.width / (2 * view.scale),
158
+ top: view.y - box.height / (2 * view.scale),
159
+ }
160
+ : { foot: box.height, left: 0, right: box.width, top: 0 }
161
+
162
+ room(reader.count)
163
+
164
+ // Where everything is, and which of the two it is in. Anything you can
165
+ // see the water through is laid over what is already there in the order
166
+ // it arrives, since two lights over one another are a painting rather
167
+ // than a stack of distances; everything else takes its turn by how far
168
+ // back it stands, which an infinite lane arranges.
169
+ let kept = 0
170
+ while (reader.next()) {
171
+ const through =
172
+ reader.alpha < 0.999 || reader.form === FORM.light || reader.form === FORM.wash
173
+ places[kept] = reader.offset
174
+ lanes[kept] = through ? Number.POSITIVE_INFINITY : reader.lane
175
+ kept++
176
+ }
177
+
178
+ // Stable, because equal lanes have to keep the order they were drawn in.
179
+ order.length = kept
180
+ for (let at = 0; at < kept; at++) order[at] = at
181
+ order.sort((a, b) => {
182
+ const one = lanes[a] ?? 0
183
+ const two = lanes[b] ?? 0
184
+ return one === two ? a - b : one - two
185
+ })
186
+
187
+ const paint = new Reader(floats)
188
+ for (const which of order) {
189
+ paint.stand(places[which] ?? 0)
190
+ one(context, paint, palette, box, seen, bleed)
191
+ }
192
+
193
+ context.setTransform(1, 0, 0, 1, 0, 0)
194
+ },
195
+ }
196
+ }
197
+
198
+ /** One drawing, whichever of the four kinds it is. */
199
+ function one(
200
+ context: CanvasRenderingContext2D,
201
+ mark: Reader,
202
+ palette: Palette,
203
+ box: Box,
204
+ seen: Seen,
205
+ bleed: number,
206
+ ): void {
207
+ if (mark.alpha <= 0.002) return
208
+
209
+ if (mark.form === FORM.wash) {
210
+ context.fillStyle = down(context, mark, palette, box, 0, box.height)
211
+ context.fillRect(0, 0, box.width, box.height)
212
+ return
213
+ }
214
+
215
+ if (mark.form === FORM.light) {
216
+ light(context, mark, palette, box)
217
+ return
218
+ }
219
+
220
+ const floats = mark.floats
221
+ const shape = new Path2D()
222
+ let top = Number.POSITIVE_INFINITY
223
+ let foot = Number.NEGATIVE_INFINITY
224
+ let left = Number.POSITIVE_INFINITY
225
+ let right = Number.NEGATIVE_INFINITY
226
+
227
+ mark.each((from, points) => {
228
+ if (points < 2) return
229
+ for (let i = 0; i < points; i++) {
230
+ const x = floats[from + i * 2] ?? 0
231
+ const y = floats[from + i * 2 + 1] ?? 0
232
+ if (i === 0) shape.moveTo(x, y)
233
+ else shape.lineTo(x, y)
234
+ if (y < top) top = y
235
+ if (y > foot) foot = y
236
+ if (x < left) left = x
237
+ if (x > right) right = x
238
+ }
239
+ if (mark.form !== FORM.stroke) shape.closePath()
240
+ })
241
+
242
+ if (top > foot) return
243
+
244
+ // Off the picture entirely, which happens to a fish that has left and to
245
+ // every plant standing behind a mass of near rock.
246
+ const margin = Math.max(seen.right - seen.left, seen.foot - seen.top) * bleed
247
+ if (right < seen.left - margin || left > seen.right + margin) return
248
+ if (foot < seen.top - margin || top > seen.foot + margin) return
249
+
250
+ const tall = foot - top > box.height * TALL
251
+ const ink = tall
252
+ ? down(context, mark, palette, box, top, foot)
253
+ : flat(mark, palette, box, mark.shade === OWN ? (top + foot) / 2 : mark.shade)
254
+
255
+ if (mark.form === FORM.stroke) {
256
+ context.strokeStyle = ink
257
+ context.lineWidth = Math.max(0.1, mark.width)
258
+ context.lineCap = "round"
259
+ context.lineJoin = "round"
260
+ context.stroke(shape)
261
+ return
262
+ }
263
+
264
+ context.fillStyle = ink
265
+ context.fill(shape)
266
+ }
267
+
268
+ /**
269
+ * A round light, as an ellipse with the falling off built into a gradient.
270
+ *
271
+ * The card does this per pixel out of a distance from the middle. Here it is a
272
+ * radial gradient with the same curve sampled along it, which is the one place
273
+ * a canvas gets the better bargain: a gradient is a gradient however many
274
+ * pixels it covers.
275
+ */
276
+ function light(context: CanvasRenderingContext2D, mark: Reader, palette: Palette, box: Box): void {
277
+ const floats = mark.floats
278
+ const cx = floats[mark.first + 1] ?? 0
279
+ const cy = floats[mark.first + 2] ?? 0
280
+ const across = mark.width
281
+ if (across <= 0) return
282
+
283
+ const hue = hueOf(
284
+ palette,
285
+ mark.tone,
286
+ mark.weight,
287
+ mark.shade === OWN ? cy : mark.shade,
288
+ box.height,
289
+ )
290
+ // Under 1 this is a streak rather than a disc, which is what a lens does with
291
+ // a light it cannot quite hold. The gradient is built inside the squash as
292
+ // well as the shape, since a gradient is read in whatever space it is filled.
293
+ const thin = Math.max(0.001, mark.thin)
294
+
295
+ context.save()
296
+ context.translate(cx, cy)
297
+ context.scale(1, thin)
298
+
299
+ const glow = context.createRadialGradient(0, 0, 0, 0, 0, across)
300
+ for (let stop = 0; stop < STOPS; stop++) {
301
+ const t = stop / (STOPS - 1)
302
+ glow.addColorStop(t, css(hue, mark.alpha * (1 - t) ** mark.fall))
303
+ }
304
+
305
+ context.fillStyle = glow
306
+ context.beginPath()
307
+ context.arc(0, 0, across, 0, Math.PI * 2)
308
+ context.fill()
309
+ context.restore()
310
+ }
311
+
312
+ /** One colour, read at one height. */
313
+ function flat(mark: Reader, palette: Palette, box: Box, y: number): string {
314
+ const hue = hueOf(palette, mark.tone, mark.weight, y, box.height)
315
+ return css(hue, leftAt(mark.alpha, mark.fadeTop, mark.fadeSpan, mark.fall, mark.spent, y))
316
+ }
317
+
318
+ /** The same, sampled down the drawing's own height. */
319
+ function down(
320
+ context: CanvasRenderingContext2D,
321
+ mark: Reader,
322
+ palette: Palette,
323
+ box: Box,
324
+ top: number,
325
+ foot: number,
326
+ ): CanvasGradient {
327
+ const run = context.createLinearGradient(0, top, 0, Math.max(top + 1, foot))
328
+
329
+ for (let stop = 0; stop < STOPS; stop++) {
330
+ const t = stop / (STOPS - 1)
331
+ const y = top + (foot - top) * t
332
+ run.addColorStop(t, flat(mark, palette, box, mark.shade === OWN ? y : mark.shade))
333
+ }
334
+
335
+ return run
336
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * A theme's colour, as numbers a renderer can mix.
3
+ *
4
+ * A renderer is handed its two colours as whatever a page's stylesheet hands
5
+ * back, and needs them as sRGB in 0 to 1 to mix the water from them.
6
+ *
7
+ * ## Why the colours are parsed rather than measured
8
+ *
9
+ * The obvious way to turn a theme token into numbers is to fill a pixel with it
10
+ * and read the pixel back, which is the browser doing the colour conversion for
11
+ * you. It is also wrong here. A browser with fingerprinting protection on
12
+ * (Librewolf, Brave, Firefox with `resistFingerprinting`) perturbs canvas
13
+ * readback on purpose, so the same token comes back a slightly different colour
14
+ * on every load, and an ornament whose hue wanders between reloads is an
15
+ * ornament that looks broken.
16
+ *
17
+ * Parsing is therefore the only stable answer. Only what a computed style can
18
+ * actually hand back has to be understood, which is `rgb()` for anything the
19
+ * page states in sRGB and `oklch()` for the theme tokens, since a computed
20
+ * colour keeps the space it was authored in. Anything else returns null and the
21
+ * caller falls back to colours of its own.
22
+ */
23
+
24
+ export type Rgb = readonly [number, number, number]
25
+
26
+ const clamp01 = (value: number): number => Math.min(1, Math.max(0, value))
27
+
28
+ /**
29
+ * Oklab to linear sRGB, then to sRGB. Ottosson's matrices, as published.
30
+ *
31
+ * Out-of-gamut values are clamped per channel, which is what a browser does
32
+ * when it paints one.
33
+ */
34
+ function oklabToRgb(lightness: number, a: number, b: number): Rgb {
35
+ const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3
36
+ const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3
37
+ const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3
38
+
39
+ const linear = [
40
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
41
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
42
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
43
+ ]
44
+
45
+ return linear.map((channel) => {
46
+ const value = clamp01(channel)
47
+ return value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055
48
+ }) as unknown as Rgb
49
+ }
50
+
51
+ const numbers = (css: string): number[] =>
52
+ css
53
+ .slice(css.indexOf("(") + 1, css.lastIndexOf(")"))
54
+ .split(/[\s,/]+/)
55
+ .filter(Boolean)
56
+ .map((part) => (part.endsWith("%") ? Number.parseFloat(part) / 100 : Number.parseFloat(part)))
57
+
58
+ /**
59
+ * A computed colour as sRGB in 0 to 1, or null for anything not understood,
60
+ * which includes the transparent an element with no background of its own
61
+ * reports.
62
+ */
63
+ export function parseColour(css: string): Rgb | null {
64
+ const value = css.trim().toLowerCase()
65
+
66
+ if (value.startsWith("rgb")) {
67
+ const [r, g, b, alpha] = numbers(value)
68
+ if (r == null || g == null || b == null || alpha === 0) return null
69
+ return [clamp01(r / 255), clamp01(g / 255), clamp01(b / 255)]
70
+ }
71
+
72
+ if (value.startsWith("oklch")) {
73
+ const [lightness, chroma, hue, alpha] = numbers(value)
74
+ if (lightness == null || chroma == null || alpha === 0) return null
75
+ const angle = ((hue ?? 0) * Math.PI) / 180
76
+ return oklabToRgb(lightness, chroma * Math.cos(angle), chroma * Math.sin(angle))
77
+ }
78
+
79
+ if (value.startsWith("oklab")) {
80
+ const [lightness, a, b, alpha] = numbers(value)
81
+ if (lightness == null || a == null || b == null || alpha === 0) return null
82
+ return oklabToRgb(lightness, a, b)
83
+ }
84
+
85
+ return null
86
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * What colour a drawing comes out, and how much of it there is.
3
+ *
4
+ * Every colour in this scene is worked out from two: the theme's own ink and
5
+ * the surface it is standing on. That is what lets a theme switch recolour the
6
+ * whole sea without a single drawing knowing it happened, and it is why nothing
7
+ * in `paint/` names a colour. A drawing says how heavy it is and which of the
8
+ * two it is mixed from, and the answer is worked out here.
9
+ *
10
+ * The exceptions are the lights, which are the only colours in the picture that
11
+ * are not the theme's. A sun is the colour a sun is.
12
+ *
13
+ * ## Where it is read
14
+ *
15
+ * A card reads it per pixel, in the fragment shader, so a shape spanning half
16
+ * the box takes the water's own gradient across its whole height. A canvas
17
+ * cannot: a fill is one colour, or one gradient built before the fill. So the
18
+ * same arithmetic is here in TypeScript, and `flat.ts` decides how many times to
19
+ * ask it. The two agree on every number, which is the point of the file.
20
+ */
21
+
22
+ import type { Rgb } from "./colour.ts"
23
+ import { TONE } from "./pen.ts"
24
+
25
+ /**
26
+ * How much ink is in the water at the surface.
27
+ *
28
+ * The water is not the page's background with things drawn on it. It is a
29
+ * column, lightest at the top where the light comes in and giving out with
30
+ * depth, and this is how far from the background the lit top of it stands.
31
+ */
32
+ const LIT = 0.13
33
+
34
+ /**
35
+ * How fast the light gives out going down.
36
+ *
37
+ * Over 1, so the top half of the box keeps most of it and the bottom goes
38
+ * quickly, which is what looking down a water column actually looks like. A
39
+ * straight line reads as a printed gradient.
40
+ */
41
+ const SINKAGE = 2.1
42
+
43
+ /** The three lights, which are the only colours here the theme does not own. */
44
+ const SUNLIGHT: Rgb = [1, 0.93, 0.76]
45
+ const MOONLIGHT: Rgb = [0.95, 0.92, 0.85]
46
+ const DUSKLIGHT: Rgb = [0.91, 0.64, 0.3]
47
+
48
+ export interface Palette {
49
+ /** The theme's own ink, which everything in the water is drawn towards. */
50
+ ink: Rgb
51
+ /** What the water is standing on, which everything fades into. */
52
+ surface: Rgb
53
+ }
54
+
55
+ const mix = (a: Rgb, b: Rgb, at: number): Rgb => [
56
+ a[0] + (b[0] - a[0]) * at,
57
+ a[1] + (b[1] - a[1]) * at,
58
+ a[2] + (b[2] - a[2]) * at,
59
+ ]
60
+
61
+ /** The water at a height in the box, lit from above and giving up with depth. */
62
+ export function waterAt(palette: Palette, y: number, height: number): Rgb {
63
+ const t = Math.min(1, Math.max(0, y / Math.max(1, height)))
64
+ return mix(palette.surface, mix(palette.surface, palette.ink, LIT), (1 - t) ** SINKAGE)
65
+ }
66
+
67
+ /**
68
+ * One drawing's colour: the water where it is, with its own weight mixed in.
69
+ *
70
+ * `weight` is how much of the thing stopped the light. A body is one weight,
71
+ * because a body at this distance is one shape the light did not come back
72
+ * through, and that is what a silhouette is.
73
+ */
74
+ export function hueOf(
75
+ palette: Palette,
76
+ tone: number,
77
+ weight: number,
78
+ y: number,
79
+ height: number,
80
+ ): Rgb {
81
+ const base = waterAt(palette, y, height)
82
+ const w = Math.min(1, Math.max(0, weight))
83
+
84
+ switch (tone & 0xff) {
85
+ case TONE.shadow:
86
+ return mix(base, palette.surface, w)
87
+ case TONE.sun:
88
+ return SUNLIGHT
89
+ case TONE.moon:
90
+ return MOONLIGHT
91
+ case TONE.dusk:
92
+ return DUSKLIGHT
93
+ case TONE.surface:
94
+ return palette.surface
95
+ case TONE.ink:
96
+ return palette.ink
97
+ // The water column, which on a page is the page itself: whatever is behind
98
+ // the canvas is what you see through it, so nothing is painted at all.
99
+ case TONE.wall:
100
+ return base
101
+ default:
102
+ return mix(base, palette.ink, w)
103
+ }
104
+ }
105
+
106
+ export const css = (hue: Rgb, alpha: number): string =>
107
+ `rgba(${Math.round(hue[0] * 255)},${Math.round(hue[1] * 255)},${Math.round(hue[2] * 255)},${alpha})`
108
+
109
+ /**
110
+ * What is left of a shape's own light at a height in the box.
111
+ *
112
+ * Two shapes of falling off and no others. Everything that fades down the box
113
+ * takes the same exponent, because a light in water does not thin in a straight
114
+ * line and a straight line is what two ends of a gradient draw. `spent` is the
115
+ * one that does not: it holds most of its strength for the first half of its
116
+ * length and then goes quickly, which is what a shaft of sunlight does and no
117
+ * single exponent draws.
118
+ */
119
+ export function leftAt(
120
+ alpha: number,
121
+ fadeTop: number,
122
+ fadeSpan: number,
123
+ fall: number,
124
+ spent: boolean,
125
+ y: number,
126
+ ): number {
127
+ if (fadeSpan <= 0) return alpha
128
+ const down = Math.min(1, Math.max(0, (y - fadeTop) / fadeSpan))
129
+ if (spent) {
130
+ const held = 1 - down * down
131
+ return alpha * held * held
132
+ }
133
+ return alpha * (1 - down) ** fall
134
+ }
@@ -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
+ }