Ready to test print

This commit is contained in:
2026-07-19 13:37:10 -04:00
parent 3f2d25f90e
commit 2a4fffbb2a
81 changed files with 3923 additions and 34 deletions

67
src/compose/waterline.js Normal file
View File

@@ -0,0 +1,67 @@
/* ============================================================
waterline.js — the world is sovereign, and it doesn't notice.
Splits track polylines at a horizon line yH (scene coords,
[-1,1]) into ABOVE and BELOW sets so a compositor can treat
the submerged evidence differently: the sea never reacts to
the event; everything that crosses the surface is altered BY
the water instead —
· a refraction KINK at the crossing (horizontal offset
driven by the entry slope),
· a slight vertical COMPRESSION of the submerged world
toward the surface (optical squash),
· (tint / blur / dimming are the compositor's job).
β, weight, kind, z, age are preserved across the split so
physics-law palettes keep their colour continuity.
============================================================ */
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
/* one track → runs of consecutive points on one side, with exact
crossing points interpolated onto BOTH adjacent runs */
function runsOf(track, yH) {
const runs = [];
let cur = null;
const pts = track.pts;
const side = (p) => (p.y < yH ? 'above' : 'below');
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
if (!cur) { cur = { side: side(p), pts: [p], entrySlope: 0 }; continue; }
if (side(p) === cur.side) { cur.pts.push(p); continue; }
// crossing: interpolate the surface point
const q = pts[i - 1];
const f = (yH - q.y) / (p.y - q.y);
const cross = {
x: q.x + (p.x - q.x) * f, y: yH,
beta: (q.beta ?? 0.5) + ((p.beta ?? 0.5) - (q.beta ?? 0.5)) * f,
theta: q.theta ?? 0,
};
cur.pts.push(cross);
runs.push(cur);
const slope = (p.x - q.x) / Math.max(1e-6, Math.abs(p.y - q.y));
cur = { side: side(p), pts: [{ ...cross }], entrySlope: clamp(slope, -1.4, 1.4) };
}
if (cur) runs.push(cur);
return runs;
}
/* split a track list at the waterline.
opts: kink (horizontal refraction offset per unit entry slope),
compress (vertical squash of the submerged world, 1 = none) */
export function splitTracksAtWaterline(tracks, yH, opts = {}) {
const kink = opts.kink ?? 0.016;
const comp = opts.compress ?? 0.95;
const above = [], below = [];
for (const t of tracks) {
if (!t.pts || t.pts.length < 2) continue;
for (const run of runsOf(t, yH)) {
if (run.pts.length < 2) continue;
if (run.side === 'above') { above.push({ ...t, pts: run.pts }); continue; }
const dx = run.entrySlope * kink;
below.push({
...t,
pts: run.pts.map(p => ({ ...p, x: p.x + dx, y: yH + (p.y - yH) * comp })),
});
}
}
return { above, below };
}

View File

