@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 +18 -8
- package/package.json +4 -4
- package/src/canvas/flat.ts +336 -0
- package/src/paint/colour.ts +86 -0
- package/src/paint/ink.ts +134 -0
- package/src/paint/svg.ts +326 -0
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
|
-
|
|
7
|
-
sky is doing at this hour, and what each drawing looks
|
|
8
|
-
renderer.
|
|
9
|
-
|
|
10
|
-
- CodinCod's site, whose `assets/js/ornament/` holds the
|
|
11
|
-
|
|
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.
|
|
4
|
-
"description": "The sea behind CodinCod: what lives in it, the weather over it,
|
|
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.
|
|
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
|
+
}
|
package/src/paint/ink.ts
ADDED
|
@@ -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
|
+
}
|
package/src/paint/svg.ts
ADDED
|
@@ -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, "&").replace(/"/g, """).replace(/</g, "<")
|
|
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
|
+
}
|