@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.
- package/LICENSE +661 -0
- package/README.md +62 -2
- package/package.json +35 -3
- package/src/biome.ts +157 -0
- package/src/canvas/flat.ts +336 -0
- package/src/caustics.ts +81 -0
- package/src/census.ts +262 -0
- package/src/cephalopods.ts +1041 -0
- package/src/cloud.ts +308 -0
- package/src/crags.ts +974 -0
- package/src/drift.ts +435 -0
- package/src/eels.ts +667 -0
- package/src/fish_shape.ts +410 -0
- package/src/flora.ts +1955 -0
- package/src/flow_field.ts +491 -0
- package/src/jellies.ts +1794 -0
- package/src/nemos.ts +606 -0
- package/src/paint/bed.ts +231 -0
- package/src/paint/colour.ts +86 -0
- package/src/paint/frame.ts +204 -0
- package/src/paint/ink.ts +134 -0
- package/src/paint/life.ts +611 -0
- package/src/paint/light.ts +132 -0
- package/src/paint/pen.ts +225 -0
- package/src/paint/rock.ts +325 -0
- package/src/paint/sea.ts +809 -0
- package/src/paint/shade.ts +109 -0
- package/src/paint/sky.ts +377 -0
- package/src/paint/specimen.ts +300 -0
- package/src/paint/svg.ts +326 -0
- package/src/paint/trace.ts +164 -0
- package/src/passers.ts +730 -0
- package/src/perlin.ts +168 -0
- package/src/plenty.ts +65 -0
- package/src/puffers.ts +510 -0
- package/src/rays.ts +225 -0
- package/src/reef.ts +945 -0
- package/src/relics.ts +451 -0
- package/src/seabed.ts +525 -0
- package/src/shoal.ts +1658 -0
- package/src/sizes.ts +86 -0
- package/src/sun.ts +500 -0
- package/src/swarm.ts +587 -0
- package/src/visitors.ts +1936 -0
- package/src/walkers.ts +861 -0
|
@@ -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
|
+
}
|
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
|
+
}
|