@@ -51,7 +51,7 @@ export function carpetSVG(size, opts = {}) {
// power-spectrum-shaped spectrum)
warpFn: null, phaseFn: null, modesFn: null,
}, opts);
const W = size, H = size, u = size / 1000;
const W = o.width ?? size, H = o.height ?? size, u = size / 1000; // width/height default to a square of `size`
const paper = resolveSubstrate(o.substrate).paper.flat;
const rng = makeRng(o.seed, o.salt);
@@ -66,6 +66,13 @@ export function carpetSVG(size, opts = {}) {
}
const norm = modes.reduce((s, m) => s + m.a, 0) || 1;
// coherentRows: express the standing-wave field in a SHARED row coordinate
// (this reference count) instead of the layer's own `rows`. Two carpets with
// the same seed/salt/chaos/blips but DIFFERENT rows then phase-lock — the
// crests and spiralling packets land at the same depth, so a stack of sheets
// with different line counts reads as one sea sampled at several resolutions.
const REF = o.coherentRows ?? o.rows;
// ---- coherent excitations: soft wave-packets that drift + rotate phase
// across rows, so they SPIRAL through the depth of the stack ----
const nExc = Math.round((2 + o.chaos * 5) * o.blips);
@@ -73,26 +80,30 @@ export function carpetSVG(size, opts = {}) {
for (let e = 0; e < nExc; e++) {
exc.push({
x0: range(rng, 0.12, 0.88),
row0: range(rng, 0, o.rows - 1),
span: range(rng, 0.12, 0.3) * o.rows, // rows over which it lives
row0: range(rng, 0, REF - 1),
span: range(rng, 0.12, 0.3) * REF, // reference rows over which it lives
w: range(rng, 0.05, 0.11), // packet width (soft edge)
k: range(rng, 3.5, 7), // oscillations within the packet
amp: range(rng, 0.30, 0.7),
drift: range(rng, -0.018, 0.018), // lateral drift per row
drift: range(rng, -0.018, 0.018), // lateral drift per ref-row
phase: range(rng, 0, Math.PI * 2),
phaseAdv: range(rng, -0.45, 0.45), // phase rotation per row → spiral
phaseAdv: range(rng, -0.45, 0.45), // phase rotation per ref-row → spiral
sign: rng() < 0.5 ? -1 : 1,
});
}
// r is this layer's integer row; rr is the shared reference-row coordinate at
// the same depth, so the field is a function of depth, not of line count.
const value = (t, r) => {
const d = o.rows > 1 ? r / (o.rows - 1) : 0;
const rr = d * (REF - 1);
let s = 0;
const dphi = o.phaseFn ? o.phaseFn(t, r) : 0;
for (const m of modes) s += m.a * Math.sin(2 * Math.PI * m.f * t + m.phi + r * m.drift + dphi);
for (const m of modes) s += m.a * Math.sin(2 * Math.PI * m.f * t + m.phi + rr * m.drift + dphi);
s /= norm;
let blip = 0;
for (const e of exc) {
const dr = r - e.row0;
const dr = rr - e.row0;
const env = Math.exp(-Math.pow(dr / (e.span * 0.5), 2));
if (env < 0.02) continue;
const cx = e.x0 + e.drift * dr;

View File

@@ -112,6 +112,25 @@ export function bubbleStops(soft) {
const midA = Math.max(0.15, 0.9 - 0.32 * s);
return [[0, coreA], [midOff, midA], [1, 0]];
}
// Hollow "hybrid" bubble: a faint paper-tinted veil in the core + a brighter,
// SATURATED rim that softens outward — a refracting bubble rather than a filled
// disc. Each stop carries [offset, alpha, paperMix]: paperMix 1 = fully toward
// paper (pale core), 0 = full family colour (jewel rim). `weight` (0..1) scales
// the rim's peak alpha for delicacy; `soft` widens the rim inward.
export function bubbleHollowStops(weight = 0.7, soft = 0.3, peakOverride = null) {
const s = clamp(soft, 0, 1.6);
const peak = peakOverride ?? (0.35 + 0.45 * clamp(weight, 0, 1)); // ~0.7 at weight 0.78
const rimOff = clamp(0.80 - 0.14 * s, 0.6, 0.86); // rim sits ~0.70.8 of radius
return [
[0.00, peak * 0.36, 0.74], // pale veil at the very centre
[Math.max(0.22, rimOff - 0.5), peak * 0.26, 0.5], // dip — the transparent belly
[rimOff - 0.16, peak * 0.72, 0.12], // rim rising, colour saturating
[rimOff, peak, 0.0], // bright saturated rim (peak)
[Math.min(0.94, rimOff + 0.12), peak * 0.55, 0.04],
[1.00, 0.0, 0.0], // feather to nothing
];
}
export const bubbleFoot = (soft) => 2.4 + clamp(soft, 0, 1.6) * 1.5; // sprite footprint grows with softness
/* ---------- the colour mappings ---------- */
@@ -246,6 +265,52 @@ export const PALETTES = {
feature: (e) => featureHue(e.inv, e.diskHue ?? 0.06, e.diskSat ?? 0.82), // disk + furniture
},
// magentarise's type-family with the β law living INSIDE each member: kind
// chooses the hue (hot magenta primaries, pink δ-rays, cooler purples for
// cosmics and V's — the family structure that made seethe-bold's chord), and
// within each track β drives transparent birth → the family hue at full
// strength → a dive to dark violet only at the very end (dying is slowing).
magbeta: {
id: 'magbeta', label: 'Magenta family · β within',
bubbleInk: (b, e) => {
const bb = Math.round(clamp(b.beta ?? 0.5, 0, 1) * 20) / 20;
const slow = 1 - bb;
const kindH = (((MAG_HUE[b.track.kind] ?? 0.9) + (e.traceHue ?? 0)) % 1 + 1) % 1;
const dive = Math.pow(slow, 3.2); // hold the family hue; violet is the last word
const h = kindH - (kindH - 0.755) * dive;
const s = 0.62 + 0.28 * slow;
const l = e.inv ? 0.56 - 0.22 * slow : 0.32 + 0.30 * slow;
const c = hslToRgb(h, s, l);
const toPaper = Math.pow(bb, 1.4) * 0.7; // fast → dissolves into the substrate
return mix(c, e.basePaper.flat, toPaper);
},
feature: (e) => featureHue(e.inv, e.diskHue ?? 0.06, e.diskSat ?? 0.82),
},
// β-law: the strict physics legend. Opacity is already physics (bubble density
// ∝ 1/β² — fast particles barely nucleate), and ink now follows the same law:
// born fast = barely tints the PAPER (mixing toward the substrate, not white —
// transparent births), slowing = saturating through the magenta family, at
// rest = dense violet. "Death earns the violet" emerges without a narrative
// rule, because dying IS slowing. Feature/disk defaults to the ember heart.
betalaw: {
id: 'betalaw', label: 'β law (fast → faint · rest → violet)',
bubbleInk: (b, e) => {
const bb = Math.round(clamp(b.beta ?? 0.5, 0, 1) * 20) / 20; // quantise: bounded gradients
const slow = 1 - bb;
// quadratic hue: the family HOLDS hot magenta through most of a life and
// dives to violet only at the very end — the violet is the last word, not
// the whole sentence.
const h = 0.95 - 0.19 * slow * slow;
const s = 0.60 + 0.30 * slow;
const l = e.inv ? 0.58 - 0.24 * slow : 0.30 + 0.32 * slow;
const c = hslToRgb(h, s, l);
const toPaper = Math.pow(bb, 1.3) * 0.72; // fast → dissolves into the substrate
return mix(c, e.basePaper.flat, toPaper);
},
feature: (e) => featureHue(e.inv, e.diskHue ?? 0.02, e.diskSat ?? 0.88),
},
// a complete "chemistry": overrides paper + ink together (deep Prussian-blue
// ground, pale lines) — the blueprint/cyanotype look.
cyanotype: {

View File

@@ -13,7 +13,7 @@
============================================================ */
import { makeRng, cyrb53 } from '../rng.js';
import { sampleBubbles, trackInkWeight, depthFactors } from '../scene/bubbles.js';
import { resolvePalette, paperTone, rgbHex, rgbKey, bubbleStops, hslToRgb, mix } from './palette.js';
import { resolvePalette, paperTone, rgbHex, rgbKey, bubbleStops, bubbleHollowStops, hslToRgb, mix } from './palette.js';
const MARGIN = 0.02;
@@ -37,12 +37,18 @@ function rgb2hsl([r, g, b]) {
}
export function renderSVG(scene, params, sizePx = 4800) {
const w = sizePx, h = sizePx;
const scale = (w / 2) * (1 - MARGIN);
const cx = w / 2, cy = h / 2;
// Non-square canvas support (defaults preserve exact square behaviour):
// canvasW/canvasH → explicit page size · originX/originY → scene origin (px)
// scale stays isotropic (min dimension) so the event never distorts; the
// scene origin can be placed anywhere on the page (e.g. a golden intersection).
const w = params.canvasW ?? sizePx, h = params.canvasH ?? sizePx;
// sceneZoom > 1 magnifies the whole event about its origin (bubbles AND spray
// extent grow together); content may spill past the page edge. Default 1 = unchanged.
const scale = (Math.min(w, h) / 2) * (1 - MARGIN) * (params.sceneZoom ?? 1);
const cx = params.originX ?? w / 2, cy = params.originY ?? h / 2;
const tx = (x) => (cx + x * scale).toFixed(2);
const ty = (y) => (cy + y * scale).toFixed(2);
const u = w / 1000;
const u = Math.min(w, h) / 1000;
const inv = params.invert;
// base ink (mono), toned paper (independent of ink palette), then resolve feel
@@ -60,7 +66,10 @@ export function renderSVG(scene, params, sizePx = 4800) {
// so the centre reads as compressed colour rather than a hole.
const press = Math.max(0, Math.min(1, params.diskPressure ?? 0));
const [fh, fs, fl] = rgb2hsl(pal.feature());
const coreInk = rgbHex(hslToRgb(fh, Math.min(1, fs * (1 + 0.25 * press)), fl * (1 - 0.6 * press)));
// diskHollow winds the compression back: the core ink relaxes toward the
// feature hue as the centre opens (a grey plug would just become a grey ring)
const hol0 = Math.max(0, Math.min(1, params.diskHollow ?? 0));
const coreInk = rgbHex(hslToRgb(fh, Math.min(1, fs * (1 + 0.25 * press)), fl * (1 - 0.6 * press * (1 - 0.75 * hol0))));
const featKey = rgbKey(pal.feature());
const colorMap = new Map([[featKey, pal.feature()]]); // distinct bubble colours → gradients
@@ -155,6 +164,12 @@ export function renderSVG(scene, params, sizePx = 4800) {
const buckets = new Map(); // layerId -> Map(colorKey|alpha -> {key, alpha, arr})
const ensure = (id) => { if (!under.has(id)) { under.set(id, ''); buckets.set(id, new Map()); } };
const bop = params.bubbleOpacity ?? 1; // master bubble opacity (a single slider)
const hollowOn = (params.bubbleHollow ?? 0) > 0;
// bubbleMerge: crowded bubbles fuse into ONE flat union at a uniform density
// (group opacity isolates → overlaps don't accumulate). Dense knots become
// even lace, not mud — even ink density = even backlit transmission.
const merge = !!params.bubbleMerge;
const bubbleRng = makeRng(params.seed, 'bubbles'); // scene.tracks order → deterministic
for (const track of scene.tracks) {
if (track.pts.length < 2) continue;
@@ -162,16 +177,18 @@ export function renderSVG(scene, params, sizePx = 4800) {
ensure(id);
const df = depthFactors(track, params);
const repCol = pal.ink(track); // representative ink (under-stroke)
// continuity under-stroke
// continuity under-stroke. When bubbles are hollow, thin this solid core
// hard so the delicate rings carry the trail instead of a filled spine.
const iw = trackInkWeight(track);
const lw = Math.min(2.6, 0.25 + Math.sqrt(iw) * 0.12) * u * params.size * track.weight;
if (lw >= 0.2 * u) {
const lw = Math.min(2.6, 0.25 + Math.sqrt(iw) * 0.12) * u * params.size * track.weight * (hollowOn ? 0.6 : 1);
if (lw >= 0.2 * u && !merge) { // merge carries continuity via the union itself
let d = `M ${tx(track.pts[0].x)} ${ty(track.pts[0].y)}`;
for (let i = 1; i < track.pts.length; i++) d += ` L ${tx(track.pts[i].x)} ${ty(track.pts[i].y)}`;
under.set(id, under.get(id) + `<path d="${d}" stroke="${rgbHex(repCol)}" stroke-opacity="${(0.14 * df.tone).toFixed(3)}" stroke-width="${lw.toFixed(2)}"/>`);
const uop = 0.14 * df.tone * bop * (hollowOn ? 0.4 : 1);
under.set(id, under.get(id) + `<path d="${d}" stroke="${rgbHex(repCol)}" stroke-opacity="${uop.toFixed(3)}" stroke-width="${lw.toFixed(2)}"/>`);
}
// bubbles — opacity from depth/age tone, colour per-bubble from palette
const alpha = Math.round(Math.min(1, 0.36 + df.tone * 0.58) * 20) / 20;
// bubbles — merge: uniform bucket (density capped by the group). else: opacity from depth/age tone × master.
const alpha = merge ? 1 : Math.round(Math.min(1, (0.36 + df.tone * 0.58) * bop) * 100) / 100;
const m = buckets.get(id);
for (const b of sampleBubbles({ ...track, sizeScale: df.sizeScale }, params, bubbleRng)) {
const bcol = pal.bubbleInk(track, b.life, b.beta), bkc = rgbKey(bcol);
@@ -187,7 +204,11 @@ export function renderSVG(scene, params, sizePx = 4800) {
const us = under.get(L.id);
if (us) content += `<g fill="none" stroke-linecap="round" stroke-linejoin="round">${us}</g>\n`;
for (const { key, alpha, arr } of buckets.get(L.id).values()) {
if (arr.length) content += `<g fill="url(#bub-${key})" fill-opacity="${alpha}">${arr.join('')}</g>\n`;
if (!arr.length) continue;
// merge: one isolated group at the uniform target → overlaps stay flat (no mud).
content += merge
? `<g fill="url(#bub-${key})" opacity="${bop.toFixed(2)}" style="isolation:isolate">${arr.join('')}</g>\n`
: `<g fill="url(#bub-${key})" fill-opacity="${alpha}">${arr.join('')}</g>\n`;
}
return layer(L.id, L.label, content);
}).join('');
@@ -309,7 +330,7 @@ export function renderSVG(scene, params, sizePx = 4800) {
let s = `<?xml version="1.0" encoding="UTF-8"?>\n`;
s += `<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n`;
s += `<metadata>Bubble Chamber · seed=${params.seed} · hash=${cyrb53(params.seed)} · palette=${params.palette || 'mono'}</metadata>\n`;
s += defs({ paperC, ink, coreInk, press, baseVign: pal.vign(), params, u, colorMap });
s += defs({ paperC, paperFlat: paperRGB.flat, ink, coreInk, press, baseVign: pal.vign(), params, u, colorMap });
// emit: optional array of layer-GROUPS to include, so the plate can be rendered
// as decoupled sub-layers — 'background' | 'disk' | 'bubble' | 'fiduciaries'.
const emit = params.emit;
@@ -327,19 +348,52 @@ export function renderSVG(scene, params, sizePx = 4800) {
return s;
}
function defs({ paperC, ink, coreInk, press = 0, baseVign, params, u, colorMap }) {
// diskHollow: 0 = pressure plug (dense core), 1 = the centre dissolves to the
// paper — density gathers into an ember RING and the heart is left open (for
// backlit media the unprinted centre passes lamp light: the sun glows).
function shockcoreStops({ coreInk, ink, press, hollow }) {
const hol = Math.max(0, Math.min(1, hollow ?? 0));
const core = coreInk || ink;
const a0 = 0.5 + press * 0.45, a1 = 0.32 + press * 0.32;
if (hol <= 0) return `<stop offset="0%" stop-color="${core}" stop-opacity="${a0.toFixed(2)}"/>
<stop offset="28%" stop-color="${core}" stop-opacity="${a1.toFixed(2)}"/>
<stop offset="62%" stop-color="${ink}" stop-opacity="0.28"/>
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>`;
return `<stop offset="0%" stop-color="${core}" stop-opacity="${(a0 * (1 - hol)).toFixed(2)}"/>
<stop offset="${Math.round(16 + 8 * hol)}%" stop-color="${core}" stop-opacity="${(a0 * (1 - 0.72 * hol)).toFixed(2)}"/>
<stop offset="${Math.round(28 + 12 * hol)}%" stop-color="${core}" stop-opacity="${(a1 * (1 - 0.45 * hol)).toFixed(2)}"/>
<stop offset="62%" stop-color="${ink}" stop-opacity="0.28"/>
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>`;
}
function defs({ paperC, paperFlat, ink, coreInk, press = 0, baseVign, params, u, colorMap }) {
const soften = params.diskSoften > 0
? `<filter id="soften" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="${(params.diskSoften * u).toFixed(2)}"/></filter>`
: '';
// one soft-bubble gradient per distinct ink colour used (edge profile shared
// with the raster sprite via bubbleStops)
const stops = bubbleStops(params.bubbleSoft ?? 0.3);
// one bubble gradient per distinct ink colour. Default: soft filled disc
// (edge profile shared with the raster sprite via bubbleStops). Opt-in
// bubbleHollow>0: a hollow refracting bubble — pale core, saturated rim.
const hollow = params.bubbleHollow ?? 0;
let bubGrads = '';
for (const [key, col] of colorMap) {
const c = rgbHex(col);
let st = '';
for (const [off, a] of stops) st += `<stop offset="${(off * 100).toFixed(0)}%" stop-color="${c}" stop-opacity="${a.toFixed(3)}"/>`;
bubGrads += `<radialGradient id="bub-${key}" cx="50%" cy="50%" r="50%">${st}</radialGradient>`;
if (hollow > 0) {
// merge wants a near-opaque rim so union coverage is flat within the group
const hy = bubbleHollowStops(params.bubbleWeight ?? 0.7, params.bubbleSoft ?? 0.3, params.bubbleMerge ? 0.97 : null);
for (const [key, col] of colorMap) {
let st = '';
for (const [off, a, pm] of hy) {
const c = rgbHex(mix(col, paperFlat, pm * hollow)); // rim = family colour, core → paper
st += `<stop offset="${(off * 100).toFixed(0)}%" stop-color="${c}" stop-opacity="${a.toFixed(3)}"/>`;
}
bubGrads += `<radialGradient id="bub-${key}" cx="50%" cy="50%" r="50%">${st}</radialGradient>`;
}
} else {
const stops = bubbleStops(params.bubbleSoft ?? 0.3);
for (const [key, col] of colorMap) {
const c = rgbHex(col);
let st = '';
for (const [off, a] of stops) st += `<stop offset="${(off * 100).toFixed(0)}%" stop-color="${c}" stop-opacity="${a.toFixed(3)}"/>`;
bubGrads += `<radialGradient id="bub-${key}" cx="50%" cy="50%" r="50%">${st}</radialGradient>`;
}
}
const vignHex = rgbHex(baseVign);
return `<defs>
@@ -349,10 +403,7 @@ function defs({ paperC, ink, coreInk, press = 0, baseVign, params, u, colorMap }
</radialGradient>
${bubGrads}
<radialGradient id="shockcore" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="${coreInk || ink}" stop-opacity="${(0.5 + press * 0.45).toFixed(2)}"/>
<stop offset="28%" stop-color="${coreInk || ink}" stop-opacity="${(0.32 + press * 0.32).toFixed(2)}"/>
<stop offset="62%" stop-color="${ink}" stop-opacity="0.28"/>
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>
${shockcoreStops({ coreInk, ink, press, hollow: params.diskHollow })}
</radialGradient>
<radialGradient id="shockstain" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="${ink}" stop-opacity="0.9"/>

105
src/scene/chinagraph.js Normal file
View File

@@ -0,0 +1,105 @@
/* ============================================================
chinagraph.js — the fictional scanner's hand, done right.
Letterforms built as STROKES (no fonts anywhere): each glyph
is a set of polylines in a unit box, rendered with seeded
wobble, per-character baseline drift and rotation, and a
waxy double-pass — the soft fat grease pencil of a 195585
scanning table. Seeded per plate: one plate's hand is
consistent with itself, no two plates' hands identical.
Charset is the scanner's working set: digits, Nº, θ, °, ·,
colon, dash, question — measurement, not prose.
============================================================ */
import { gauss } from '../rng.js';
/* glyphs in a unit box, y down; each = array of polylines */
const ellipse = (cx, cy, rx, ry, n = 11, a0 = -0.4) => {
const pts = [];
for (let i = 0; i <= n; i++) {
const a = a0 + (i / n) * Math.PI * 2 * 1.03; // slight overshoot — doesn't close cleanly
pts.push([cx + Math.cos(a) * rx, cy + Math.sin(a) * ry]);
}
return pts;
};
const GLYPHS = {
'0': [ellipse(0.48, 0.5, 0.3, 0.44)],
'1': [[[0.3, 0.26], [0.55, 0.08], [0.55, 0.95]]],
'2': [[[0.16, 0.3], [0.2, 0.12], [0.5, 0.05], [0.76, 0.16], [0.77, 0.36], [0.15, 0.95], [0.85, 0.93]]],
'3': [[[0.16, 0.13], [0.6, 0.06], [0.8, 0.25], [0.53, 0.46]], [[0.53, 0.46], [0.84, 0.68], [0.6, 0.93], [0.15, 0.87]]],
'4': [[[0.64, 0.08], [0.13, 0.66], [0.87, 0.66]], [[0.64, 0.3], [0.64, 0.95]]],
'5': [[[0.8, 0.08], [0.22, 0.09], [0.17, 0.46], [0.55, 0.4], [0.82, 0.6], [0.6, 0.92], [0.17, 0.87]]],
'6': [[[0.7, 0.07], [0.32, 0.34], [0.17, 0.64], [0.3, 0.92], [0.64, 0.9], [0.75, 0.66], [0.52, 0.5], [0.22, 0.62]]],
'7': [[[0.15, 0.1], [0.85, 0.09], [0.42, 0.95]]],
'8': [[[0.5, 0.08], [0.25, 0.2], [0.31, 0.42], [0.5, 0.5], [0.72, 0.63], [0.66, 0.88], [0.4, 0.92], [0.27, 0.79], [0.35, 0.56], [0.5, 0.5], [0.71, 0.36], [0.67, 0.15], [0.5, 0.08]]],
'9': [[[0.74, 0.38], [0.5, 0.5], [0.27, 0.44], [0.22, 0.2], [0.46, 0.07], [0.7, 0.13], [0.75, 0.4], [0.6, 0.7], [0.35, 0.94]]],
'N': [[[0.1, 0.95], [0.1, 0.08]], [[0.1, 0.1], [0.8, 0.93]], [[0.8, 0.95], [0.8, 0.08]]],
'º': [ellipse(0.42, 0.24, 0.17, 0.17, 8), [[0.14, 0.6], [0.72, 0.58]]],
'°': [ellipse(0.45, 0.18, 0.14, 0.14, 8)],
'θ': [ellipse(0.48, 0.5, 0.28, 0.44), [[0.22, 0.5], [0.75, 0.5]]],
':': [[[0.48, 0.3], [0.51, 0.33]], [[0.48, 0.72], [0.51, 0.75]]],
'·': [[[0.48, 0.5], [0.52, 0.53]]],
'-': [[[0.15, 0.52], [0.8, 0.5]]],
'+': [[[0.16, 0.5], [0.8, 0.49]], [[0.48, 0.18], [0.49, 0.82]]],
'h': [[[0.16, 0.08], [0.16, 0.95]], [[0.16, 0.5], [0.4, 0.36], [0.66, 0.42], [0.7, 0.95]]],
'?': [[[0.2, 0.24], [0.32, 0.08], [0.62, 0.06], [0.78, 0.22], [0.68, 0.44], [0.48, 0.55], [0.48, 0.7]], [[0.47, 0.88], [0.5, 0.91]]],
' ': [],
};
/* render a string as chinagraph strokes.
o: { x, y px origin (baseline-left)
h char height px
rng seeded stream (one per plate → consistent hand)
ink css colour
width base stroke width px
slant italic shear (default slight) }
Returns an SVG fragment (a <g>). */
export function chinagraphText(str, o) {
const { x, y, h, rng, ink } = o;
const w0 = o.width ?? h * 0.11;
const slant = o.slant ?? 0.07;
const cw = h * 0.72;
let cx = x;
let out = '';
for (const ch of String(str)) {
const glyph = GLYPHS[ch] ?? GLYPHS['·'];
const rot = gauss(rng) * 0.035;
const dy = gauss(rng) * 0.045 * h;
const cos = Math.cos(rot), sin = Math.sin(rot);
for (const stroke of glyph) {
if (!stroke.length) continue;
// two passes: the waxy body + a lighter ghost slightly offset (pencil drag)
for (let pass = 0; pass < 2; pass++) {
const off = pass * 0.05 * h, op = pass ? 0.3 : 0.82;
const wv = w0 * (0.8 + rng() * 0.45);
let d = '';
stroke.forEach((pt, i) => {
let gx = (pt[0] - slant * (1 - pt[1])) * cw * 1.18;
let gy = (pt[1] - 0.9) * h;
const rx = gx * cos - gy * sin, ry = gx * sin + gy * cos;
const jx = gauss(rng) * 0.013 * h, jy = gauss(rng) * 0.013 * h;
d += `${i ? 'L' : 'M'} ${(cx + rx + jx + off).toFixed(1)} ${(y + dy + ry + jy + off * 0.4).toFixed(1)} `;
});
out += `<path d="${d}" fill="none" stroke="${ink}" stroke-opacity="${op}" stroke-width="${wv.toFixed(2)}" stroke-linecap="round" stroke-linejoin="round"/>`;
}
}
cx += cw * (ch === '·' || ch === ':' ? 0.55 : ch === ' ' ? 0.6 : 1);
}
return `<g>${out}</g>`;
}
/* a wobbly grease arrow (shaft + open head), same hand */
export function chinagraphArrow(x1, y1, x2, y2, o) {
const { rng, ink } = o;
const w = o.width ?? 3;
const seg = 8;
let d = '';
for (let i = 0; i <= seg; i++) {
const t = i / seg;
const px = x1 + (x2 - x1) * t + gauss(rng) * 2.2;
const py = y1 + (y2 - y1) * t + gauss(rng) * 2.2;
d += `${i ? 'L' : 'M'} ${px.toFixed(1)} ${py.toFixed(1)} `;
}
const ang = Math.atan2(y2 - y1, x2 - x1), hl = Math.hypot(x2 - x1, y2 - y1) * 0.14;
const head = (da) => `M ${x2.toFixed(1)} ${y2.toFixed(1)} L ${(x2 - Math.cos(ang + da) * hl + gauss(rng) * 1.5).toFixed(1)} ${(y2 - Math.sin(ang + da) * hl + gauss(rng) * 1.5).toFixed(1)} `;
return `<g fill="none" stroke="${ink}" stroke-linecap="round" stroke-opacity="0.8" stroke-width="${w}">`
+ `<path d="${d}"/><path d="${head(0.42)}${head(-0.42)}"/></g>`;
}