feat(mockups): 16 voyage readable (Inter labels, listen & lyrics tab, combined paths)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 03:29:27 +01:00
1 parent 27b1d2fe26
commit b500115a8b
13 files changed
+2743 -185

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
// Copied from 13: a small redraw of 12's general chart, with "You are here". In 14 it lives in the Paths tab.
// Coordinates are 12's general chart scaled into a 320×210 card.
const P = (x: number, y: number): [number, number] => [10 + ((x - 50) * 300) / 1050, 10 + ((y - 85) * 190) / 660];
const PORTS: [number, number][] = [
P(344, 277), P(404, 331), P(432, 407), P(420, 488), P(373, 552),
P(456, 498), P(543, 576), P(679, 576), P(766, 499), P(960, 400),
];
const LOOP = P(410, 592);
const AT_SEA = P(392, 584);
const pt = (p: [number, number]) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`;
const port = (i: number) => PORTS[i]!;
/** The ship's course on the card, as one path (12's red dashed line). */
const COURSE = `M${pt(port(0))} Q${pt(P(380, 300))} ${pt(port(1))} Q${pt(P(440, 360))} ${pt(port(2))} L${pt(port(3))} L${pt(port(4))}
Q${pt(P(380, 600))} ${pt(LOOP)} Q${pt(P(440, 570))} ${pt(port(5))} Q${pt(P(480, 570))} ${pt(port(6))} L${pt(port(7))}
Q${pt(P(730, 560))} ${pt(port(8))} Q${pt(P(860, 530))} ${pt(port(9))}`;
/** Where "You are here" sits: k=0 out at sea after Troy, k=1 at the first Cyclops port. */
export function hereAt(k: number): [number, number] {
const a = AT_SEA, b = LOOP, c = port(5);
// two straight legs are close enough at this size
if (k < 0.4) { const s = k / 0.4; return [a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s]; }
const s = (k - 0.4) / 0.6;
return [b[0] + (c[0] - b[0]) * s, b[1] + (c[1] - b[1]) * s];
}
export function chartSvg(): string {
const [ix, iy] = P(605, 470);
const coast = `M0,0 L${pt(P(275, 85))} Q${pt(P(300, 200))} ${pt(P(290, 300))} Q${pt(P(390, 400))} ${pt(P(375, 430))} Q${pt(P(330, 520))} ${pt(P(285, 550))} Q${pt(P(240, 680))} ${pt(P(200, 745))} L0,210 Z`;
const ports = PORTS.map((p, i) => `<circle class="c-port" cx="${p[0].toFixed(1)}" cy="${p[1].toFixed(1)}" r="2.6"/><text class="c-num" x="${(p[0] + 4).toFixed(1)}" y="${(p[1] - 3).toFixed(1)}">${i + 1}</text>`).join("");
let rhumbs = "";
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
rhumbs += `M206,48 L${(206 + Math.cos(a) * 260).toFixed(0)},${(48 + Math.sin(a) * 260).toFixed(0)} `;
}
let spiral = "";
for (let r = 3; r < 15; r += 3) spiral += `<ellipse class="c-ink" cx="${port(9)[0].toFixed(1)}" cy="${(port(9)[1] - 4).toFixed(1)}" rx="${r * 1.5}" ry="${r * 0.8}"/>`;
return `<svg class="chart__svg" viewBox="0 0 320 210" role="img" aria-label="General chart of the voyage from Troy, with the ship's position marked">
<defs><clipPath id="chart-clip"><rect x="6" y="6" width="308" height="198"/></clipPath></defs>
<rect class="c-vellum" x="0" y="0" width="320" height="210"/>
<g clip-path="url(#chart-clip)">
<path class="c-rhumb" d="${rhumbs}"/>
<path class="c-land" d="${coast}"/>
<ellipse class="c-land" cx="${ix.toFixed(1)}" cy="${iy.toFixed(1)}" rx="38" ry="22"/>
<path class="c-ink" d="M${(ix - 6).toFixed(1)},${(iy + 6).toFixed(1)} a8,8 0 0 1 16,0" />
<ellipse class="c-land" cx="${P(450, 195)[0]}" cy="${P(450, 195)[1]}" rx="9" ry="6"/>
<ellipse class="c-land" cx="${P(722, 335)[0]}" cy="${P(722, 335)[1]}" rx="5" ry="3.5"/>
${spiral}
<path class="c-course" d="${COURSE}"/>
${ports}
<text class="c-place" x="16" y="34">Troy</text>
<text class="c-place" x="${(ix - 30).toFixed(1)}" y="${(iy - 28).toFixed(1)}">Cyclops' Island</text>
<text class="c-sea" x="${P(870, 440)[0].toFixed(1)}" y="${P(870, 440)[1].toFixed(1)}">two years at sea</text>
<g class="c-rose" transform="translate(292,178)"><circle r="11"/><path d="M0,-15 L3,0 L0,15 L-3,0 Z M-15,0 L0,3 L15,0 L0,-3 Z"/></g>
</g>
<g class="c-here" id="chart-here">
<circle class="c-here__ring" r="7"/>
<circle class="c-here__dot" r="3"/>
<text class="c-here__label" x="10" y="-8">You are here</text>
</g>
<rect class="c-frame" x="4" y="4" width="312" height="202"/>
</svg>`;
}
@@ -0,0 +1,72 @@
// Placeholder fan works for the panel's "Fan works" tab. Every title and handle here is made up and
// clearly marked as a placeholder: no real fan artists, no real links. Local to 14 (src/data/ has no
// fan-works model yet; see open question 3b in the round notes).
export type FanWorkType = "art" | "animatic" | "cover" | "cosplay";
export interface FanWork { type: FanWorkType; title: string; handle: string }
const P = "@fan-artist";
const BY_SONG: Record<string, FanWork[]> = {
"the-horse-and-the-infant": [
{ type: "art", title: "Placeholder: the horse at the gates, ink study", handle: P },
{ type: "animatic", title: "Placeholder: storyboard of the final night", handle: P },
{ type: "cover", title: "Placeholder: piano cover", handle: P },
],
"full-speed-ahead": [
{ type: "art", title: "Placeholder: the fleet under sail", handle: P },
{ type: "cover", title: "Placeholder: sea-shanty arrangement", handle: P },
{ type: "animatic", title: "Placeholder: crew at the oars, animatic", handle: P },
{ type: "cosplay", title: "Placeholder: Eurylochus armour build", handle: P },
],
polyphemus: [
{ type: "art", title: "Placeholder: one eye in the dark", handle: P },
{ type: "animatic", title: "Placeholder: the cave scene, animatic", handle: P },
{ type: "cosplay", title: "Placeholder: Cyclops costume and club", handle: P },
{ type: "cover", title: "Placeholder: low-strings cover", handle: P },
],
survive: [
{ type: "art", title: "Placeholder: the crew by firelight", handle: P },
{ type: "cover", title: "Placeholder: choir cover", handle: P },
{ type: "animatic", title: "Placeholder: escape plan, animatic", handle: P },
],
};
export function fanWorksFor(songId: string): FanWork[] {
return BY_SONG[songId] ?? [
{ type: "art", title: "Placeholder: illustration", handle: P },
{ type: "cover", title: "Placeholder: cover", handle: P },
{ type: "animatic", title: "Placeholder: animatic", handle: P },
];
}
export const TYPE_LABEL: Record<FanWorkType, string> = { art: "Art", animatic: "Animatic", cover: "Cover", cosplay: "Cosplay" };
/** A stylised placeholder thumbnail per type (no external images). Colours come from CSS custom properties. */
export function thumbSvg(type: FanWorkType, accent: string, seed: number): string {
const r = (n: number) => ((Math.sin(seed * 12.9898 + n * 78.233) * 43758.5453) % 1 + 1) % 1;
let art = "";
if (type === "art") {
art = `<circle cx="${120 + r(1) * 30}" cy="34" r="16" fill="${accent}"/>
<path d="M0 80 Q40 ${58 + r(2) * 10} 80 76 T160 70 V100 H0Z" class="t-ink"/>
<path d="M10 88 q14 -8 28 0 t28 0 t28 0 t28 0 t28 0" class="t-line"/>
<path d="M${40 + r(3) * 40} 70 l10 -30 l10 30z" class="t-ink"/>`;
} else if (type === "animatic") {
let frames = "";
for (let i = 0; i < 3; i++) frames += `<rect x="${8 + i * 50}" y="22" width="44" height="56" rx="2" class="t-frame"/><path d="M${18 + i * 50} ${66 - i * 6} q12 ${-18 + i * 6} 24 ${-4 - i * 3}" class="t-line" style="stroke:${accent}"/>`;
let holes = "";
for (let x = 6; x < 160; x += 12) holes += `<rect x="${x}" y="8" width="6" height="6" class="t-hole"/><rect x="${x}" y="86" width="6" height="6" class="t-hole"/>`;
art = `<rect x="0" y="0" width="160" height="100" class="t-ink"/>${holes}${frames}`;
} else if (type === "cover") {
let bars = "";
for (let i = 0; i < 18; i++) { const h = 10 + r(i) * 46; bars += `<rect x="${78 + i * 4.4}" y="${50 - h / 2}" width="2.6" height="${h}" fill="${accent}"/>`; }
art = `<circle cx="40" cy="50" r="30" class="t-ink"/><circle cx="40" cy="50" r="9" fill="${accent}"/><circle cx="40" cy="50" r="20" class="t-line"/>${bars}`;
} else {
art = `<path d="M80 18 a10 10 0 1 1 0.1 0 M64 22 q16 -18 34 -4 l-4 6 q-14 -8 -26 2z" class="t-ink"/>
<path d="M66 36 h28 l6 34 h-10 l-2 22 h-16 l-2 -22 h-10z" class="t-ink"/>
<path d="M100 30 l22 -14" class="t-line" style="stroke:${accent};stroke-width:4"/>
<path d="M66 52 h34" class="t-line" style="stroke:${accent}"/>`;
}
return `<svg class="fw__thumb" viewBox="0 0 160 100" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Placeholder image">
<rect width="160" height="100" class="t-bg"/>${art}</svg>`;
}
+269
View File
@@ -0,0 +1,269 @@
// Copied from 04-greek-frieze/figures.ts via 13 (mockups never import across folders).
// Black-figure painting kit: glaze (fill: var(--ink)) with "incised" detail lines scratched back to the clay (stroke: var(--inc)).
// Every panel shares one 300-unit-tall coordinate space with the ground line at GROUND.
export const PANEL_H = 300;
export const GROUND = 284;
type Arms = "spear" | "open" | "down" | "raise" | "reach" | "mourn" | "club" | "throw";
interface Person {
x: number; y?: number; s?: number; f?: 1 | -1; arms?: Arms;
helmet?: boolean; shield?: boolean; robe?: boolean; cyclops?: boolean; kneel?: boolean;
beard?: boolean; infant?: boolean; cup?: boolean;
}
export const g = (t: string, body: string) => `<g transform="${t}">${body}</g>`;
export const inc = (d: string, w = 1.6) => `<path class="inc" d="${d}" stroke-width="${w}"/>`;
export const ink = (d: string) => `<path class="ink" d="${d}"/>`;
// A limb: glaze stroke over a wider clay stroke, so it reads as incised where it crosses glaze.
export const limb = (d: string, w = 6) =>
`<path class="limb-edge" d="${d}" stroke-width="${w + 3}"/><path class="limb" d="${d}" stroke-width="${w}"/>`;
export function person(p: Person): string {
const { x, y = GROUND, s = 1, f = 1, arms = "down" } = p;
const lift = p.kneel ? 36 : 0;
let legs = "";
if (p.kneel) {
legs = limb("M4,-44 L34,-40 L36,-2", 8) + limb("M-2,-44 L-24,-4 L-4,-2", 8);
} else if (!p.robe) {
legs = limb("M-2,-80 L-10,-40 L-24,-2 L-14,-2", 8) + limb("M4,-80 L14,-40 L30,-2 L40,-2", 8);
}
let upper = "";
// back arm sits behind the torso
if (arms === "open") upper += limb("M-8,-148 L-36,-176 L-44,-196");
if (arms === "mourn") upper += limb("M-8,-148 L-14,-120 L-6,-104");
upper += ink("M-3,-160 h8 v10 h-8 Z");
upper += ink("M-13,-154 L15,-154 L9,-104 L-5,-104 Z");
if (p.robe) {
upper += ink("M-9,-108 L11,-108 L28,-2 L-24,-2 Z");
upper += inc("M1,-100 L2,-8 M-5,-96 L-14,-8 M7,-96 L18,-8 M-20,-30 L24,-30");
} else {
upper += ink("M-8,-108 L10,-108 L20,-74 L-18,-74 Z");
upper += inc("M1,-104 L1,-77 M-5,-100 L-10,-77 M7,-100 L12,-77");
}
upper += inc("M-9,-146 L11,-146 M-2,-150 L1,-112");
// head
upper += `<circle class="ink" cx="2" cy="-168" r="11"/>`;
upper += ink("M10,-173 L18,-166 L10,-162 Z");
if (p.beard) upper += ink("M-2,-162 L13,-162 L5,-146 Z") + inc("M2,-158 L5,-150", 1.2);
if (p.cyclops) {
upper += `<circle class="clay" cx="7" cy="-171" r="5"/><circle class="ink" cx="8.5" cy="-171" r="2.4"/>`;
} else {
upper += inc("M5,-171 h5", 1.4);
}
if (p.helmet) {
upper += ink("M-12,-166 A14 14 0 0 1 15,-170 L15,-160 L9,-160 L9,-166 Z");
upper += ink("M-18,-178 Q-8,-214 24,-194 Q4,-200 -8,-176 Z");
upper += inc("M-10,-166 L13,-168 M4,-176 L4,-166");
} else if (!p.cyclops) {
upper += inc("M-6,-176 Q2,-182 10,-176", 1.3);
}
if (p.shield) {
upper += `<circle class="ink" cx="12" cy="-124" r="32"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="26" stroke-width="1.6"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="7" stroke-width="1.6"/>`;
}
// front arm
switch (arms) {
case "spear":
upper += limb("M10,-148 L30,-132 L40,-150");
upper += limb("M-4,-52 L72,-222", 3.5) + ink("M72,-222 L66,-244 L80,-226 Z");
break;
case "open": upper += limb("M10,-148 L40,-178 L48,-198"); break;
case "raise": upper += limb("M10,-148 L22,-182 L26,-214"); break;
case "reach": upper += limb("M10,-148 L36,-140 L56,-146"); break;
case "mourn": upper += limb("M10,-148 L26,-136 L12,-172"); break;
case "club":
upper += limb("M10,-148 L30,-130 L40,-142");
upper += limb("M18,-96 L66,-214", 10) + ink("M60,-218 Q66,-236 78,-214 Z");
break;
case "throw":
upper += limb("M10,-148 L-6,-182 L-12,-214");
upper += `<circle class="ink" cx="-16" cy="-232" r="18"/>` + inc("M-26,-238 Q-16,-246 -6,-236 M-24,-226 l10,2", 1.4);
break;
default: upper += limb("M10,-148 L16,-104");
}
if (p.infant) {
upper += `<ellipse class="ink" cx="58" cy="-150" rx="7.5" ry="17" transform="rotate(-14 58 -150)"/><circle class="ink" cx="63" cy="-174" r="7"/>`;
upper += inc("M52,-158 l12,-3 M53,-148 l12,-3 M54,-138 l11,-3", 1.3);
}
if (p.cup) {
upper += ink("M48,-162 h26 q-2,12 -13,12 q-11,0 -13,-12 Z M60,-150 v5 h-5 h10");
upper += inc("M52,-158 h18", 1.2);
}
const body = legs + (lift ? g(`translate(0,${lift})`, upper) : upper);
return g(`translate(${x},${y}) scale(${f * s},${s})`, body);
}
export function trojanHorse(x: number, s: number): string {
let b = "";
b += ink("M-92,-32 h176 v12 h-176 Z");
for (const lx of [-60, -42, 30, 48]) b += limb(`M${lx},-112 L${lx},-30`, 10);
b += limb("M-80,-150 Q-108,-128 -98,-88", 6);
b += ink("M-84,-164 h150 q10,0 10,12 v36 q0,14 -14,14 h-132 q-14,0 -14,-14 v-36 q0,-12 12,-12 Z");
b += ink("M40,-156 L60,-236 L88,-230 L68,-140 Z");
b += ink("M58,-238 Q70,-256 100,-244 L124,-208 Q120,-196 106,-202 L84,-224 Z M62,-246 L68,-266 L76,-244 Z");
b += inc("M-76,-146 H62 M-76,-130 H62 M-76,-116 H62 M-40,-160 V-104 M0,-160 V-104 M36,-160 V-104");
b += inc("M62,-228 l6,-6 l2,8 l6,-6 l2,8 l6,-6 M50,-196 l6,-6 l2,8 l6,-6", 1.4);
b += `<circle class="inc" cx="90" cy="-232" r="3" stroke-width="1.6"/>`;
b += inc("M-6,-150 h28 v22 h-28 Z", 1.4);
for (const wx of [-66, 56]) {
b += `<circle class="ink" cx="${wx}" cy="-12" r="13"/><circle class="inc" cx="${wx}" cy="-12" r="8" stroke-width="1.4"/>`;
b += inc(`M${wx - 8},-12 h16 M${wx},-20 v16`, 1.2);
}
return g(`translate(${x},${GROUND}) scale(${s})`, b);
}
export function ship(x: number, y: number, s: number, o: { sail?: boolean; tilt?: number; f?: 1 | -1 } = {}): string {
const f = o.f ?? 1;
let b = "";
for (let i = 0; i < 9; i++) {
const ox = -96 + i * 24;
b += `<path class="oar" d="M${ox},-44 L${ox - 18},6" />`;
}
b += `<path class="rig" d="M-84,-218 L-130,-60 M84,-218 L120,-48 M0,-232 L150,-90"/>`;
b += limb("M0,-50 L0,-236", 5);
if (o.sail) {
b += ink("M-78,-222 h156 v104 q-78,-18 -156,0 Z");
let grid = "";
for (let gx = -58; gx < 78; gx += 20) grid += `M${gx},-220 V-122 `;
grid += "M-76,-196 H76 M-76,-172 H76 M-76,-148 H76";
b += inc(grid, 1.3);
}
b += limb("M-86,-222 L86,-222", 4);
for (let i = 0; i < 8; i++) b += `<circle class="ink" cx="${-84 + i * 24}" cy="-62" r="6.5"/>`;
b += ink("M-160,-104 Q-150,-52 -108,-30 L118,-30 L178,-36 L132,-46 L140,-84 L128,-86 Q122,-54 100,-54 L-100,-54 Q-140,-58 -150,-104 Z");
b += inc("M-112,-40 H120 M-118,-48 H104", 1.4);
b += `<circle class="clay" cx="122" cy="-44" r="4.5"/><circle class="ink" cx="123" cy="-44" r="2"/>`;
return g(`translate(${x},${y}) rotate(${o.tilt ?? 0}) scale(${f * s},${s})`, b);
}
export function waves(x0: number, x1: number, y: number, size = 1): string {
let d = "";
const step = 36 * size;
for (let x = x0; x < x1 - step / 2; x += step) {
d += `M${x},${y} C${x + 8 * size},${y - 22 * size} ${x + 34 * size},${y - 24 * size} ${x + 34 * size},${y - 8 * size} C${x + 34 * size},${y} ${x + 22 * size},${y + 2 * size} ${x + 20 * size},${y - 6 * size} `;
}
return `<path class="wave" d="${d}" stroke-width="${4 * size}"/><path class="wave" d="M${x0},${y + 2} H${x1}" stroke-width="3"/>`;
}
function stele(x: number): string {
return g(`translate(${x},${GROUND})`,
ink("M-36,0 v-12 h72 v12 Z M-24,-12 V-150 L0,-178 L24,-150 V-12 Z") +
inc("M-18,-146 H18 M-14,-60 H14 M-14,-48 H14 M-14,-36 H14") +
`<circle class="inc" cx="0" cy="-112" r="12" stroke-width="1.6"/><circle class="inc" cx="0" cy="-112" r="5" stroke-width="1.2"/>`);
}
const bolt = (x: number, y: number, s: number) =>
g(`translate(${x},${y}) scale(${s})`, ink("M0,0 L22,0 L10,34 L24,34 L-6,92 L2,48 L-12,48 Z"));
function cave(w: number): string {
return ink(`M0,0 H${w} V${PANEL_H} H${w - 30} Q${w - 20},${66} ${w / 2},${58} Q30,66 32,${PANEL_H} H0 Z`) +
inc(`M${w - 22},120 q-6,-30 -14,-48 M20,150 q6,-40 18,-70 M${w * 0.3},40 q30,-6 60,-2 M${w * 0.62},44 q24,0 46,10`, 1.6);
}
export interface PanelArt { w: number; art: string; inscription?: "dark" | "light"; inscrEnd?: boolean }
const ground = (w: number) => `<path class="ground" d="M0,${GROUND} H${w}"/>`;
export const PANELS: Record<string, PanelArt> = {
"the-horse-and-the-infant": { w: 370, art: trojanHorse(132, 0.9) + person({ x: 300, f: -1, arms: "reach", helmet: true, infant: true }) },
"just-a-man": {
w: 240,
art: person({ x: 96, arms: "mourn", helmet: true, beard: true }) +
limb("M178,284 L192,70", 3.5) + ink("M192,70 L186,48 L200,66 Z") +
`<circle class="ink" cx="176" cy="250" r="32"/><circle class="inc" cx="176" cy="250" r="26" stroke-width="1.6"/>` +
inc("M162,236 l28,28 M190,236 l-28,28", 1.4),
},
"full-speed-ahead": { w: 390, art: waves(0, 390, 290) + ship(190, 276, 1, { sail: true }) },
"open-arms": {
w: 300,
art: person({ x: 92, arms: "open", beard: true }) + person({ x: 214, f: -1, arms: "reach", robe: true }),
},
"warrior-of-the-mind": {
w: 340,
art: person({ x: 98, arms: "spear", helmet: true, shield: true, robe: true }) +
person({ x: 246, f: -1, arms: "spear", helmet: true, shield: true, beard: true }),
},
polyphemus: {
w: 340,
art: person({ x: 76, arms: "reach", beard: true, cup: true }) +
person({ x: 252, f: -1, s: 1.2, arms: "club", cyclops: true, beard: true }),
},
survive: {
w: 340, inscription: "light",
art: person({ x: 214, f: -1, s: 1.02, arms: "down", cyclops: true, beard: true }) +
person({ x: 82, s: 0.62, arms: "mourn", kneel: true }) + person({ x: 120, s: 0.62, arms: "reach" }) + cave(340),
},
"remember-them": {
w: 310,
art: stele(155) + person({ x: 62, arms: "mourn", kneel: true, beard: true }) + person({ x: 250, f: -1, arms: "mourn", robe: true }),
},
"my-goodbye": {
w: 400, inscrEnd: true,
art: waves(160, 400, 290) + person({ x: 78, s: 1.15, arms: "throw", cyclops: true, beard: true }) +
ship(292, 276, 0.62, { sail: true }),
},
storm: {
w: 350,
art: bolt(52, 40, 1) + bolt(282, 52, 0.8) + ship(176, 252, 0.86, { tilt: -9 }) + waves(0, 350, 278, 1.3) + waves(18, 350, 296, 0.9),
},
};
const escXml = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
/** The painted scene for a song: figures plus its name written in the field, as on a vase. */
export function panelSvg(id: string, title: string, withTitle = true): string {
const p = PANELS[id] ?? { w: 260, art: person({ x: 130 }) };
const light = p.inscription === "light";
const x = p.inscrEnd ? p.w - 16 : light ? 46 : 16;
const label = withTitle
? `<text class="inscr${light ? " inscr--light" : ""}" x="${x}" y="${light ? 32 : 28}"${p.inscrEnd ? ' text-anchor="end"' : ""}>${escXml(title.toUpperCase())}</text>`
: "";
return `<svg class="scene" viewBox="0 0 ${p.w} ${PANEL_H}" aria-hidden="true" focusable="false">${p.art}${ground(p.w)}${label}</svg>`;
}
export function panelWidth(id: string): number {
return PANELS[id]?.w ?? 260;
}
/** Narrow ladder band between consecutive songs of one saga. */
export function ladderSvg(): string {
let d = "M5,0 V300 M21,0 V300 ";
for (let y = 8; y < 300; y += 12) d += `M5,${y} H21 `;
return `<svg class="sep-art" viewBox="0 0 26 300" aria-hidden="true"><path class="rule" d="${d}"/></svg>`;
}
export function palmette(cx: number, cy: number, flip = false): string {
let leaves = "";
for (let i = -3; i <= 3; i++) {
leaves += `<ellipse class="ink" cx="0" cy="-26" rx="5.5" ry="18" transform="rotate(${i * 22})"/>`;
}
const volutes = `<path class="vol" d="M0,0 C-10,10 -28,8 -28,-4 C-28,-12 -18,-14 -16,-6 M0,0 C10,10 28,8 28,-4 C28,-12 18,-14 16,-6"/>`;
return g(`translate(${cx},${cy}) scale(1,${flip ? -1 : 1})`, leaves + `<circle class="ink" cx="0" cy="-2" r="7"/>` + volutes);
}
/** A saga boundary: palmettes framing the saga's name, painted vertically. */
export function sagaSvg(name: string, gap: number): string {
const tally = gap > 0
? `<text class="tally" x="66" y="296" text-anchor="middle">${gap} ${gap === 1 ? "year" : "years"} later</text>`
: "";
return `<svg class="sep-art" viewBox="0 0 132 300" aria-hidden="true">
<path class="rule" d="M4,0 V300 M128,0 V300"/>
${palmette(66, 52)}${palmette(66, gap > 0 ? 226 : 248, true)}
<text class="saga-name" transform="translate(71,150) rotate(-90)" text-anchor="middle">${escXml(name.replace(/^The /, "").toUpperCase())}</text>
${tally}
</svg>`;
}
/** Elapsed story time between two songs, painted as a tally of years. */
export function gapSvg(gap: number): string {
const w = 40 + gap * 24;
let bars = "";
for (let i = 0; i < gap; i++) bars += `M${20 + i * 24 + 6},120 V180 `;
return `<svg class="sep-art" viewBox="0 0 ${w} 300" aria-hidden="true">
<path class="rule" d="M4,0 V300 M${w - 4},0 V300"/>
<path class="tally-bars" d="${bars}"/>
<text class="tally" transform="translate(${w / 2 + 4},250) rotate(-90)" >+${gap} yr</text>
</svg>`;
}
+226
View File
@@ -0,0 +1,226 @@
// From 13/14 via 15: the two frieze "curtains" in the 04 black-figure kit. In 15 the viewBox is cropped to the
// band, so it is fitted to the stage (no empty glaze above and below), and a register under the ground line
// holds a running-wave scroll, or (paths B) the current song's motif threads.
import type { Song } from "../../data/types";
import { GROUND, PANEL_H, g, inc, ink, limb, palmette, person, PANELS, ship, waves } from "./figures";
import { bright, byTrack, hitsOf, indexOf, routeOf, songMotifs } from "./threads";
export const VB_W = 1440;
export const VB_H = 850;
/** Band layout (stage units): top border, painted field (panels are 300 units tall, drawn at SCALE), bottom border. */
const BAND_TOP = 146;
const FIELD_Y = 222;
const SCALE = 1.24;
/** Where the painted ground line falls on the stage (also the 3D horizon at the start of the morph). */
export const GROUND_Y = FIELD_Y + GROUND * SCALE; // ≈ 574
/** The register above the band: a palmette chain, as on a vase's neck (fills the stage height in 15). */
const NECK_H = 96;
/** The register under the ground line: wave scroll (paths A, C) or motif threads (paths B). */
const REG_H = 78;
const BOT_Y = GROUND_Y + REG_H;
const BAND_BOT = BOT_Y + 100;
/** The cropped viewBox: the band plus a margin of glaze. */
export const VB_Y = BAND_TOP - NECK_H - 20;
export const VB_FIT_H = BAND_BOT + 22 - VB_Y;
/** Centre of the cave mouth in the Cyclops frieze, in stage units: the zoom goes through here. */
export const MOUTH = { x: 720, y: FIELD_Y + (GROUND - 100) * SCALE };
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
function defs(id: string): string {
const meander = (key: string, y: number) =>
`<pattern id="${id}-${key}" y="${y}" width="28" height="28" patternUnits="userSpaceOnUse"><path class="mean" d="M0 25.2H28M4.2 25.2V3H24V20.3H9.8V8.4H18.2V14.7" /></pattern>`;
return `<defs>
${meander("mt", BAND_TOP + 14)}${meander("mb", BOT_Y + 8)}
<pattern id="${id}-tongues" y="${BAND_TOP + 50}" width="18" height="22" patternUnits="userSpaceOnUse">
<path class="ink" d="M2 0H16V13A7 7 0 0 1 2 13Z" />
</pattern>
<pattern id="${id}-chain" x="0" y="${BAND_TOP - NECK_H + 10}" width="84" height="78" patternUnits="userSpaceOnUse">
<path class="vol" d="M0 39 Q10.5 62 21 62 Q31.5 62 42 39 Q52.5 16 63 16 Q73.5 16 84 39"/>
${g("translate(21,60) scale(0.66)", palmette(0, 0))}${g("translate(63,18) scale(0.66)", palmette(0, 0, true))}
</pattern>
<pattern id="${id}-scroll" x="0" y="${GROUND_Y + 14}" width="56" height="52" patternUnits="userSpaceOnUse">
<path class="scroll" d="M0 44 C20 44 34 36 34 20 C34 9 24 5 18 11 C13 16 18 23 24 19" />
<path class="scroll" d="M34 20 C34 34 44 44 56 44" />
</pattern>
<pattern id="${id}-rays" y="${BOT_Y + 46}" width="32" height="54" patternUnits="userSpaceOnUse">
<path class="ink" d="M2 54L16 6L30 54Z" />
</pattern>
</defs>`;
}
/**
* Shared chrome, split at the ground line into a top half (surround, band, top border) and a bottom half,
* so the morph can part them along the line that becomes the horizon.
*/
function chrome(id: string, threads = "") {
const X0 = -200, W = VB_W + 400;
const rule = (y: number) => `<path class="rule" d="M${X0} ${y}H${X0 + W}"/>`;
const frame = (y: number) => `<path class="frame-rule" d="M${X0} ${y}H${X0 + W}M${X0} ${y + 5}H${X0 + W}"/>`;
const top = `<g class="f-top">
<rect class="f-surround" x="${X0}" y="-600" width="${W}" height="${GROUND_Y + 600}"/>
<rect class="f-band" x="${X0}" y="${BAND_TOP - NECK_H}" width="${W}" height="${GROUND_Y - BAND_TOP + NECK_H}"/>
${rule(BAND_TOP - NECK_H + 4)}
<rect x="${X0}" y="${BAND_TOP - NECK_H + 10}" width="${W}" height="78" fill="url(#${id}-chain)"/>
${frame(BAND_TOP - 12)}${rule(BAND_TOP + 8)}
<rect x="${X0}" y="${BAND_TOP + 14}" width="${W}" height="28" fill="url(#${id}-mt)"/>
${rule(BAND_TOP + 46)}
<rect x="${X0}" y="${BAND_TOP + 50}" width="${W}" height="22" fill="url(#${id}-tongues)"/>
</g>`;
const bottom = `<g class="f-bottom">
<rect class="f-surround" x="${X0}" y="${GROUND_Y}" width="${W}" height="${VB_H - GROUND_Y + 600}"/>
<rect class="f-band" x="${X0}" y="${GROUND_Y}" width="${W}" height="${BAND_BOT - GROUND_Y}"/>
<rect class="f-scroll" x="${X0}" y="${GROUND_Y + 14}" width="${W}" height="52" fill="url(#${id}-scroll)"/>
<g class="f-threads">${threads}</g>
${rule(BOT_Y + 2)}
<rect x="${X0}" y="${BOT_Y + 8}" width="${W}" height="28" fill="url(#${id}-mb)"/>
${rule(BOT_Y + 40)}
<rect x="${X0}" y="${BOT_Y + 46}" width="${W}" height="54" fill="url(#${id}-rays)"/>
${frame(BAND_BOT + 6)}
</g>`;
return { top, bottom };
}
/**
* Paths B in a frieze: the song's motifs as ink-edged threads in the register under the ground line,
* knotted under each painted song where the motif plays, running off the band where it continues.
*/
function friezeThreads(song: Song, stops: { song: string; x: number }[]): string {
const ms = songMotifs(song);
const cur = indexOf(song);
let out = "";
ms.forEach((m, k) => {
const y = GROUND_Y + 16 + k * 23 - (ms.length - 3) * 8;
const hits = hitsOf(m.id);
const inFrieze = stops.filter((st) => hits.some((h) => h === stopIndex(st.song)));
const first = hits[0]!, last = hits[hits.length - 1]!;
const firstStop = stopIndex(stops[0]!.song), lastStop = stopIndex(stops[stops.length - 1]!.song);
const x0 = first < firstStop ? 304 : inFrieze[0]?.x ?? 304;
const x1 = last > lastStop ? VB_W + 200 : inFrieze[inFrieze.length - 1]?.x ?? x0;
const wav = (x: number) => y + Math.sin(x / 34 + k * 1.9) * 2.6;
let d = "";
for (let x = x0; x < x1; x += 8) d += `${d ? "L" : "M"}${x},${wav(x).toFixed(1)}`;
d += `L${x1},${wav(x1).toFixed(1)}`;
const r = routeOf(song, m.id);
out += `<g class="ft" data-m="${m.id}" data-pick="${m.id}" style="--c:${bright(m.id)}">
<title>${esc(m.name)}: ${esc(r.from)} · ${esc(r.next)}</title>
<path class="ft__edge" d="${d}"/><path class="ft__line" d="${d}"/>
${inFrieze.map((st) => `<circle class="ft__knot${stopIndex(st.song) === cur ? " is-here" : ""}" cx="${st.x}" cy="${wav(st.x).toFixed(1)}" r="${stopIndex(st.song) === cur ? 8 : 6}"/>`).join("")}
<circle class="ft__dot" cx="32" cy="${y}" r="6"/>
<text class="ft__name" x="46" y="${y + 7}">${esc(m.name)}</text>
</g>`;
});
return out;
}
const stopIndex = (songId: string) => hitsIndex.get(songId) ?? -1;
const hitsIndex = new Map(byTrack.map((s, i) => [s.id, i] as const));
const placed = (x: number, art: string) => g(`translate(${x},${FIELD_Y}) scale(${SCALE})`, art);
const inscription = (x: number, text: string, anchor: "start" | "middle" | "end" = "start") =>
`<text class="inscr" x="${x}" y="${FIELD_Y + 34}" text-anchor="${anchor}">${esc(text.toUpperCase())}</text>`;
/** A painted saga column (palmettes framing the saga's name), as in 04. */
function sagaColumn(x: number, name: string): string {
return placed(x, `<path class="rule" d="M4,0 V${PANEL_H} M128,0 V${PANEL_H}"/>${palmette(66, 52)}${palmette(66, 248, true)}
<text class="saga-name" transform="translate(71,150) rotate(-90)" text-anchor="middle">${esc(name.toUpperCase())}</text>`);
}
const ladder = (x: number) => {
let d = "M5,0 V300 M21,0 V300 ";
for (let y = 8; y < 300; y += 12) d += `M5,${y} H21 `;
return placed(x, `<path class="rule" d="${d}"/>`);
};
/** Beat 1: the fall of Troy. */
export function troyFrieze(song: Song): string {
const id = "fz1";
const u = (n: number) => n * SCALE;
const stops: { song: string; x: number }[] = [];
let x = 22;
let figs = sagaColumn(x, "Troy Saga");
x += u(132) + 18;
figs += placed(x, PANELS["the-horse-and-the-infant"]!.art) + inscription(x + 18, "The Horse and the Infant");
stops.push({ song: "the-horse-and-the-infant", x: Math.round(x + u(185)) });
x += u(370);
figs += ladder(x);
x += u(26) + 6;
figs += placed(x, PANELS["just-a-man"]!.art) + inscription(x + 18, "Just a Man");
stops.push({ song: "just-a-man", x: Math.round(x + u(120)) });
x += u(240);
figs += ladder(x);
x += u(26) + 6;
figs += placed(x, waves(0, 330, 290) + ship(170, 276, 0.9, { sail: true })) + inscription(x + 18, "Full Speed Ahead");
stops.push({ song: "full-speed-ahead", x: Math.round(x + u(170)) });
const c = chrome(id, friezeThreads(song, stops));
return `<svg class="frieze frieze--troy" viewBox="0 ${VB_Y} ${VB_W} ${VB_FIT_H}" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
${defs(id)}
${c.top}${c.bottom}
<g class="f-figs">${figs}</g>
<path class="ground horizon-line" d="M-200 ${GROUND_Y}H${VB_W + 200}"/>
</svg>`;
}
/* ---------- Cyclops frieze: crew, cave mouth, Polyphemus, sheep ---------- */
function sheep(x: number, s: number, f: 1 | -1 = 1): string {
let b = "";
b += limb("M-14,-14 L-16,0", 4) + limb("M-4,-14 L-3,0", 4) + limb("M12,-14 L13,0", 4) + limb("M20,-14 L22,0", 4);
b += ink("M-24,-30 Q-26,-46 -10,-48 Q2,-58 14,-48 Q30,-50 28,-32 Q32,-16 16,-14 L-14,-14 Q-30,-14 -24,-30 Z");
b += inc("M-14,-38 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0 M-16,-26 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0", 1.3);
b += ink("M26,-40 L40,-44 Q46,-38 40,-30 L28,-30 Z");
b += `<circle class="clay" cx="38" cy="-39" r="1.6"/>`;
return g(`translate(${x},${GROUND}) scale(${f * s},${s})`, b);
}
/** The rock and the arch of the cave mouth; the mouth itself is cut out with a mask so the 3D cave shows through. */
const MOUTH_W = 120; // half-width of the arch, panel units
const MOUTH_H = 200; // height of the arch above the ground line
function mouthPath(cx: number): string {
return `M${cx - MOUTH_W},${GROUND} V${GROUND - 92} Q${cx - MOUTH_W},${GROUND - MOUTH_H} ${cx},${GROUND - MOUTH_H} Q${cx + MOUTH_W},${GROUND - MOUTH_H} ${cx + MOUTH_W},${GROUND - 92} V${GROUND} Z`;
}
function caveRock(cx: number): string {
const r = MOUTH_W + 46;
let b = ink(`M${cx - r - 40},${GROUND} Q${cx - r - 30},${GROUND - 170} ${cx - r + 20},${GROUND - 222} Q${cx - 60},${GROUND - 268} ${cx + 10},${GROUND - 262} Q${cx + r - 10},${GROUND - 270} ${cx + r + 18},${GROUND - 200} Q${cx + r + 46},${GROUND - 110} ${cx + r + 40},${GROUND} Z`);
b += `<path class="inc" d="${mouthPath(cx)}" stroke-width="5" transform="translate(${cx},${GROUND}) scale(1.06) translate(${-cx},${-GROUND})"/>`;
b += inc(`M${cx - r - 18},${GROUND - 120} q18,-30 46,-40 M${cx - r + 4},${GROUND - 190} q34,-24 70,-26 M${cx + r - 30},${GROUND - 214} q30,6 44,40 M${cx + r + 10},${GROUND - 80} q8,-30 4,-60 M${cx - 40},${GROUND - 246} q40,-8 84,2`, 2);
return b;
}
export function cyclopsFrieze(song: Song): string {
const id = "fz4";
const c = chrome(id, friezeThreads(song, [{ song: "polyphemus", x: 640 }, { song: "survive", x: 1275 }]));
const u = (n: number) => n * SCALE;
const panelX = (stageX: number) => (stageX - 0) / SCALE; // field art is placed at x=0
const cx = panelX(MOUTH.x);
let art = "";
// crew on the left: Odysseus offers the cup, two men behind him
art += person({ x: panelX(250), s: 0.82, arms: "spear", helmet: true, shield: true });
art += person({ x: panelX(326), s: 0.82, arms: "down", helmet: true });
art += person({ x: panelX(398), s: 0.94, arms: "reach", beard: true, cup: true });
art += caveRock(cx);
// Polyphemus at his door, then the flock
art += person({ x: panelX(1060), f: -1, s: 1.16, arms: "club", cyclops: true, beard: true });
art += sheep(panelX(1180), 0.95) + sheep(panelX(1275), 0.85, -1) + sheep(panelX(1370), 0.92);
let figs = sagaColumn(22, "Cyclops Saga");
figs += placed(0, art);
figs += inscription(u(132) + 60, "Polyphemus");
figs += inscription(VB_W - 34, "Survive", "end");
const mouthStage = `translate(0,${FIELD_Y}) scale(${SCALE})`;
return `<svg class="frieze frieze--cyclops" viewBox="0 ${VB_Y} ${VB_W} ${VB_FIT_H}" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
${defs(id)}
<mask id="${id}-mouth" maskUnits="userSpaceOnUse" x="-210" y="-610" width="${VB_W + 420}" height="${VB_H + 1220}">
<rect x="-210" y="-610" width="${VB_W + 420}" height="${VB_H + 1220}" fill="#fff"/>
<path d="${mouthPath(cx)}" transform="${mouthStage}" fill="#000"/>
</mask>
<radialGradient id="${id}-glow" cx="0.5" cy="0.92" r="0.75">
<stop offset="0" class="glow-a"/><stop offset="0.55" class="glow-b"/><stop offset="1" class="glow-c"/>
</radialGradient>
<g class="f-zoom">
<g mask="url(#${id}-mouth)">
${c.top}${c.bottom}
<g class="f-figs">${figs}</g>
<path class="ground" d="M-200 ${GROUND_Y}H${VB_W + 200}"/>
</g>
<path class="mouth-fill" d="${mouthPath(cx)}" transform="${mouthStage}" fill="url(#${id}-glow)"/>
</g>
</svg>`;
}
+119 -66
View File
@@ -5,7 +5,7 @@ import { hereAt } from "./chart";
import { cyclopsFrieze, GROUND_Y, MOUTH, troyFrieze, VB_FIT_H, VB_H, VB_W, VB_Y } from "./frieze";
import { createPanel, TABS, type Tab } from "./panel";
import { VoyageScene, ease, webglAvailable } from "./scene";
import { applyPick, bright, byTrack, COURSE_Y_C, hStrip, indexOf, routeOf, songMotifs, stripX } from "./threads";
import { applyPick, bright, byTrack, hStrip, indexOf, resetMeasure, songMotifs, stripX, stripY, type StripKind } from "./threads";
mountCredits();
@@ -25,8 +25,6 @@ const songById = (id: string) => songs.find((s) => s.id === id)!;
/** Each beat's song as an index on the voyage (track order), so strip markers can sail between them. */
const BEAT_IDX = BEATS.map((b) => indexOf(songById(b.songId)));
type Paths = "a" | "b" | "c";
const PATHS: Record<Paths, string> = { a: "In the panel", b: "In the world", c: "Timeline" };
/* ---------- params ---------- */
@@ -37,8 +35,12 @@ const startBeat = clampBeat(Number(params.get("beat") ?? 1));
const freezeT = params.has("t") ? Math.min(1, Math.max(0, Number(params.get("t")) || 0)) : null;
const startTab: Tab = TABS.some((t) => t.id === params.get("tab")) ? (params.get("tab") as Tab) : "about";
const startOpen = params.get("panel") !== "0";
let paths: Paths = (["a", "b", "c"] as const).find((m) => m === params.get("paths")) ?? "a";
/** Paths, combined: the bottom timeline (open by default, collapsible), the panel strip while it is
* collapsed, and the ribbons on the sea as an on/off switch. */
let tlOpen = params.get("timeline") !== "closed";
let ribbons = params.get("ribbons") === "1";
let pick: string | null = params.get("motif");
const playAt = params.has("t_play") ? Math.max(0, Number(params.get("t_play")) || 0) : null;
/* ---------- DOM ---------- */
@@ -55,23 +57,25 @@ app.innerHTML = `
<div class="wlabels" id="wlabels" aria-label="Motif threads in the world"></div>
<div class="cut" id="cut" aria-hidden="true"></div>
<header class="cap" id="cap" aria-live="polite">
<p class="lbl cap__eyebrow" id="cap-eyebrow"></p>
<p class="cap__eyebrow" id="cap-eyebrow"></p>
<h1 class="cap__title" id="cap-title"></h1>
<button type="button" class="back" id="back" hidden>Back to sea</button>
</header>
<nav class="hud" id="hud" aria-label="Beats and paths">
<nav class="hud" id="hud" aria-label="The voyage: beats and timeline">
<div class="hud__row">
<button type="button" class="hud__play" id="play" aria-pressed="false">Play</button>
<button type="button" class="hud__play" id="play" aria-pressed="false">Play voyage</button>
<button type="button" class="hud__step" id="prev" aria-label="Previous beat">←</button>
<ol class="dots" id="dots">
${BEATS.map((b, i) => `<li><button type="button" data-beat="${i + 1}"><span class="dots__n">${i + 1}</span><span class="lbl dots__label">${esc(b.short)}</span></button></li>`).join("")}
${BEATS.map((b, i) => `<li><button type="button" data-beat="${i + 1}"><span class="dots__n">${i + 1}</span><span class="dots__label">${esc(b.short)}</span></button></li>`).join("")}
</ol>
<button type="button" class="hud__step" id="next" aria-label="Next beat">→</button>
<div class="ptoggle" role="group" aria-label="Where the motif paths are shown">
<span class="lbl ptoggle__label">Paths</span>
${(["a", "b", "c"] as Paths[]).map((m) => `<button type="button" data-paths="${m}" title="${PATHS[m]}">${m.toUpperCase()}</button>`).join('<span class="ptoggle__sep" aria-hidden="true">·</span>')}
<span class="ptoggle__name" id="paths-name"></span>
</div>
<span class="hud__gap"></span>
<button type="button" class="switch" id="ribbons" role="switch" aria-checked="false">
<span class="switch__track" aria-hidden="true"><span class="switch__knob"></span></span>Threads on the sea
</button>
<button type="button" class="tlbtn" id="tlbtn" aria-controls="tl" aria-expanded="true">
<span class="tlbtn__text" id="tlbtn-text">Hide timeline</span><span class="tlbtn__chev" aria-hidden="true">⌄</span>
</button>
</div>
<div class="tl" id="tl"></div>
</nav>
@@ -117,7 +121,8 @@ const panel = createPanel($("panel"), {
open: startOpen,
reduced,
pick,
pinned: paths === "a",
pinned: !tlOpen,
playAt,
onTab: (t) => setParam("tab", t),
onOpen: (o) => { app.dataset.panel = o ? "open" : "closed"; setParam("panel", o ? null : "0"); },
onPick: (id) => setPick(id),
@@ -147,55 +152,82 @@ function setSong(next: Song) {
if (pick && !songMotifs(next).some((m) => m.id === pick)) pick = null;
panel.setSong(next);
scene?.setThreadSong(next);
renderLabels();
labelKey = "";
renderTimeline();
setPick(pick);
}
/* ---------- paths B: floating labels over the 3D ribbons ---------- */
function renderLabels() {
wlabels.innerHTML = songMotifs(song).map((m) => {
const r = routeOf(song, m.id);
return `<button type="button" class="wl" data-m="${m.id}" data-pick="${m.id}" aria-pressed="false" style="--c:${bright(m.id)}" title="${esc(m.name)}: ${esc(r.from)} · ${esc(r.next)}">
<span class="wl__name"><i aria-hidden="true"></i>${esc(m.name)}</span><span class="wl__note" data-note></span></button>`;
}).join("");
}
/* ---------- threads on the sea: floating labels over the 3D ribbons ---------- */
// Labels are grouped by the knot they point at (two motifs returning in the same song share one label),
// lifted well above it on a leader line so they never cover the cave mouth, and stacked so they never overlap.
let labelKey = "";
const LIFT = 96;
function placeLabels() {
if (!scene || paths !== "b") return;
if (!scene || !ribbons) return;
const vis = p < 2 ? clamp01((p - 0.6) / 0.25) * (1 - clamp01((p - 2) / 0.25)) : p < 3.5 ? 1 - clamp01((p - 2) / 0.25) : clamp01((p - 3.78) / 0.15);
wlabels.style.opacity = String(vis);
wlabels.style.visibility = vis > 0.02 ? "visible" : "hidden";
if (vis <= 0.02) return;
const pts = scene.labelPoints(p);
const pts = scene.labelPoints(p).filter((pt) => pt.visible);
const cur = indexOf(song);
const btns = [...wlabels.querySelectorAll<HTMLElement>(".wl")];
// stack labels that would overlap, nearest the knot first
const placed: { x: number; y: number }[] = [];
pts.map((pt, k) => ({ pt, k })).sort((a, b) => b.pt.y - a.pt.y).forEach(({ pt, k }) => {
const el = btns[k];
const groups = new Map<string, { song: number; ahead: boolean; ids: string[]; x: number; y: number }>();
for (const pt of pts) {
const k = `${pt.song}:${pt.ahead}`;
const g = groups.get(k);
if (g) { g.ids.push(pt.id); g.x = (g.x + pt.x) / 2; g.y = Math.max(g.y, pt.y); }
else groups.set(k, { song: pt.song, ahead: pt.ahead, ids: [pt.id], x: pt.x, y: pt.y });
}
const list = [...groups.values()].sort((a, b) => b.y - a.y);
const key = list.map((g) => `${g.song}${g.ahead}${g.ids.join()}`).join("|");
if (key !== labelKey) {
labelKey = key;
wlabels.innerHTML = `<svg class="wl-leaders" aria-hidden="true"></svg>` + list.map((g) => {
const s = byTrack[g.song]!;
const head = g.ahead ? `Next: ${s.track}. ${esc(s.title)}` : g.song === cur ? "Not heard again after this" : `Last heard: ${s.track}. ${esc(s.title)}`;
return `<div class="wl"><p class="wl__head">${head}</p><p class="wl__ms">${g.ids.map((id) => {
const m = songMotifs(song).find((x) => x.id === id)!;
return `<button type="button" class="wl__m" data-m="${id}" data-pick="${id}" aria-pressed="${pick === id}" style="--c:${bright(id)}"><i aria-hidden="true"></i>${esc(m.name)}</button>`;
}).join("")}</p></div>`;
}).join("");
applyPick(wlabels, pick);
}
const els = [...wlabels.querySelectorAll<HTMLElement>(".wl")];
const W = stage.clientWidth;
const top = capEl.offsetTop + capEl.offsetHeight + 10;
const placed: { l: number; r: number; t: number; b: number }[] = [];
let lines = "";
list.forEach((g, i) => {
const el = els[i];
if (!el) return;
let y = pt.y;
for (const q of placed) if (Math.abs(q.x - pt.x) < 200 && Math.abs(q.y - y) < 54) y = q.y - 56;
placed.push({ x: pt.x, y });
el.hidden = !pt.visible;
el.style.transform = `translate(${Math.round(pt.x)}px, ${Math.round(y)}px)`;
const s = byTrack[pt.song]!;
const note = el.querySelector("[data-note]")!;
const text = pt.ahead ? `next: ${s.track}. ${s.title}` : pt.song === cur ? "not heard again after this" : `last heard: ${s.track}. ${s.title}`;
if (note.textContent !== text) note.textContent = text;
const w = el.offsetWidth, h = el.offsetHeight;
let l = Math.min(W - w - 12, Math.max(12, g.x - w / 2));
let b = g.y - LIFT;
// stack above anything already placed that this would overlap
for (let guard = 0; guard < 6; guard++) {
const hit = placed.find((q) => l < q.r + 8 && l + w > q.l - 8 && b > q.t - 8 && b - h < q.b + 8);
if (!hit) break;
b = hit.t - 10;
}
b = Math.max(top + h, b);
placed.push({ l, r: l + w, t: b - h, b });
el.style.transform = `translate(${Math.round(l)}px, ${Math.round(b - h)}px)`;
const ax = Math.min(l + w - 14, Math.max(l + 14, g.x));
lines += `<path d="M${ax.toFixed(1)},${b.toFixed(1)} L${g.x.toFixed(1)},${(g.y - 6).toFixed(1)}"/><circle cx="${g.x.toFixed(1)}" cy="${g.y.toFixed(1)}" r="4"/>`;
});
const svg = wlabels.querySelector<SVGSVGElement>(".wl-leaders");
if (svg && svg.innerHTML !== lines) svg.innerHTML = lines;
}
/* ---------- paths C: the timeline at the bottom ---------- */
/* ---------- the timeline at the bottom: open (titles + threads) or slim (stops + you are here) ---------- */
const TL = { labelW: 168, rowH: 20, titles: true } as const;
const TL_LABEL_W = 156;
const tlOpts = (): { width: number; labelW: number; rowH: number; kind: StripKind } =>
({ width: tl.clientWidth, labelW: tlOpen ? TL_LABEL_W : 112, rowH: 16, kind: tlOpen ? "timeline" : "slim" });
function renderTimeline() {
if (paths !== "c") { tl.innerHTML = ""; return; }
const width = tl.clientWidth;
if (!width) return;
tl.innerHTML = hStrip({ ...TL, width, song, pick });
const o = tlOpts();
if (!o.width) return;
tl.innerHTML = hStrip({ ...o, song, pick });
}
/* ---------- state ---------- */
@@ -283,13 +315,11 @@ function renderDom() {
($("prev") as HTMLButtonElement).disabled = target <= 0;
($("next") as HTMLButtonElement).disabled = target >= 4;
// the ship markers on the strips (A in the panel, C at the bottom) sail with the voyage
// the ship markers on the strips (the timeline, and the panel's while it is pinned) sail with the voyage
const idx = shipIdx();
if (paths === "a") panel.setShip(idx);
if (paths === "c") {
const m = tl.querySelector<SVGGElement>("[data-ship]");
if (m) m.setAttribute("transform", `translate(${stripX({ ...TL, width: tl.clientWidth }, idx).toFixed(1)},${COURSE_Y_C})`);
}
if (!tlOpen) panel.setShip(idx);
const m = tl.querySelector<SVGGElement>("[data-ship]");
if (m) { const o = tlOpts(); m.setAttribute("transform", `translate(${stripX(o, idx).toFixed(1)},${stripY(o.kind)})`); }
placeLabels();
}
let panelReady = false;
@@ -318,7 +348,7 @@ function crossCut(to: number) {
function setPlaying(on: boolean) {
playing = on;
playBtn.textContent = on ? "Pause" : "Play";
playBtn.textContent = on ? "Pause voyage" : "Play voyage";
playBtn.setAttribute("aria-pressed", String(on));
if (on) {
if (Math.round(p) >= 4 && !anim) go(0);
@@ -326,18 +356,37 @@ function setPlaying(on: boolean) {
}
}
function setPaths(m: Paths) {
paths = m;
document.body.dataset.paths = m;
panel.setPinned(m === "a");
scene?.setPaths(m === "b");
wlabels.hidden = m !== "b";
document.querySelectorAll<HTMLButtonElement>(".ptoggle button").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.paths === m)));
$("paths-name").textContent = PATHS[m];
setParam("paths", m === "a" ? null : m);
/** Keep the frieze box and the 3D framing clear of the HUD, whatever its height. */
function frameAroundHud() {
const hud = $("hud");
const reserve = hud.offsetHeight + 16;
stage.style.setProperty("--fz-bot", `${reserve + 12}px`);
// the step bar of 13–15 (about 92px) was the framing the scenes were tuned for; lift the view by part of the rest
scene?.setFrameShift(Math.max(0, (reserve - 92) * 0.55));
measureFit();
}
function setTimeline(open: boolean) {
tlOpen = open;
document.body.dataset.timeline = open ? "open" : "closed";
panel.setPinned(!open);
$("tlbtn").setAttribute("aria-expanded", String(open));
$("tlbtn-text").textContent = open ? "Hide timeline" : "Show timeline";
setParam("timeline", open ? null : "closed");
renderTimeline();
applyPick(stage, pick);
frameAroundHud();
renderDom();
}
function setRibbons(on: boolean) {
ribbons = on;
document.body.dataset.ribbons = on ? "on" : "off";
scene?.setPaths(on);
wlabels.hidden = !on;
labelKey = "";
$("ribbons").setAttribute("aria-checked", String(on));
setParam("ribbons", on ? "1" : null);
renderDom();
}
@@ -347,8 +396,8 @@ $("next").addEventListener("click", () => { setPlaying(false); go((anim ? anim.t
backBtn.addEventListener("click", () => { setPlaying(false); go(1); });
document.querySelectorAll<HTMLButtonElement>("#dots button").forEach((b) =>
b.addEventListener("click", () => { setPlaying(false); go(Number(b.dataset.beat) - 1); }));
document.querySelectorAll<HTMLButtonElement>(".ptoggle button").forEach((b) =>
b.addEventListener("click", () => setPaths(b.dataset.paths as Paths)));
$("tlbtn").addEventListener("click", () => setTimeline(!tlOpen));
$("ribbons").addEventListener("click", () => setRibbons(!ribbons));
window.addEventListener("keydown", (e) => {
// Keys inside the panel (tabs, audio, links) belong to the panel.
if (e.target instanceof Element && e.target.closest(".panel")) return;
@@ -374,7 +423,11 @@ function frame(now: number) {
requestAnimationFrame(frame);
}
new ResizeObserver(() => { measureFit(); renderTimeline(); applyPick(stage, pick); }).observe(stage);
setPaths(paths);
new ResizeObserver(() => { renderTimeline(); applyPick(stage, pick); frameAroundHud(); }).observe(stage);
new ResizeObserver(() => frameAroundHud()).observe($("hud"));
setRibbons(ribbons);
setTimeline(tlOpen);
// timeline titles are laid out by measured width: measure again once Inter has loaded
document.fonts?.ready.then(() => { resetMeasure(); renderTimeline(); applyPick(stage, pick); });
requestAnimationFrame(frame);
if (params.get("play") === "1" && freezeT === null) setPlaying(true);
+386
View File
@@ -0,0 +1,386 @@
// The persistent right-side companion: About · Listen & lyrics · Paths · Fan works for the song of the
// current beat. A play button sits beside the song title in the header; it starts the song and opens
// Listen & lyrics, where a player (scrub bar with part markers) sits above the lyrics, by part.
// While the bottom timeline is collapsed, a compact thread strip is pinned between the header and the tabs.
// The picked motif is owned by the stage (main.ts); the panel reports clicks and reflects the pick.
import { motifsInSong, sagaOf } from "../../data/query";
import { motifs, songs } from "../../data/sample";
import type { Motif, Song } from "../../data/types";
import { createVideoCards } from "../../shared/video-cards";
import { chartSvg } from "./chart";
import { fanWorksFor, thumbSvg, TYPE_LABEL } from "./fanworks";
import { fmt, SongPlayer } from "./player";
import { applyPick, byTrack, hitsOf, hStrip, inked, routeOf, stripX, stripY } from "./threads";
export type Tab = "about" | "listen" | "paths" | "fan";
export const TABS: { id: Tab; label: string }[] = [
{ id: "about", label: "About" },
{ id: "listen", label: "Listen & lyrics" },
{ id: "paths", label: "Paths" },
{ id: "fan", label: "Fan works" },
];
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
const STRIP = { width: 372, labelW: 160, rowH: 24, kind: "panel" } as const;
const PLAY_ICON = `<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M6 3.5 L16.5 10 L6 16.5 Z"/></svg>`;
const PAUSE_ICON = `<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 3.5 H8.6 V16.5 H5 Z M11.4 3.5 H15 V16.5 H11.4 Z"/></svg>`;
export interface Panel {
setSong(song: Song): void;
setTab(tab: Tab): void;
setOpen(open: boolean): void;
setPick(id: string | null): void;
/** Show or hide the pinned strip (shown while the bottom timeline is collapsed). */
setPinned(on: boolean): void;
/** Move the pinned strip's ship marker to a fractional song index. */
setShip(idx: number): void;
/** The chart's "You are here" marker, so the stage can move it as the ship sails. */
here: SVGGElement;
}
export interface PanelOpts {
tab: Tab; open: boolean; reduced: boolean; pick: string | null; pinned: boolean;
/** Start the first song's player at this time (s), playing: for screenshots mid-playback. */
playAt: number | null;
onTab(tab: Tab): void; onOpen(open: boolean): void; onPick(id: string | null): void;
}
export function createPanel(host: HTMLElement, opts: PanelOpts): Panel {
let tab = opts.tab;
let song: Song = songs[0]!;
let pick = opts.pick;
let songPick: string | null = null;
let swapTimer = 0;
let shownPart = -1;
let playAt = opts.playAt;
host.innerHTML = `
<button type="button" class="panel__tab" id="panel-open" aria-controls="panel-body" aria-expanded="false">
<span class="panel__tab-arrow" aria-hidden="true">‹</span>
<span class="panel__tab-n" id="panel-tab-n"></span>
<span class="panel__tab-label">About · Listen &amp; lyrics · Paths · Fan works</span>
</button>
<div class="panel__body" id="panel-body">
<header class="panel__head">
<button type="button" class="pplay" id="panel-play" aria-pressed="false"></button>
<div class="panel__song" id="panel-song" aria-live="polite"></div>
<button type="button" class="panel__hide" id="panel-hide" aria-controls="panel-body" aria-expanded="true" title="Collapse the panel">Hide <span aria-hidden="true">›</span></button>
</header>
<section class="pin" id="pin" aria-label="Motif paths of this song"></section>
<div class="tabs" role="tablist" aria-label="About this song">
${TABS.map((t) => `<button type="button" role="tab" id="tab-${t.id}" aria-controls="panel-view" data-tab="${t.id}">${t.label.replace("&", "&amp;")}</button>`).join("")}
</div>
<div class="panel__view" id="panel-view" role="tabpanel" tabindex="0"></div>
</div>`;
const $ = (id: string) => host.querySelector<HTMLElement>(`#${id}`)!;
const view = $("panel-view");
const songEl = $("panel-song");
const pin = $("pin");
const headPlay = $("panel-play") as HTMLButtonElement;
const player = new SongPlayer(() => syncPlayer());
// The route chart is built once and moved into the Paths tab, so "You are here" keeps its place.
const chartFig = document.createElement("figure");
chartFig.className = "route";
chartFig.innerHTML = `${chartSvg()}<figcaption>The ship's course so far, on 12's general chart.</figcaption>`;
const here = chartFig.querySelector<SVGGElement>("#chart-here")!;
/* ----- head and pinned strip ----- */
function renderHead() {
const saga = sagaOf(song);
songEl.innerHTML = `<p class="panel__eyebrow" id="panel-eyebrow"></p>
<h2 class="panel__title">${esc(song.title)}</h2>
<p class="panel__sub">${esc(saga.name)} · ${esc(song.storyTime.label)}</p>`;
$("panel-tab-n").textContent = String(song.track);
}
let shipIdx = 0;
function renderPin() {
pin.innerHTML = `<p class="pin__head"><span class="pin__title">Motif paths</span><span class="pin__sub">heard before · here · returns</span></p>${hStrip({ ...STRIP, song, pick })}`;
api.setShip(shipIdx);
}
/* ----- tabs ----- */
const motifTag = (m: Motif) => `<button type="button" class="mtag" data-m="${m.id}" data-pick="${m.id}" aria-pressed="${pick === m.id}" style="--c:${inked(m.id)}"><i aria-hidden="true"></i>${esc(m.name)}</button>`;
function about(): HTMLElement {
const el = document.createElement("div");
el.className = "about";
const saga = sagaOf(song);
const o = song.odyssey;
const homer = o ? `<a href="${esc(o.url)}" target="_blank" rel="noopener noreferrer">Book ${o.book}, lines ${esc(o.lines.replace("-", "–"))}</a>` : "No counterpart in Homer";
el.innerHTML = `
<dl class="facts">
<div><dt>Track</dt><dd>${song.track} of ${songs.length} in this sample</dd></div>
<div><dt>Saga</dt><dd>${esc(saga.name)}</dd></div>
<div><dt>Story time</dt><dd>${song.storyTime.yearsAfterTroy === 0 ? "Year 0" : `Year ${song.storyTime.yearsAfterTroy}`} · ${esc(song.storyTime.label)}</dd></div>
<div><dt>The Odyssey</dt><dd>${homer}</dd></div>
</dl>
${song.facts.map((f) => `<p class="about__fact">${esc(f)}</p>`).join("")}
<h3 class="sec">Parts and singers</h3>
<ol class="parts">${song.parts.map((p) => `<li><span class="parts__name">${esc(p.name)}</span><span class="parts__who">${p.singers.map(esc).join(", ")}</span></li>`).join("")}</ol>
<button type="button" class="golisten" data-act="go-listen">${PLAY_ICON}<span>Listen with the lyrics</span></button>
<h3 class="sec">Watch</h3><div class="slot-video"></div>`;
el.querySelector(".slot-video")!.append(createVideoCards(song));
return el;
}
function paths(): HTMLElement {
const el = document.createElement("div");
el.className = "paths";
const ms = motifsInSong(song);
const sec = document.createElement("div");
sec.innerHTML = `<h3 class="sec">Motif threads</h3>
<p class="hint">Each thread is a motif; a knot is a song where it plays. Pick a motif to follow it everywhere.</p>
<ul class="mlist">${ms.map((m) => {
const r = routeOf(song, m.id);
return `<li><button type="button" class="mpick" data-m="${m.id}" data-pick="${m.id}" aria-pressed="${pick === m.id}" style="--c:${inked(m.id)}">
<span class="mpick__name"><i aria-hidden="true"></i>${esc(m.name)}</span><span class="mpick__path">${esc(r.from)} · ${esc(r.next)}</span></button></li>`;
}).join("")}</ul>`;
el.append(sec, strip(ms));
const route = document.createElement("h3");
route.className = "sec";
route.textContent = "The route";
el.append(route, chartFig);
return el;
}
/** The vertical thread strip: one row per song, one soft thread per motif of this song, knotted where it plays. */
function strip(ms: Motif[]): HTMLElement {
const ROW = 34, COL = 26, X0 = 16;
const wrap = document.createElement("div");
wrap.className = "vstrip";
wrap.style.setProperty("--cols", String(ms.length));
const rows = byTrack.map((s) => {
const cls = ["vstrip__row", s.id === song.id ? "is-current" : "", s.id === songPick ? "is-picked" : ""].join(" ");
return `<li><button type="button" class="${cls}" data-song="${s.id}" aria-pressed="${s.id === songPick}">
<span class="vstrip__n">${s.track}</span><span class="vstrip__t">${esc(s.title)}</span>${s.id === song.id ? '<span class="vstrip__now">now</span>' : ""}</button></li>`;
}).join("");
const H = byTrack.length * ROW;
const W = X0 * 2 + (ms.length - 1) * COL;
let svg = "";
ms.forEach((m, k) => {
const x = X0 + k * COL;
const ys = hitsOf(m.id).map((i) => i * ROW + ROW / 2);
const y0 = ys[0]!, y1 = ys[ys.length - 1]!;
const wx = (y: number) => (x + Math.sin(y / 22 + k * 1.7) * 2.4).toFixed(1);
let d = "";
for (let y = y0; y < y1; y += 6) d += `${d ? "L" : "M"}${wx(y)},${y}`;
d += `${d ? "L" : "M"}${wx(y1)},${y1}`;
svg += `<g class="thread" data-m="${m.id}" style="--c:${inked(m.id)}"><path class="thread__line" d="${d}"/>${ys.map((y) => `<circle class="thread__knot" cx="${wx(y)}" cy="${y}" r="5"/>`).join("")}</g>`;
});
wrap.innerHTML = `<ol class="vstrip__rows">${rows}</ol>
<svg class="vstrip__threads" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" aria-hidden="true">${svg}</svg>`;
wrap.querySelectorAll<HTMLButtonElement>(".vstrip__row").forEach((b) => b.addEventListener("click", () => {
songPick = songPick === b.dataset.song ? null : b.dataset.song!;
rerender(true);
}));
return wrap;
}
/** Listen & lyrics: the player on top, the lyrics by part below; parts are the player's chapters. */
function listen(): HTMLElement {
const el = document.createElement("div");
el.className = "listen";
const dur = player.dur;
const segs = song.parts.map((p, i) => {
const a = player.starts[i]!;
return `<button type="button" class="seg" data-seek="${i}" title="Jump to ${esc(p.name)}">
<span class="seg__name">${esc(p.name)}</span><span class="seg__time">${fmt(a)}</span></button>`;
}).join("");
const ticks = player.starts.slice(1).map((s) => `<span class="scrub__tick" style="left:${((s / dur) * 100).toFixed(2)}%"></span>`).join("");
el.innerHTML = `
<section class="player" aria-label="Player">
<div class="player__top">
<button type="button" class="player__play" data-act="toggle" aria-pressed="false"></button>
<div class="player__meta">
<p class="player__part" data-now-part></p>
<p class="player__time"><span data-elapsed>0:00</span> <span class="player__of">/ ${fmt(dur)}</span></p>
</div>
</div>
<div class="scrub" role="slider" tabindex="0" aria-label="Position in ${esc(song.title)}" aria-valuemin="0" aria-valuemax="${Math.round(dur)}">
<div class="scrub__track"><div class="scrub__fill" data-fill></div>${ticks}<div class="scrub__head" data-head></div></div>
</div>
<div class="segs" role="group" aria-label="Parts">${segs}</div>
${player.simulated ? `<p class="player__note">Preview player: no audio file yet. Playback is simulated.</p>` : ""}
</section>
<p class="note">Placeholder lyrics. The real lyrics are not reproduced on this fan site.</p>
${song.parts.map((p, i) => {
const ms = p.motifIds.flatMap((id) => motifs.find((m) => m.id === id) ?? []);
const c = ms[0] ? inked(ms[0].id) : "transparent";
return `<section class="part" data-part="${i}" data-ms="${p.motifIds.join(" ")}" style="--c:${c}">
<header class="part__head">
<button type="button" class="part__jump" data-seek="${i}" title="Play from ${esc(p.name)}">
<span class="part__icon" aria-hidden="true">${PLAY_ICON}</span><h3>${esc(p.name)}</h3><span class="part__at">${fmt(player.starts[i]!)}</span>
</button>
<span class="part__now">Now playing</span>
</header>
<p class="part__who">${p.singers.map((s) => `<span class="singer">${esc(s)}</span>`).join("")}</p>
<div class="part__lines">${p.lines.map((l) => `<p>${esc(l)}</p>`).join("")}</div>
${ms.length ? `<p class="part__motifs">${ms.map(motifTag).join("")}</p>` : ""}
</section>`;
}).join("")}`;
const scrub = el.querySelector<HTMLElement>(".scrub")!;
const seekAt = (clientX: number) => {
const r = scrub.getBoundingClientRect();
player.seek(((clientX - r.left) / r.width) * player.dur);
};
scrub.addEventListener("pointerdown", (e) => {
seekAt(e.clientX);
scrub.setPointerCapture(e.pointerId);
const move = (ev: PointerEvent) => seekAt(ev.clientX);
scrub.addEventListener("pointermove", move);
scrub.addEventListener("pointerup", () => scrub.removeEventListener("pointermove", move), { once: true });
});
scrub.addEventListener("keydown", (e) => {
const d = e.key === "ArrowRight" ? 5 : e.key === "ArrowLeft" ? -5 : 0;
if (d) { e.preventDefault(); player.seek(player.t + d); }
});
return el;
}
function fan(): HTMLElement {
const el = document.createElement("div");
el.className = "fan";
const accent = inked(motifsInSong(song)[0]?.id ?? "odysseus");
el.innerHTML = `<p class="note">Placeholder fan works — real ones will be credited and linked to their creators.</p>
<ul class="fw">${fanWorksFor(song.id).map((w, i) => `<li class="fw__card">
${thumbSvg(w.type, accent, song.track * 7 + i)}
<p class="fw__type">${TYPE_LABEL[w.type]}</p>
<p class="fw__title">${esc(w.title)}</p>
<p class="fw__by">by ${esc(w.handle)}</p>
<p class="fw__credit">Credit + link to the original <span>(placeholder)</span></p>
</li>`).join("")}</ul>`;
return el;
}
/* ----- the player, reflected in the header and the Listen & lyrics tab ----- */
function syncPlayer() {
const on = player.playing;
const active = on || player.t > 0;
const k = player.partAt();
const part = song.parts[k];
headPlay.innerHTML = `${on ? PAUSE_ICON : PLAY_ICON}<span class="sr">${on ? "Pause" : "Play"} ${esc(song.title)}</span>`;
headPlay.setAttribute("aria-pressed", String(on));
headPlay.title = `${on ? "Pause" : "Play"} ${song.title}`;
const eyebrow = host.querySelector<HTMLElement>("#panel-eyebrow");
if (eyebrow) {
eyebrow.textContent = active ? `${on ? "Playing" : "Paused"} · ${part?.name ?? ""} · ${fmt(player.t)} / ${fmt(player.dur)}` : `Track ${song.track} · now on the voyage`;
eyebrow.classList.toggle("is-live", on);
}
const lv = view.querySelector<HTMLElement>(".listen");
if (!lv) { shownPart = -1; return; }
const pct = player.dur ? (player.t / player.dur) * 100 : 0;
lv.querySelector<HTMLElement>("[data-fill]")!.style.width = `${pct}%`;
lv.querySelector<HTMLElement>("[data-head]")!.style.left = `${pct}%`;
lv.querySelector("[data-elapsed]")!.textContent = fmt(player.t);
lv.querySelector("[data-now-part]")!.innerHTML = part ? `${esc(part.name)} <span>· ${part.singers.map(esc).join(", ")}</span>` : "";
const pb = lv.querySelector<HTMLButtonElement>(".player__play")!;
pb.innerHTML = `${on ? PAUSE_ICON : PLAY_ICON}<span class="sr">${on ? "Pause" : "Play"}</span>`;
pb.setAttribute("aria-pressed", String(on));
const scrub = lv.querySelector<HTMLElement>(".scrub")!;
scrub.setAttribute("aria-valuenow", String(Math.round(player.t)));
scrub.setAttribute("aria-valuetext", `${fmt(player.t)} of ${fmt(player.dur)}, ${part?.name ?? ""}`);
lv.querySelectorAll<HTMLElement>(".seg").forEach((s, i) => s.classList.toggle("is-now", active && i === k));
lv.querySelectorAll<HTMLElement>(".part").forEach((s, i) => s.classList.toggle("is-now", active && i === k));
if (active && k !== shownPart) {
shownPart = k;
const el = lv.querySelector<HTMLElement>(`.part[data-part="${k}"]`);
if (el && k > 0) view.scrollTo({ top: Math.max(0, el.offsetTop - view.offsetTop - 12), behavior: opts.reduced ? "auto" : "smooth" });
else if (el) view.scrollTo({ top: 0, behavior: opts.reduced ? "auto" : "smooth" });
}
}
function reflectPick() {
applyPick(host, pick);
view.querySelectorAll<HTMLElement>(".part").forEach((el) => {
const has = !!pick && (el.dataset.ms ?? "").split(" ").includes(pick);
el.classList.toggle("is-on", has);
el.classList.toggle("is-dim", !!pick && !has);
});
}
function rerender(keepScroll = false) {
const top = view.scrollTop;
view.replaceChildren(tab === "about" ? about() : tab === "paths" ? paths() : tab === "listen" ? listen() : fan());
view.setAttribute("aria-labelledby", `tab-${tab}`);
host.querySelectorAll<HTMLButtonElement>("[role=tab]").forEach((b) => {
const on = b.dataset.tab === tab;
b.setAttribute("aria-selected", String(on));
b.tabIndex = on ? 0 : -1;
});
reflectPick();
view.scrollTop = keepScroll ? top : 0;
shownPart = -1;
syncPlayer();
}
/* ----- wiring ----- */
host.addEventListener("click", (e) => {
const t = e.target as Element;
const b = t.closest<HTMLElement>("[data-pick]");
if (b) { opts.onPick(pick === b.dataset.pick ? null : b.dataset.pick!); return; }
const seek = t.closest<HTMLElement>("[data-seek]");
if (seek) { player.seek(player.starts[Number(seek.dataset.seek)] ?? 0); player.play(); return; }
const act = t.closest<HTMLElement>("[data-act]")?.dataset.act;
if (act === "toggle") player.toggle();
if (act === "go-listen") { api.setTab("listen"); opts.onTab(tab); player.play(); }
});
headPlay.addEventListener("click", () => {
if (player.playing) { player.pause(); return; }
if (tab !== "listen") { api.setTab("listen"); opts.onTab(tab); }
player.play();
});
host.querySelectorAll<HTMLButtonElement>("[role=tab]").forEach((b, i, all) => {
b.addEventListener("click", () => { api.setTab(b.dataset.tab as Tab); opts.onTab(tab); });
b.addEventListener("keydown", (e) => {
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!d) return;
e.preventDefault();
e.stopPropagation();
const nb = all[(i + d + all.length) % all.length]!;
nb.focus();
nb.click();
});
});
$("panel-open").addEventListener("click", () => { api.setOpen(true); opts.onOpen(true); });
$("panel-hide").addEventListener("click", () => { api.setOpen(false); opts.onOpen(false); });
const api: Panel = {
here,
setSong(next) {
if (next.id === song.id && songEl.childElementCount) return;
const first = !songEl.childElementCount;
song = next;
songPick = null;
// a new song on the voyage loads its own player (stopped at 0:00)
player.load(next);
if (playAt !== null) { player.seek(playAt); player.play(); playAt = null; }
const swap = () => { renderHead(); renderPin(); rerender(); };
if (first || opts.reduced) { swap(); return; }
// a gentle swap: the panel's content dips and settles when the voyage reaches a new song
host.classList.add("is-swapping");
window.clearTimeout(swapTimer);
swapTimer = window.setTimeout(() => { swap(); host.classList.remove("is-swapping"); }, 180);
},
setTab(t) { tab = t; rerender(); },
setPick(id) { pick = id; reflectPick(); },
setPinned(on) { pin.hidden = !on; host.dataset.pinned = String(on); },
setShip(idx) {
shipIdx = idx;
const m = pin.querySelector<SVGGElement>("[data-ship]");
if (!m) return;
m.setAttribute("transform", `translate(${stripX(STRIP, idx).toFixed(1)},${stripY("panel")})`);
},
setOpen(open) {
host.dataset.open = String(open);
$("panel-open").setAttribute("aria-expanded", String(open));
$("panel-hide").setAttribute("aria-expanded", String(open));
($("panel-body") as HTMLElement).inert = !open;
$("panel-open").tabIndex = open ? -1 : 0;
},
};
api.setOpen(opts.open);
api.setPinned(opts.pinned);
return api;
}
+94
View File
@@ -0,0 +1,94 @@
// The Listen & lyrics player. A song with an audio file plays it through an <audio> element; the sample
// songs have none, so playback is simulated with a clock over a made-up duration. Part start times come
// from `part.startSec` where the data has it, and are otherwise spread across the song.
import type { Song } from "../../data/types";
/** A made-up but stable length per song (2:30–4:10), only for the preview player. */
const fakeDuration = (song: Song) => 150 + ((song.track * 47) % 100);
export const fmt = (sec: number) => {
const s = Math.max(0, Math.floor(sec));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
};
export class SongPlayer {
song: Song | null = null;
t = 0;
dur = 0;
playing = false;
/** Start time (s) of each part, in order. */
starts: number[] = [];
private audio: HTMLAudioElement | null = null;
private raf = 0;
private last = 0;
constructor(private onChange: () => void) {}
get simulated() { return !this.audio; }
load(song: Song) {
this.pause();
this.audio?.removeAttribute("src");
this.audio = null;
this.song = song;
this.t = 0;
this.dur = fakeDuration(song);
if (song.audio) {
const a = new Audio(song.audio);
a.preload = "metadata";
a.addEventListener("loadedmetadata", () => { if (Number.isFinite(a.duration)) { this.dur = a.duration; this.layout(); this.onChange(); } });
a.addEventListener("timeupdate", () => { this.t = a.currentTime; this.onChange(); });
a.addEventListener("ended", () => { this.playing = false; this.onChange(); });
this.audio = a;
}
this.layout();
this.onChange();
}
private layout() {
const parts = this.song?.parts ?? [];
const n = parts.length;
this.starts = parts.map((p, i) => Math.min(this.dur - 1, p.startSec ?? (i === 0 ? 0 : (i * this.dur) / n)));
}
/** Index of the part playing at time t. */
partAt(t = this.t): number {
let k = 0;
this.starts.forEach((s, i) => { if (t >= s) k = i; });
return k;
}
play() {
if (!this.song || this.playing) return;
if (this.t >= this.dur - 0.05) this.seek(0);
this.playing = true;
if (this.audio) this.audio.play().catch(() => { this.playing = false; this.onChange(); });
else { this.last = performance.now(); this.raf = requestAnimationFrame(this.tick); }
this.onChange();
}
pause() {
if (!this.playing) return;
this.playing = false;
this.audio?.pause();
cancelAnimationFrame(this.raf);
this.onChange();
}
toggle() { if (this.playing) this.pause(); else this.play(); }
seek(t: number) {
this.t = Math.min(this.dur, Math.max(0, t));
if (this.audio) this.audio.currentTime = this.t;
this.onChange();
}
private tick = (now: number) => {
if (!this.playing) return;
this.t += (now - this.last) / 1000;
this.last = now;
if (this.t >= this.dur) { this.t = this.dur; this.playing = false; }
this.onChange();
if (this.playing) this.raf = requestAnimationFrame(this.tick);
};
}
+190
View File
@@ -0,0 +1,190 @@
// Paths B in the 3D world: the current song's motifs as flat, ink-edged ribbons laid along the voyage's
// route. Every song has a fixed place on the route (its index along a curve through anchor points); a
// ribbon runs from the first to the last song where its motif plays, solid behind the ship and dashed
// ahead of it, with a knot (an open ring, as in 12) at each song where the motif plays.
import * as THREE from "three";
import type { Song } from "../../data/types";
import { bright, byTrack, hitsOf, songMotifs } from "./threads";
export interface RibbonOpts {
/** One anchor per song in track order: where that song sits on the route. */
anchors: THREE.Vector3[];
width: number;
/** Sideways distance between neighbouring ribbons. */
gap: number;
/** Height above the surface. */
lift: number;
surface: (x: number, z: number, t: number) => number;
knotR: number;
/** Samples per song along the curve. */
res: number;
/** Dash length, in song-index units (3 = three dashes per song). */
dash: number;
ink: string;
/** Distance from the camera under which ribbons fade out. */
near: number;
}
interface Ribbon {
id: string;
fill: THREE.Mesh; edge: THREE.Mesh;
fillMat: THREE.MeshBasicMaterial; edgeMat: THREE.MeshBasicMaterial;
xz: Float32Array; // base x,z per vertex (fill and edge share the layout)
knots: { g: THREE.Group; i: number; x: number; z: number }[];
}
const SONGS = byTrack.length;
export class RibbonSet {
readonly group = new THREE.Group();
private curve: THREE.CatmullRomCurve3;
private ribbons: Ribbon[] = [];
private split = { value: 0 };
private pick: string | null = null;
private time = 0;
constructor(private o: RibbonOpts) {
this.curve = new THREE.CatmullRomCurve3(o.anchors, false, "centripetal");
this.group.renderOrder = 2;
}
/** Point and sideways normal on the route at a (fractional) song index. */
private at(idx: number): { p: THREE.Vector3; n: THREE.Vector3 } {
const t = THREE.MathUtils.clamp(idx / (SONGS - 1), 0, 1);
const p = this.curve.getPoint(t);
const d = this.curve.getTangent(t);
const n = new THREE.Vector3(-d.z, 0, d.x).normalize();
return { p, n };
}
private material(color: string, layer: number): THREE.MeshBasicMaterial {
const m = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide, transparent: true, polygonOffset: true, polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2 });
m.onBeforeCompile = (sh) => {
sh.uniforms.uSplit = this.split;
sh.uniforms.uNear = { value: this.o.near };
sh.vertexShader = sh.vertexShader
.replace("#include <common>", "#include <common>\nattribute float aU;\nvarying float vU;\nvarying float vDepth;")
.replace("#include <begin_vertex>", "#include <begin_vertex>\nvU = aU;")
.replace("#include <project_vertex>", "#include <project_vertex>\nvDepth = -mvPosition.z;");
sh.fragmentShader = sh.fragmentShader
.replace("#include <common>", "#include <common>\nuniform float uSplit;\nuniform float uNear;\nvarying float vU;\nvarying float vDepth;")
.replace("#include <color_fragment>", "#include <color_fragment>\n// fade out right in front of the camera, where a flat ribbon would become a wide road\ndiffuseColor.a *= smoothstep(uNear * 0.45, uNear, vDepth);")
.replace("#include <clipping_planes_fragment>", `#include <clipping_planes_fragment>
// ahead of the ship the ribbon is dashed: where the motif is still to come
if (vU > uSplit + 0.12 && fract(vU * ${this.o.dash.toFixed(1)}) > 0.58) discard;`);
};
return m;
}
private strip(i0: number, i1: number, off: number, w: number): { geo: THREE.BufferGeometry; xz: Float32Array } {
const n = Math.max(2, Math.ceil((i1 - i0) * this.o.res) + 1);
const pos = new Float32Array(n * 2 * 3);
const u = new Float32Array(n * 2);
const xz = new Float32Array(n * 2 * 2);
const idx: number[] = [];
for (let k = 0; k < n; k++) {
const s = i0 + ((i1 - i0) * k) / (n - 1);
const { p, n: nr } = this.at(s);
const c = p.clone().addScaledVector(nr, off);
for (const side of [0, 1]) {
const v = c.clone().addScaledVector(nr, (side ? 1 : -1) * w / 2);
const j = k * 2 + side;
pos.set([v.x, 0, v.z], j * 3);
xz.set([v.x, v.z], j * 2);
u[j] = s;
}
if (k) { const a = (k - 1) * 2; idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); }
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
geo.setAttribute("aU", new THREE.BufferAttribute(u, 1));
geo.setIndex(idx);
return { geo, xz };
}
setSong(song: Song) {
for (const r of this.ribbons) {
r.fill.geometry.dispose(); r.edge.geometry.dispose(); r.fillMat.dispose(); r.edgeMat.dispose();
r.knots.forEach((k) => k.g.traverse((o) => { if (o instanceof THREE.Mesh) o.geometry.dispose(); }));
}
this.group.clear();
this.ribbons = [];
const ms = songMotifs(song);
ms.forEach((m, k) => {
const hits = hitsOf(m.id);
const off = (k - (ms.length - 1) / 2) * this.o.gap;
const i0 = hits[0]!, i1 = Math.max(hits[hits.length - 1]!, i0 + 0.01);
const fillMat = this.material(bright(m.id), 2);
const edgeMat = this.material(this.o.ink, 1);
const f = this.strip(i0, i1, off, this.o.width);
const e = this.strip(i0, i1, off, this.o.width * 1.75);
const fill = new THREE.Mesh(f.geo, fillMat);
const edge = new THREE.Mesh(e.geo, edgeMat);
fill.renderOrder = 3; edge.renderOrder = 2;
fill.frustumCulled = edge.frustumCulled = false;
this.group.add(edge, fill);
const knots = hits.map((i) => {
const { p, n } = this.at(i);
const c = p.addScaledVector(n, off);
const g = new THREE.Group();
const disc = (r: number, color: string, layer: number) => {
const mesh = new THREE.Mesh(new THREE.CircleGeometry(r, 20), new THREE.MeshBasicMaterial({ color, transparent: true, polygonOffset: true, polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2 }));
mesh.rotation.x = -Math.PI / 2;
mesh.renderOrder = 3 + layer;
g.add(mesh);
};
disc(this.o.knotR * 1.32, this.o.ink, 3);
disc(this.o.knotR, bright(m.id), 4);
disc(this.o.knotR * 0.46, "#f3ead6", 5);
g.position.set(c.x, 0, c.z);
this.group.add(g);
return { g, i, x: c.x, z: c.z };
});
this.ribbons.push({ id: m.id, fill, edge, fillMat, edgeMat, xz: f.xz, knots });
// the edge strip has its own layout; keep its xz on the mesh
edge.userData.xz = e.xz;
});
this.setPick(this.pick && ms.some((m) => m.id === this.pick) ? this.pick : null);
}
setPick(id: string | null) {
this.pick = id;
for (const r of this.ribbons) {
const op = id && r.id !== id ? 0.3 : 1;
r.fillMat.opacity = op;
r.edgeMat.opacity = op;
r.fill.scale.setScalar(1);
r.knots.forEach((k) => {
k.g.scale.setScalar(id === r.id ? 1.35 : 1);
k.g.traverse((o) => { if (o instanceof THREE.Mesh) (o.material as THREE.MeshBasicMaterial).opacity = op; });
});
}
}
/** Follow the waves, and move the solid/dashed split to the ship's place on the route. */
update(t: number, split: number) {
this.time = t;
this.split.value = split;
const lift = (x: number, z: number) => this.o.surface(x, z, t) + this.o.lift;
for (const r of this.ribbons) {
for (const [mesh, xz] of [[r.fill, r.xz], [r.edge, r.edge.userData.xz as Float32Array]] as const) {
const pos = mesh.geometry.getAttribute("position") as THREE.BufferAttribute;
for (let j = 0; j < pos.count; j++) pos.setY(j, lift(xz[j * 2]!, xz[j * 2 + 1]!));
pos.needsUpdate = true;
}
for (const k of r.knots) k.g.position.y = lift(k.x, k.z) + 0.04;
}
}
/**
* Where each ribbon's label goes: on its next knot ahead of the ship, or, if the motif is not heard
* again, on its last knot. Returned in world space, a little above the knot.
*/
labels(split: number): { id: string; pos: THREE.Vector3; song: number; ahead: boolean }[] {
return this.ribbons.map((r) => {
const ahead = r.knots.find((k) => k.i > split + 0.35);
const k = ahead ?? r.knots[r.knots.length - 1]!;
return { id: r.id, pos: new THREE.Vector3(k.x, this.o.surface(k.x, k.z, this.time) + this.o.lift + 1.2, k.z), song: k.i, ahead: !!ahead };
});
}
}
+732
View File
@@ -0,0 +1,732 @@
// The 3D half of the voyage (from 14, look 2 only): toon shading with a stepped gradient, ink outlines
// (inverted hulls), posterised sky bands, a flat graphic sun and a sea of drawn wave strokes. Choreography
// is a pure function of the beat position p (0..4). Paths B adds motif ribbons on the sea and cave floor.
import * as THREE from "three";
import { mergeVertices } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import type { Song } from "../../data/types";
import { RibbonSet } from "./ribbons";
export function webglAvailable(): boolean {
try {
const c = document.createElement("canvas");
return !!(c.getContext("webgl2") ?? c.getContext("webgl"));
} catch {
return false;
}
}
/* ---------- wave field (GLSL and JS agree) ---------- */
const WAVE_GLSL = /* glsl */ `
uniform float uTime;
float seaWave(vec2 p) {
return 0.55 * sin(p.x * 0.09 + uTime * 0.8)
+ 0.40 * sin(p.y * 0.12 - uTime * 0.6 + p.x * 0.03)
+ 0.18 * sin((p.x + p.y) * 0.31 + uTime * 1.7);
}`;
const seaWave = (x: number, z: number, t: number) =>
0.55 * Math.sin(x * 0.09 + t * 0.8) + 0.4 * Math.sin(z * 0.12 - t * 0.6 + x * 0.03) + 0.18 * Math.sin((x + z) * 0.31 + t * 1.7);
/* ---------- palettes: a short list of flat inks per look ---------- */
interface Palette {
sky: [string, string, string, string]; // horizon band → zenith
sun: string; sunRing: string;
deep: string; mid: string; crest: string; contour: string; fog: string; fogNear: number; fogFar: number;
ink: string; inkSoft: number; light: string; lightI: number; ambient: string; ambientI: number;
hull: string; band: string; wood: string; sail: string; sailStripe: string; eye: string;
sand: string; rock: string; top: string; mouth: string;
caveRock: string; caveFloor: string; caveInk: string; fire: string; fireCore: string; caveAmbient: number;
giant: string; giantInc: string; giantEye: string; wool: string; face: string;
}
// Two worlds: a poster-paint sea, teal and cream, inked in the frieze's black.
const PAL: Palette = {
sky: ["#f3dfb6", "#dcdcc4", "#b4cdc8", "#88b1c0"], sun: "#fff4dc", sunRing: "#f7e6c0",
deep: "#1d5a6e", mid: "#2b7b88", crest: "#9cd0c4", contour: "#f1efdc", fog: "#f0d7aa", fogNear: 90, fogFar: 360,
ink: "#1d140e", inkSoft: 0.4, light: "#fff6e6", lightI: 2.4, ambient: "#9fb8c2", ambientI: 1.2,
hull: "#2a211b", band: "#c46a33", wood: "#5b3c20", sail: "#f3ead6", sailStripe: "#1d5f86", eye: "#f3ead6",
sand: "#e6cf98", rock: "#b0804e", top: "#6f7d3e", mouth: "#1d140e",
caveRock: "#7a4a2c", caveFloor: "#4a2c1a", caveInk: "#1d140e", fire: "#f08a3a", fireCore: "#ffe08a", caveAmbient: 0.55,
giant: "#16100c", giantInc: "#c46a33", giantEye: "#f6ead0", wool: "#efe6d2", face: "#1d140e",
};
/* ---------- helpers ---------- */
function hash(x: number, y: number, z: number): number {
const s = Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453;
return s - Math.floor(s);
}
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
export const ease = (x: number) => { const t = clamp01(x); return t * t * (3 - 2 * t); };
const easeInOut = (x: number) => { const t = clamp01(x); return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; };
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const v3 = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z);
/** Three flat tones per surface: shadow, mid, lit. */
function stepMap(): THREE.DataTexture {
const steps = [70, 160, 255];
const data = new Uint8Array(steps.length * 4);
steps.forEach((v, i) => data.set([v, v, v, 255], i * 4));
const t = new THREE.DataTexture(data, steps.length, 1, THREE.RGBAFormat);
t.minFilter = t.magFilter = THREE.NearestFilter;
t.generateMipmaps = false;
t.needsUpdate = true;
return t;
}
const STEPS = stepMap();
const toon = (color: string, opts: THREE.MeshToonMaterialParameters = {}) =>
new THREE.MeshToonMaterial({ color, gradientMap: STEPS, ...opts });
/** Inverted hull: the same shape with smoothed normals, pushed out by t and drawn back-faces only. */
function hullOf(src: THREE.BufferGeometry, t: number): THREE.BufferGeometry {
let g = src.clone();
for (const k of Object.keys(g.attributes)) if (k !== "position") g.deleteAttribute(k);
g = mergeVertices(g, 1e-3);
g.computeVertexNormals();
const p = g.getAttribute("position") as THREE.BufferAttribute;
const n = g.getAttribute("normal") as THREE.BufferAttribute;
for (let i = 0; i < p.count; i++) p.setXYZ(i, p.getX(i) + n.getX(i) * t, p.getY(i) + n.getY(i) * t, p.getZ(i) + n.getZ(i) * t);
return g;
}
/** A flat ribbon along a 2D polyline (for incised lines on the giant). */
function ribbon(pts: [number, number][], w: number, z: number): THREE.BufferGeometry {
const pos: number[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const [ax, ay] = pts[i]!, [bx, by] = pts[i + 1]!;
const dx = bx - ax, dy = by - ay, l = Math.hypot(dx, dy) || 1;
const nx = (-dy / l) * w / 2, ny = (dx / l) * w / 2;
pos.push(ax + nx, ay + ny, z, ax - nx, ay - ny, z, bx + nx, by + ny, z, bx + nx, by + ny, z, ax - nx, ay - ny, z, bx - nx, by - ny, z);
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
return g;
}
const shape = (pts: [number, number][]) => {
const s = new THREE.Shape();
pts.forEach(([x, y], i) => (i ? s.lineTo(x, y) : s.moveTo(x, y)));
s.closePath();
return s;
};
interface IslandOpts { radius: number; height: number; seed: number }
/** A bold graphic island: few facets, three height bands (sand, rock, scrub) as vertex colours. */
function islandGeometry(o: IslandOpts, cols: { sand: THREE.Color; rock: THREE.Color; top: THREE.Color }): THREE.BufferGeometry {
let g: THREE.BufferGeometry = new THREE.ConeGeometry(o.radius, o.height, 9, 3, true);
g.translate(0, o.height / 2 - 3, 0);
const pos = g.getAttribute("position") as THREE.BufferAttribute;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
const kx = Math.round(x * 10), ky = Math.round(y * 10), kz = Math.round(z * 10) + o.seed;
const j = 1 - Math.min(1, (y + 3) / o.height) * 0.5;
pos.setXYZ(i, x + (hash(kx, ky, kz) - 0.5) * o.radius * 0.22 * j, y + (hash(ky, kz, kx) - 0.5) * o.height * 0.1, z + (hash(kz, kx, ky) - 0.5) * o.radius * 0.22 * j);
}
g = g.toNonIndexed();
paintIsland(g, o.height, cols);
g.computeVertexNormals();
return g;
}
function paintIsland(g: THREE.BufferGeometry, height: number, c: { sand: THREE.Color; rock: THREE.Color; top: THREE.Color }) {
const p = g.getAttribute("position") as THREE.BufferAttribute;
const colors = new Float32Array(p.count * 3);
for (let f = 0; f < p.count; f += 3) {
const y = (p.getY(f) + p.getY(f + 1) + p.getY(f + 2)) / 3;
const col = y < 1.2 ? c.sand : y > height * 0.45 - 3 ? c.top : c.rock;
for (let k = 0; k < 3; k++) col.toArray(colors, (f + k) * 3);
}
g.setAttribute("color", new THREE.BufferAttribute(colors, 3));
}
/* ---------- choreography ---------- */
const SHIP_A = v3(0, 0, 0); // beat 2: out at sea
const SHIP_B = v3(-4, 0, -150); // beat 3: anchored off the island
const ISLAND = v3(-34, 0, -190);
const ISLAND_SUNK = -48;
/** The ship's course from open sea to the anchorage, s in 0..1, with a gentle S. */
const shipAt = (s: number) =>
v3(lerp(SHIP_A.x, SHIP_B.x, s) + Math.sin(s * Math.PI) * 9, 0, lerp(SHIP_A.z, SHIP_B.z, s));
export class VoyageScene {
private renderer: THREE.WebGLRenderer;
private sea = new THREE.Scene();
private cave = new THREE.Scene();
private camera = new THREE.PerspectiveCamera(46, 16 / 9, 0.5, 1400);
private caveCam = new THREE.PerspectiveCamera(54, 16 / 9, 0.3, 400);
private waveU = { uTime: { value: 0 } };
private seaU = { uDeep: { value: new THREE.Color() }, uMid: { value: new THREE.Color() }, uCrest: { value: new THREE.Color() }, uContour: { value: new THREE.Color() } };
private skyU = {
uB0: { value: new THREE.Color() }, uB1: { value: new THREE.Color() }, uB2: { value: new THREE.Color() }, uB3: { value: new THREE.Color() },
uSun: { value: new THREE.Color() }, uRing: { value: new THREE.Color() },
uSunDir: { value: new THREE.Vector3(0.3, 0.17, -0.94).normalize() },
};
private fog = new THREE.Fog(0xffffff, 80, 330);
private ambient = new THREE.AmbientLight(0xffffff, 1);
private sunLight = new THREE.DirectionalLight(0xffffff, 2);
private inkMat = new THREE.MeshBasicMaterial({ color: "#1d140e", side: THREE.BackSide });
private edgeMat = new THREE.LineBasicMaterial({ color: "#1d140e", transparent: true, opacity: 0.4 });
private ship = new THREE.Group();
private island = new THREE.Group();
private islandMat = toon("#ffffff", { vertexColors: true });
private islandGeos: { geo: THREE.BufferGeometry; height: number }[] = [];
private mouthMat = new THREE.MeshBasicMaterial({ color: "#1d140e", side: THREE.DoubleSide });
private shipMats = { hull: toon("#2a211b"), band: toon("#c46a33"), wood: new THREE.MeshBasicMaterial({ color: "#5b3c20" }), sail: toon("#ffffff", { side: THREE.DoubleSide }), eye: new THREE.MeshBasicMaterial({ color: "#f3ead6" }), pupil: new THREE.MeshBasicMaterial({ color: "#1d140e" }) };
private sailCanvas = document.createElement("canvas");
private sailTex: THREE.CanvasTexture;
// cave
private caveMats = {
rock: toon("#7a4a2c", { side: THREE.BackSide }), floor: toon("#4a2c1a"), boulder: toon("#4a2c1a"),
ink: new THREE.MeshBasicMaterial({ color: "#1d140e", side: THREE.BackSide }), edge: new THREE.LineBasicMaterial({ color: "#1d140e", transparent: true, opacity: 0.55 }),
giant: new THREE.MeshBasicMaterial({ color: "#16100c" }), giantInc: new THREE.MeshBasicMaterial({ color: "#c46a33", side: THREE.DoubleSide }), giantHull: new THREE.MeshBasicMaterial({ color: "#c46a33", side: THREE.BackSide }),
eye: new THREE.MeshBasicMaterial({ color: "#f6ead0" }),
wool: toon("#efe6d2"), face: new THREE.MeshBasicMaterial({ color: "#1d140e" }),
fire: new THREE.MeshBasicMaterial({ color: "#f08a3a", side: THREE.DoubleSide }), core: new THREE.MeshBasicMaterial({ color: "#ffe08a", side: THREE.DoubleSide }),
};
private fireLight = new THREE.PointLight(0xff8a3a, 900, 0, 1.7);
private caveAmbient = new THREE.AmbientLight(0xffffff, 0.5);
private flames: THREE.Mesh[] = [];
private eye!: THREE.Mesh;
private ro: ResizeObserver;
private seaRibbons: RibbonSet;
private caveRibbons: RibbonSet;
private split = 2;
private tmp = new THREE.Vector3();
/** Fraction down the stage where the frieze's ground line sits; at p=0 the horizon sits exactly on it. */
private horizonAt = 0.62;
/** How far (px) the picture is lifted to clear the bottom timeline. */
private shift = 0;
constructor(private host: HTMLElement, private reduced: boolean) {
this.renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance", preserveDrawingBuffer: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75));
this.renderer.domElement.className = "stage__canvas";
this.renderer.domElement.setAttribute("aria-hidden", "true");
host.append(this.renderer.domElement);
this.camera.rotation.order = "YXZ";
this.sea.fog = this.fog;
this.sea.add(this.ambient, this.sunLight);
this.sunLight.position.set(-60, 90, 40);
this.buildSky();
this.buildSea();
this.buildIsland();
this.sailCanvas.width = this.sailCanvas.height = 64;
this.sailTex = new THREE.CanvasTexture(this.sailCanvas);
this.sailTex.colorSpace = THREE.SRGBColorSpace;
this.sailTex.magFilter = THREE.NearestFilter;
this.shipMats.sail.map = this.sailTex;
this.buildShip();
this.buildCave();
// Paths B: each song has a place on the route. Sea: two songs behind the ship's start, Full Speed
// Ahead where it starts, Polyphemus at the anchorage, Survive at the cave mouth, the rest beyond.
const mouth = v3(SHIP_B.x + 14, 0, SHIP_B.z + 52).sub(ISLAND).setY(0).normalize().multiplyScalar(22).add(ISLAND);
this.seaRibbons = new RibbonSet({
anchors: [v3(0, 0, 112), v3(0, 0, 56), SHIP_A.clone(), shipAt(1 / 3), shipAt(2 / 3), SHIP_B.clone(), mouth, v3(12, 0, -204), v3(34, 0, -250), v3(52, 0, -300)],
width: 0.5, gap: 2.6, lift: 0.55, surface: seaWave, knotR: 1.0, res: 40, dash: 3, ink: PAL.ink, near: 34,
});
this.sea.add(this.seaRibbons.group);
// Cave: Survive at the camera's feet, the songs after it running deeper in between the fire and the giant.
this.caveRibbons = new RibbonSet({
anchors: Array.from({ length: 10 }, (_, i) => v3(3.5 - (i - 6) * 0.9, 0, 4 - (i - 6) * 12)),
width: 0.55, gap: 2.3, lift: 0.06, surface: () => -9, knotR: 0.85, res: 30, dash: 3, ink: PAL.ink, near: 12,
});
this.cave.add(this.caveRibbons.group);
this.setPaths(false);
this.applyPalette();
this.ro = new ResizeObserver(() => this.resize());
this.ro.observe(host);
this.resize();
}
private outline<T extends THREE.Mesh>(m: T, t: number, mat: THREE.Material = this.inkMat): T {
const h = new THREE.Mesh(hullOf(m.geometry, t), mat);
m.add(h);
return m;
}
/* ----- build: sea ----- */
private buildSky() {
const mat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, fog: false, uniforms: this.skyU,
vertexShader: /* glsl */ `varying vec3 vDir; void main(){ vDir = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: /* glsl */ `
uniform vec3 uB0; uniform vec3 uB1; uniform vec3 uB2; uniform vec3 uB3; uniform vec3 uSun; uniform vec3 uRing; uniform vec3 uSunDir; varying vec3 vDir;
void main(){
vec3 d = normalize(vDir);
float h = d.y + 0.012 * sin(atan(d.x, d.z) * 9.0);
vec3 col = uB0;
col = mix(col, uB1, step(0.035, h));
col = mix(col, uB2, step(0.09, h));
col = mix(col, uB3, step(0.18, h));
// a flat paper sun with one ring: no glare
float s = dot(d, uSunDir);
col = mix(col, uRing, step(0.99863, s) * (1.0 - step(0.99897, s)));
col = mix(col, uSun, step(0.99939, s));
gl_FragColor = vec4(col, 1.0);
#include <colorspace_fragment>
}`,
});
const sky = new THREE.Mesh(new THREE.SphereGeometry(1000, 48, 24), mat);
sky.renderOrder = -1;
this.sea.add(sky);
}
private buildSea() {
const g = new THREE.PlaneGeometry(1300, 1300, 170, 170);
const mat = new THREE.MeshBasicMaterial({ color: 0xffffff });
mat.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, this.waveU, this.seaU);
shader.vertexShader = shader.vertexShader
.replace("#include <common>", `#include <common>\n${WAVE_GLSL}\nvarying float vH;\nvarying vec2 vWp;`)
.replace("#include <begin_vertex>", `#include <begin_vertex>
vec4 seaWp = modelMatrix * vec4(transformed, 1.0);
float seaH = seaWave(seaWp.xz);
transformed.z += seaH;
vH = seaH;
vWp = seaWp.xz;`);
shader.fragmentShader = shader.fragmentShader
.replace("#include <common>", `#include <common>\nuniform float uTime;\nuniform vec3 uDeep;\nuniform vec3 uMid;\nuniform vec3 uCrest;\nuniform vec3 uContour;\nvarying float vH;\nvarying vec2 vWp;`)
.replace("#include <color_fragment>", `#include <color_fragment>
// Two flat bands by swell, then thin drawn wave strokes on the upper swells (no filled crest
// patches: in 14 those read like lily pads). Strokes thin out with distance to avoid moire.
float h = vH + 0.2 * sin(vWp.x * 0.21 + vWp.y * 0.13 + uTime * 0.4);
vec3 c = mix(uDeep, uMid, step(-0.3, h));
float s = sin(vWp.y * 0.8 + 1.7 * sin(vWp.x * 0.085 + uTime * 0.22) - uTime * 1.1);
float fs = fwidth(s);
float stroke = smoothstep(0.935 - fs, 0.935 + fs, s);
float gate = smoothstep(0.3, 0.6, h) * smoothstep(0.1, 0.45, sin(vWp.x * 0.16 + vWp.y * 0.05 + 1.3));
float fade = 1.0 - smoothstep(0.3, 0.8, fs);
float halo = smoothstep(0.75 - fs, 0.75 + fs, s) * gate * fade;
c = mix(c, uCrest, halo * 0.3);
diffuseColor.rgb = mix(c, uContour, stroke * gate * fade * 0.95);`);
};
const sea = new THREE.Mesh(g, mat);
sea.rotation.x = -Math.PI / 2;
sea.position.set(0, 0, -120);
this.sea.add(sea);
}
private buildIsland() {
const white = new THREE.Color("#ffffff");
const cols = { sand: white, rock: white, top: white };
const add = (o: IslandOpts, x: number, z: number, parent: THREE.Object3D, ink: number) => {
const geo = islandGeometry(o, cols);
this.islandGeos.push({ geo, height: o.height });
const m = this.outline(new THREE.Mesh(geo, this.islandMat), ink);
m.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo, 32), this.edgeMat));
m.position.set(x, 0, z);
parent.add(m);
return m;
};
add({ radius: 26, height: 34, seed: 23 }, 0, 0, this.island, 0.45);
add({ radius: 17, height: 22, seed: 41 }, -20, -14, this.island, 0.4);
add({ radius: 11, height: 13, seed: 57 }, 21, -6, this.island, 0.35);
// The cave mouth: a black arch facing the anchorage.
const toShip = v3(SHIP_B.x + 14, 0, SHIP_B.z + 52).sub(ISLAND).setY(0).normalize();
const arch = new THREE.Shape();
arch.moveTo(-6, -1); arch.lineTo(-6, 4); arch.absarc(0, 4, 6, Math.PI, 0, true); arch.lineTo(6, -1); arch.closePath();
const mouth = new THREE.Mesh(new THREE.ShapeGeometry(arch, 8), this.mouthMat);
mouth.position.copy(toShip.clone().multiplyScalar(22)).setY(0.6);
mouth.lookAt(mouth.position.clone().add(toShip));
this.island.add(mouth);
this.island.position.copy(ISLAND);
this.sea.add(this.island);
// a few far islets, so the sea is a place
const spots: [number, number, number][] = [[95, -280, 9], [-150, -330, 12], [80, -95, 5], [-110, -70, 6], [170, -170, 7]];
spots.forEach(([x, z, r], i) => add({ radius: r, height: 4 + r * 0.9, seed: 100 + i }, x, z, this.sea, 0.3));
}
private buildShip() {
const s = this.ship;
const outline = new THREE.Shape();
outline.moveTo(0, 4.8);
outline.quadraticCurveTo(1.8, 1.6, 1.6, -1.6);
outline.quadraticCurveTo(1.35, -3.4, 0, -3.9);
outline.quadraticCurveTo(-1.35, -3.4, -1.6, -1.6);
outline.quadraticCurveTo(-1.8, 1.6, 0, 4.8);
const hullGeo = new THREE.ExtrudeGeometry(outline, { depth: 1.6, bevelEnabled: false, curveSegments: 6 });
hullGeo.rotateX(Math.PI / 2);
hullGeo.translate(0, 1.2, 0);
const hp = hullGeo.getAttribute("position") as THREE.BufferAttribute;
for (let i = 0; i < hp.count; i++) if (hp.getY(i) < 0.5) hp.setX(i, hp.getX(i) * 0.35);
hullGeo.computeVertexNormals();
s.add(this.outline(new THREE.Mesh(hullGeo, this.shipMats.hull), 0.12));
// a painted band along the gunwale
const band = new THREE.Mesh(new THREE.BoxGeometry(3.45, 0.32, 6.4), this.shipMats.band);
band.position.set(0, 1.0, 0.35);
s.add(this.outline(band, 0.07));
// tall curled stern post and a ram at the bow: silhouette first
const stern = new THREE.Mesh(new THREE.TorusGeometry(0.75, 0.17, 5, 10, Math.PI * 1.1), this.shipMats.hull);
stern.rotation.y = Math.PI / 2;
stern.position.set(0, 2.15, -3.9);
s.add(this.outline(stern, 0.08));
const ram = new THREE.Mesh(new THREE.ConeGeometry(0.35, 1.8, 4), this.shipMats.hull);
ram.rotation.x = Math.PI / 2;
ram.position.set(0, 0.35, 5.2);
s.add(this.outline(ram, 0.06));
// the painted eye on each side of the bow
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.CircleGeometry(0.38, 12), this.shipMats.eye);
eye.position.set(side * 1.25, 1.45, 3.1);
eye.rotation.y = side * (Math.PI / 2 - 0.5);
const pupil = new THREE.Mesh(new THREE.CircleGeometry(0.17, 10), this.shipMats.pupil);
pupil.position.z = 0.02;
eye.add(pupil);
s.add(eye);
for (let i = 0; i < 5; i++) {
const oar = new THREE.Mesh(new THREE.BoxGeometry(3.6, 0.1, 0.2), this.shipMats.wood);
oar.position.set(side * 2.7, 0.6, -1.8 + i * 1.05);
oar.rotation.z = side * -0.38;
s.add(oar);
}
}
const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.16, 8.4, 5), this.shipMats.wood);
mast.position.y = 5;
const yard = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 6.8, 5), this.shipMats.wood);
yard.rotation.z = Math.PI / 2;
yard.position.y = 8.2;
s.add(mast, yard);
const sailGeo = new THREE.PlaneGeometry(6.2, 4.6, 6, 4);
const sp = sailGeo.getAttribute("position") as THREE.BufferAttribute;
for (let i = 0; i < sp.count; i++) {
const x = sp.getX(i) / 3.1, y = sp.getY(i) / 2.3;
sp.setZ(i, (1 - x * x) * (1 - 0.4 * y * y) * 1.0);
}
sailGeo.computeVertexNormals();
const sail = new THREE.Mesh(sailGeo, this.shipMats.sail);
sail.position.set(0, 5.8, 0.2);
const sailEdge = new THREE.LineSegments(new THREE.EdgesGeometry(sailGeo, 30), new THREE.LineBasicMaterial({ color: "#1d140e" }));
sail.add(sailEdge);
sail.userData.edge = sailEdge;
s.add(sail);
this.sea.add(s);
}
/* ----- build: cave ----- */
private buildCave() {
const c = this.cave;
// A faceted dome, seen from inside, with its facets inked.
let dome: THREE.BufferGeometry = new THREE.IcosahedronGeometry(1, 2);
const dp = dome.getAttribute("position") as THREE.BufferAttribute;
for (let i = 0; i < dp.count; i++) {
const x = dp.getX(i), y = dp.getY(i), z = dp.getZ(i);
const n = 1 + (hash(Math.round(x * 40), Math.round(y * 40), Math.round(z * 40)) - 0.5) * 0.24;
dp.setXYZ(i, x * n, y * n, z * n);
}
dome = dome.toNonIndexed();
dome.computeVertexNormals();
const shell = new THREE.Mesh(dome, this.caveMats.rock);
shell.add(new THREE.LineSegments(new THREE.EdgesGeometry(dome, 1), this.caveMats.edge));
shell.scale.set(44, 30, 56);
shell.position.set(0, 4, -16);
c.add(shell);
const floor = new THREE.Mesh(new THREE.CircleGeometry(70, 24), this.caveMats.floor);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -9;
c.add(floor);
for (let i = 0; i < 8; i++) {
const r = 1.6 + hash(i, 2, 9) * 3.2;
const b = new THREE.Mesh(new THREE.DodecahedronGeometry(r, 0), this.caveMats.boulder);
const side = i % 2 ? 1 : -1;
b.position.set(side * (16 + hash(i, 4, 1) * 16), -9 + r * 0.45, -hash(1, i, 4) * 44 + 6);
c.add(this.outline(b, 0.18, this.caveMats.ink));
}
// The fire: flat, layered flame shapes, as painted.
const flame = (w: number, h: number) => {
const s = new THREE.Shape();
s.moveTo(-w, 0);
s.quadraticCurveTo(-w * 1.1, h * 0.45, 0, h);
s.quadraticCurveTo(w * 1.1, h * 0.45, w, 0);
s.closePath();
return new THREE.ShapeGeometry(s, 6);
};
const fire = new THREE.Group();
fire.position.set(-12, -9, -30);
[[-2.2, 1.8, 5.5, 0], [0, 2.6, 8.5, 1], [2.3, 1.7, 6, 2], [0, 1.4, 4.6, 3], [-1, 0.9, 3, 4], [1.2, 0.8, 2.8, 5]].forEach(([x, w, h, i]) => {
const core = i! >= 3;
const f = new THREE.Mesh(flame(w!, h!), core ? this.caveMats.core : this.caveMats.fire);
f.position.set(x!, 0, core ? 0.3 : 0);
f.userData = { phase: i! * 1.7 };
fire.add(f);
this.flames.push(f);
});
const logs = new THREE.Mesh(new THREE.BoxGeometry(6, 0.8, 1.2), this.caveMats.face);
logs.position.y = 0.2;
fire.add(logs);
c.add(fire);
this.fireLight.position.set(-12, -2, -26);
c.add(this.fireLight, this.caveAmbient);
this.buildGiant();
this.buildFlock();
}
/** Polyphemus as a black-figure silhouette: one profile cut-out, incised in clay, with one great eye. */
private buildGiant() {
const gm = this.caveMats.giant;
const giant = new THREE.Group();
const D = 2.4; // depth of the cut-out
const part = (pts: [number, number][]) => {
const geo = new THREE.ExtrudeGeometry(shape(pts), { depth: D, bevelEnabled: false });
geo.translate(0, 0, -D / 2);
giant.add(this.outline(new THREE.Mesh(geo, gm), 0.15, this.caveMats.giantHull));
};
// Facing left (towards his flock and the cave mouth), striding, club raised behind.
part([[-9.5, 0], [-5.2, 0], [-4.4, 1.2], [-1.6, 12.5], [-4.6, 13], [-7.4, 1.6], [-9.6, 1.2]]); // front leg
part([[3.4, 0], [8.2, 0], [8.2, 1.3], [6.0, 1.6], [4.0, 12.8], [0.8, 12.4], [3.6, 1.4]]); // back leg
part([[-5.6, 11.6], [5.2, 11.4], [4.2, 15.2], [3.4, 25.6], [1.6, 27.4], [-3.4, 27.4], [-4.8, 25.6], [-4.6, 15.2]]); // body and chiton
part([[-4.2, 25.8], [-10.4, 19.4], [-11.6, 17.2], [-10.4, 16.4], [-8.4, 18.4], [-2.4, 22.6]]); // front arm, reaching
part([[1.6, 27.0], [6.0, 31.4], [7.6, 34.6], [6.4, 35.6], [4.4, 33.0], [0.4, 24.4]]); // back arm, raised
part([[5.0, 33.6], [6.6, 32.8], [14.6, 47.4], [14.6, 49.8], [12.6, 50.6], [10.6, 48.6]]); // the club
// head with brow and a pointed beard
// head in profile: brow, a heavy nose, a pointed beard
const head = new THREE.Shape();
head.moveTo(-0.8, 35.4);
head.quadraticCurveTo(-4.4, 35.4, -5.0, 32.8);
head.lineTo(-6.8, 30.6);
head.lineTo(-5.2, 30.2);
head.lineTo(-5.3, 28.8);
head.lineTo(-6.0, 24.6);
head.lineTo(-2.0, 26.6);
head.lineTo(1.0, 27.2);
head.quadraticCurveTo(3.2, 29.0, 2.8, 32.0);
head.quadraticCurveTo(2.4, 35.4, -0.8, 35.4);
const hg = new THREE.ExtrudeGeometry(head, { depth: D, bevelEnabled: false, curveSegments: 8 });
hg.translate(0, 0, -D / 2);
giant.add(this.outline(new THREE.Mesh(hg, gm), 0.15, this.caveMats.giantHull));
// incisions: belt, hem, knee, muscle and beard lines
const z = D / 2 + 0.03;
const inc = (pts: [number, number][], w = 0.26) => giant.add(new THREE.Mesh(ribbon(pts, w, z), this.caveMats.giantInc));
inc([[-4.7, 16.2], [4.2, 16.0]]);
inc([[-5.4, 12.6], [-4.2, 13.6], [-3.0, 12.6], [-1.8, 13.6], [-0.6, 12.6], [0.6, 13.6], [1.8, 12.6], [3.0, 13.6], [4.4, 12.6]], 0.2);
inc([[-4.6, 6.6], [-3.2, 7.2]]);
inc([[3.0, 7.2], [4.6, 6.8]]);
inc([[-3.8, 24.0], [-0.6, 21.2], [2.4, 23.6]], 0.2);
inc([[-4.4, 28.6], [-5.0, 25.8]], 0.18);
inc([[-3.0, 28.6], [-3.4, 26.6]], 0.18);
inc([[-0.6, 28.4], [0.4, 31.6], [-0.2, 34.2]], 0.2); // ear and hairline
inc([[-1.0, 33.8], [-2.8, 34.2], [-4.6, 33.2]], 0.3); // brow
// the single eye: a big painted ring with a dark pupil, catching the firelight
const eye = new THREE.Mesh(new THREE.CircleGeometry(1.15, 20), this.caveMats.eye);
eye.position.set(-2.7, 31.9, z + 0.01);
const pupil = new THREE.Mesh(new THREE.CircleGeometry(0.55, 14), this.caveMats.face);
pupil.position.set(-0.4, 0, 0.02);
eye.add(pupil);
giant.add(eye);
this.eye = eye;
giant.position.set(7, -9, -34);
giant.scale.setScalar(0.72);
giant.rotation.y = -0.18;
this.cave.add(giant);
}
/** The flock: round wool puffs with black heads and legs. */
private buildFlock() {
for (let i = 0; i < 6; i++) {
const sheep = new THREE.Group();
const body = new THREE.Mesh(new THREE.IcosahedronGeometry(1.35, 1), this.caveMats.wool);
body.scale.set(1.4, 1, 1);
sheep.add(this.outline(body, 0.06, this.caveMats.ink));
for (const [px, py] of [[-0.8, 0.8], [0.3, 1.0], [-0.2, -0.1]] as const) {
const puff = new THREE.Mesh(new THREE.IcosahedronGeometry(0.8, 1), this.caveMats.wool);
puff.position.set(px, py, 0);
sheep.add(this.outline(puff, 0.05, this.caveMats.ink));
}
const head = new THREE.Mesh(new THREE.ConeGeometry(0.55, 1.4, 6), this.caveMats.face);
head.rotation.z = Math.PI / 2 + 0.4;
head.position.set(2.0, 0.35, 0);
sheep.add(head);
for (const [lx, lz] of [[-0.8, -0.45], [-0.8, 0.45], [0.8, -0.45], [0.8, 0.45]] as const) {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.24, 1.1, 0.24), this.caveMats.face);
leg.position.set(lx, -1.15, lz);
sheep.add(leg);
}
sheep.position.set(-24 + i * 3.8 + hash(i, 3, 3) * 2, -7.4, -1 - hash(3, i, 3) * 9);
sheep.rotation.y = (hash(i, 9, 2) - 0.5) * 1.2 + (i % 2 ? Math.PI : 0);
this.cave.add(sheep);
}
}
/* ----- palette ----- */
private applyPalette() {
const p = PAL;
p.sky.forEach((h, i) => (this.skyU[`uB${i}` as "uB0"].value as THREE.Color).set(h));
this.skyU.uSun.value.set(p.sun);
this.skyU.uRing.value.set(p.sunRing);
this.seaU.uDeep.value.set(p.deep);
this.seaU.uMid.value.set(p.mid);
this.seaU.uCrest.value.set(p.crest);
this.seaU.uContour.value.set(p.contour);
this.fog.color.set(p.fog);
this.fog.near = p.fogNear;
this.fog.far = p.fogFar;
this.ambient.color.set(p.ambient);
this.ambient.intensity = p.ambientI;
this.sunLight.color.set(p.light);
this.sunLight.intensity = p.lightI;
this.inkMat.color.set(p.ink);
this.edgeMat.color.set(p.ink);
this.edgeMat.opacity = p.inkSoft;
this.mouthMat.color.set(p.mouth);
const cols = { sand: new THREE.Color(p.sand), rock: new THREE.Color(p.rock), top: new THREE.Color(p.top) };
for (const { geo, height } of this.islandGeos) { paintIsland(geo, height, cols); (geo.getAttribute("color") as THREE.BufferAttribute).needsUpdate = true; }
this.shipMats.hull.color.set(p.hull);
this.shipMats.band.color.set(p.band);
this.shipMats.wood.color.set(p.wood);
this.shipMats.eye.color.set(p.eye);
const ctx = this.sailCanvas.getContext("2d");
if (ctx) {
ctx.fillStyle = p.sail; ctx.fillRect(0, 0, 64, 64);
ctx.fillStyle = p.sailStripe;
for (const x of [8, 27, 46]) ctx.fillRect(x, 0, 10, 64);
}
this.sailTex.needsUpdate = true;
this.ship.traverse((o) => { if (o.userData.edge) ((o.userData.edge as THREE.LineSegments).material as THREE.LineBasicMaterial).color.set(p.ink); });
// cave
this.caveMats.rock.color.set(p.caveRock);
this.caveMats.floor.color.set(p.caveFloor);
this.caveMats.boulder.color.set(p.caveFloor);
this.caveMats.ink.color.set(p.caveInk);
this.caveMats.edge.color.set(p.caveInk);
this.caveMats.giant.color.set(p.giant);
this.caveMats.giantInc.color.set(p.giantInc);
this.caveMats.giantHull.color.set(p.giantInc);
this.caveMats.eye.color.set(p.giantEye);
this.caveMats.wool.color.set(p.wool);
this.caveMats.face.color.set(p.face);
this.caveMats.fire.color.set(p.fire);
this.caveMats.core.color.set(p.fireCore);
this.fireLight.color.set(p.fire);
this.caveAmbient.intensity = p.caveAmbient;
}
/* ----- frame ----- */
setHorizon(fraction: number) { this.horizonAt = fraction; }
/** Lift the picture by px (the principal point moves up, nothing is zoomed), so the ship and the island
* sit above the bottom timeline. Both cameras render a window into a taller frame. */
setFrameShift(px: number) {
if (Math.abs(px - this.shift) < 0.5) return;
this.shift = px;
this.applyView();
}
private applyView() {
const w = this.host.clientWidth, h = this.host.clientHeight;
if (!w || !h) return;
const s = this.shift, fullH = h + 2 * s;
for (const [cam, fov] of [[this.camera, 46], [this.caveCam, 54]] as const) {
cam.fov = THREE.MathUtils.radToDeg(2 * Math.atan(Math.tan(THREE.MathUtils.degToRad(fov / 2)) * (fullH / h)));
cam.aspect = w / fullH;
if (s > 0) cam.setViewOffset(w, fullH, 0, 2 * s, w, h);
else cam.clearViewOffset();
cam.updateProjectionMatrix();
}
}
/* ----- paths B ----- */
setPaths(on: boolean) { this.seaRibbons.group.visible = this.caveRibbons.group.visible = on; }
setThreadSong(song: Song) { this.seaRibbons.setSong(song); this.caveRibbons.setSong(song); }
setPick(id: string | null) { this.seaRibbons.setPick(id); this.caveRibbons.setPick(id); }
/** Screen positions (px in the canvas) for the ribbons' floating labels at beat position p. */
labelPoints(p: number): { id: string; x: number; y: number; song: number; ahead: boolean; visible: boolean }[] {
const inCave = p >= 2.9;
const cam = inCave ? this.caveCam : this.camera;
const set = inCave ? this.caveRibbons : this.seaRibbons;
const w = this.host.clientWidth, h = this.host.clientHeight;
return set.labels(inCave ? 6 : this.split).map((l) => {
const v = l.pos.clone().project(cam);
return { id: l.id, x: (v.x * 0.5 + 0.5) * w, y: (-v.y * 0.5 + 0.5) * h, song: l.song, ahead: l.ahead, visible: v.z < 1 && Math.abs(v.x) < 1.05 && Math.abs(v.y) < 1.05 };
});
}
private resize() {
const w = this.host.clientWidth, h = this.host.clientHeight;
if (!w || !h) return;
this.renderer.setSize(w, h, false);
this.applyView();
}
/** Points the sea camera from pos at target; at p=0 the pitch is forced so the horizon sits on the frieze's ground line. */
private aimSea(pos: THREE.Vector3, target: THREE.Vector3, horizonLock: number) {
const d = this.tmp.copy(target).sub(pos);
const yaw = Math.atan2(-d.x, -d.z);
const pitchAim = Math.atan2(d.y, Math.hypot(d.x, d.z));
// in the un-lifted frame: the horizon's offset from the picture's centre, which sits `shift` px above the stage's
const tanHalf = Math.tan(THREE.MathUtils.degToRad(46 / 2));
const h = this.host.clientHeight || 1;
const pitchLock = Math.atan((this.horizonAt - 0.5 + this.shift / h) * 2 * tanHalf);
this.camera.position.copy(pos);
this.camera.rotation.set(lerp(pitchAim, pitchLock, horizonLock), yaw, 0);
}
/** Render the world at beat position p (0 = frieze … 4 = cave), wall-clock time in seconds. */
render(p: number, time: number) {
const t = this.reduced ? time * 0.25 : time;
this.waveU.uTime.value = t;
if (p < 2.9) this.renderSea(p, t);
else this.renderCave(p, t);
}
private renderSea(p: number, t: number) {
const sail = easeInOut(p - 1);
const shipPos = shipAt(sail);
this.split = 2 + 3 * sail;
this.seaRibbons.update(t, this.split);
const ahead = shipAt(Math.min(1, sail + 0.01)).sub(shipAt(Math.max(0, sail - 0.01)));
const heading = Math.atan2(ahead.x, ahead.z);
this.ship.position.set(shipPos.x, seaWave(shipPos.x, shipPos.z, t) * 0.6 - 0.15, shipPos.z);
this.ship.rotation.set(Math.sin(t * 0.9) * 0.03, heading, Math.sin(t * 1.1) * 0.05);
const rise = easeInOut((p - 1.05) / 0.7);
this.island.position.y = lerp(ISLAND_SUNK, 0, rise);
this.island.visible = rise > 0;
// Camera: a fixed side (behind the ship's right shoulder) the whole way; landfall only pulls back and up,
// so the island grows ahead-left of the ship instead of shuffling across the frame (13's rough spot).
const m = ease((p - 0.42) / 0.58);
const n = easeInOut(p - 1);
const off0 = v3(0, 3.2, 74), off1 = v3(12, 9, 34), off2 = v3(15, 19, 74);
const camOff = p <= 1 ? off0.lerp(off1, m) : off1.lerp(off2, n);
const pos = shipPos.clone().add(camOff);
const tgt1 = shipPos.clone().add(v3(-6, 3, -26));
const tgt2 = shipPos.clone().lerp(ISLAND, 0.5).setY(7);
const target = p <= 1 ? tgt1 : tgt1.lerp(tgt2, n);
this.aimSea(pos, target, p <= 1 ? 1 - m : 0);
this.renderer.render(this.sea, this.camera);
}
private renderCave(p: number, t: number) {
const k = easeInOut((p - 3.12) / 0.88);
const pos = v3(lerp(0, 1, k), lerp(1, -1, k), lerp(42, 30, k));
const target = v3(lerp(0, -4, k), lerp(0, 5, k), -40);
this.caveCam.position.copy(pos);
this.caveCam.lookAt(target);
for (const f of this.flames) {
const s = 0.82 + 0.18 * Math.sin(t * 8 + f.userData.phase) + 0.08 * Math.sin(t * 19 + f.userData.phase * 2);
f.scale.set(1 + (1 - s) * 0.4, s, 1);
f.lookAt(this.caveCam.position.x, f.parent!.position.y + f.position.y, this.caveCam.position.z);
}
this.caveRibbons.update(t, 6);
this.fireLight.intensity = 820 * (0.9 + 0.1 * Math.sin(t * 11) * Math.sin(t * 4.3));
this.caveMats.eye.color.set(PAL.giantEye).multiplyScalar(0.85 + 0.15 * Math.sin(t * 2));
this.renderer.render(this.cave, this.caveCam);
}
dispose() {
this.ro.disconnect();
this.renderer.dispose();
}
}
+227 -119
View File
@@ -1,10 +1,12 @@
/* 15 · Voyage, look 2 — 14's voyage in look 2 only, with look 3's cohesion (one ornament, one type
pairing, terracotta + black accents across frieze, panel and HUD) and the motif paths out in the open. */
/* 16 · Voyage, readable — 15 (look 2, the meander accents, terracotta + black) with readable type:
Inter for every label and control, Cormorant only for big titles and the frieze's inscriptions, nothing
under 13px. Listen and lyrics share one tab; the paths are combined (timeline, panel strip, sea ribbons). */
@import "@fontsource/cormorant-garamond/latin-500.css";
@import "@fontsource/cormorant-garamond/latin-600.css";
@import "@fontsource/cormorant-garamond/latin-700.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-500.css";
@import "@fontsource/inter/latin-600.css";
:root {
@@ -28,9 +30,8 @@
--meander: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28'%3E%3Crect width='28' height='28' fill='%23c46a33'/%3E%3Cpath d='M0 25.2H28M4.2 25.2V3H24V20.3H9.8V8.4H18.2V14.7' fill='none' stroke='%231d140e' stroke-width='3.2'/%3E%3C/svg%3E");
/* where the fitted frieze sits on the stage: between the caption and the HUD */
--fz-top: 116px;
--fz-bot: 92px;
--fz-bot: 218px;
}
body[data-paths="c"] { --fz-bot: 218px; }
* { box-sizing: border-box; }
html, body { height: 100%; }
@@ -49,8 +50,7 @@ button { font: inherit; color: inherit; }
:focus-visible { outline: 3px solid var(--clay); outline-offset: 2px; }
[hidden] { display: none !important; }
/* one label style everywhere: the frieze's inscriptions (Cormorant, capitals, wide tracking) */
.lbl { font: 600 0.875rem/1.2 var(--serif); letter-spacing: 0.16em; text-transform: uppercase; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* the shared ornament: a meander strip between two glaze rules */
.meander-edge::before, .cap::before, .hud::before, .panel::before {
@@ -118,23 +118,24 @@ button { font: inherit; color: inherit; }
.mean { fill: none; stroke: var(--glaze); stroke-width: 3; }
.scroll { fill: none; stroke: var(--glaze); stroke-width: 3.4; stroke-linecap: round; }
.frame-rule { display: none; }
.inscr { font: 600 19px var(--serif); letter-spacing: 0.2em; fill: var(--glaze); }
.saga-name { font: 600 15px var(--serif); letter-spacing: 0.22em; fill: var(--glaze); }
/* the frieze is drawn at ~0.7 of its units on this stage: 27 units ≈ 19px on screen */
.inscr { font: 700 27px var(--serif); letter-spacing: 0.08em; fill: var(--glaze); }
.saga-name { font: 600 20px var(--sans); letter-spacing: 0.06em; fill: var(--glaze); }
.glow-a { stop-color: #e2803a; }
.glow-b { stop-color: #3a170a; }
.glow-c { stop-color: #0c0705; }
/* paths B in the frieze: the register under the ground line carries the motif threads */
/* threads on the sea, in the frieze: the register under the ground line carries the motif threads */
.f-threads { display: none; }
body[data-paths="b"] .f-threads { display: inline; }
body[data-paths="b"] .f-scroll { display: none; }
body[data-ribbons="on"] .f-threads { display: inline; }
body[data-ribbons="on"] .f-scroll { display: none; }
.ft { pointer-events: auto; cursor: pointer; transition: opacity 0.2s ease; }
.ft__edge { fill: none; stroke: var(--glaze); stroke-width: 8; stroke-linecap: round; }
.ft__line { fill: none; stroke: var(--c); stroke-width: 4.2; stroke-linecap: round; }
.ft__knot { fill: var(--added-white); stroke: var(--glaze); stroke-width: 3; }
.ft__knot.is-here { fill: var(--c); }
.ft__dot { fill: var(--c); stroke: var(--glaze); stroke-width: 2.4; }
.ft__name { font: 700 17px var(--serif); letter-spacing: 0.12em; fill: var(--glaze); }
.ft__name { font: 600 20px var(--sans); letter-spacing: 0.02em; fill: var(--glaze); paint-order: stroke; stroke: var(--clay); stroke-width: 4px; }
.ft.is-on .ft__line { stroke-width: 6; }
.ft.is-on .ft__edge { stroke-width: 10; }
.ft.is-dim { opacity: 0.28; }
@@ -164,7 +165,7 @@ body[data-paths="b"] .f-scroll { display: none; }
overflow: hidden;
}
.cap::before { border-top: 0; }
.cap__eyebrow { margin: 0; color: var(--clay-deep); }
.cap__eyebrow { margin: 0; font: 600 0.9375rem/1.3 var(--sans); color: var(--clay-deep); }
.cap__title { margin: 4px 0 0; font: 600 2rem/1.1 var(--serif); }
.back {
margin-top: 8px;
@@ -178,14 +179,13 @@ body[data-paths="b"] .f-scroll { display: none; }
cursor: pointer;
}
/* ---------- HUD: the step bar (A, B) or the bottom timeline (C) ---------- */
/* ---------- HUD: the step row and the voyage timeline across the bottom of the stage ---------- */
.hud {
position: absolute;
z-index: 7;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
padding: 20px 12px 8px;
left: 16px; right: 16px;
bottom: 16px;
padding: 16px 14px 4px;
background: var(--vellum-hi);
color: var(--iron);
border: 2px solid var(--glaze);
@@ -193,13 +193,13 @@ body[data-paths="b"] .f-scroll { display: none; }
box-shadow: 0 12px 30px -14px rgb(0 0 0 / 0.65);
white-space: nowrap;
overflow: hidden;
font-size: 0.875rem;
}
.hud::before { border-top: 0; height: 12px; background-size: 12px 12px; }
.hud__row { display: flex; align-items: center; gap: 8px; }
.hud__row { display: flex; align-items: center; gap: 6px; padding-bottom: 5px; border-bottom: 1px solid var(--line); }
.hud button { cursor: pointer; }
.hud__play {
min-width: 74px;
padding: 7px 14px;
padding: 7px 15px;
border: 0;
border-radius: 999px;
background: var(--glaze);
@@ -208,90 +208,106 @@ body[data-paths="b"] .f-scroll { display: none; }
font-size: 0.875rem;
}
.hud__step {
flex: none;
width: 32px; height: 32px;
border: 1.5px solid var(--glaze);
border-radius: 50%;
background: transparent;
font-size: 1rem;
}
.hud__step:disabled { opacity: 0.3; cursor: default; }
.hud__gap { flex: 1; }
.dots { display: flex; gap: 0; margin: 0; padding: 0; list-style: none; }
.dots button {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 6px 5px 4px;
gap: 6px;
padding: 5px 8px 5px 4px;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--iron-soft);
font-size: 0.875rem;
font-weight: 500;
}
.dots__label { font-size: 0.8125rem; letter-spacing: 0.1em; }
.dots button:hover { color: var(--iron); background: rgb(43 33 24 / 0.06); }
.dots__n {
display: inline-grid;
place-items: center;
width: 21px; height: 21px;
width: 22px; height: 22px;
border: 1.5px solid currentColor;
border-radius: 50%;
font-size: 0.7rem;
font-size: 0.8125rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.dots button[aria-current] { color: var(--iron); }
.dots button[aria-current] .dots__label { font-weight: 700; }
.dots button[aria-current] { color: var(--iron); font-weight: 600; }
.dots button[aria-current] .dots__n { background: var(--clay); border-color: var(--glaze); color: var(--glaze); }
.ptoggle { display: flex; align-items: center; gap: 4px; margin-left: 6px; padding-left: 12px; border-left: 1.5px solid var(--line); }
.ptoggle__label { margin-right: 4px; font-size: 0.8125rem; color: var(--iron-soft); }
.ptoggle__sep { color: var(--iron-soft); }
.ptoggle button {
width: 28px; height: 28px;
border: 1.5px solid transparent;
border-radius: 50%;
/* "Threads on the sea": an on/off switch */
.switch {
display: inline-flex; align-items: center; gap: 8px;
padding: 5px 10px 5px 6px;
border: 0; border-radius: 999px;
background: transparent;
font: 700 0.95rem var(--serif);
font-size: 0.875rem; font-weight: 600;
}
.ptoggle button:hover { border-color: var(--line); }
.ptoggle button[aria-pressed="true"] { background: var(--clay); border-color: var(--glaze); color: var(--glaze); }
.ptoggle__name { min-width: 84px; margin-left: 6px; font: italic 500 1rem var(--serif); color: var(--iron-soft); }
.tl { display: none; }
.switch:hover { background: rgb(43 33 24 / 0.06); }
.switch__track { position: relative; width: 34px; height: 20px; border: 1.5px solid var(--glaze); border-radius: 999px; background: var(--vellum-lo); transition: background-color 0.2s ease; }
.switch__knob { position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%; background: var(--glaze); transition: transform 0.2s ease; }
.switch[aria-checked="true"] .switch__track { background: var(--clay); }
.switch[aria-checked="true"] .switch__knob { transform: translateX(14px); }
/* C: the HUD grows into the voyage timeline across the bottom of the stage */
body[data-paths="c"] .hud { left: 20px; right: 20px; bottom: 16px; transform: none; padding: 20px 16px 6px; }
body[data-paths="c"] .hud__row { padding-bottom: 6px; border-bottom: 1px solid var(--line); }
body[data-paths="c"] .ptoggle { margin-left: auto; }
body[data-paths="c"] .tl { display: block; padding-top: 4px; }
.tlbtn {
display: inline-flex; align-items: center; gap: 6px;
margin-left: 4px;
padding: 5px 10px 5px 12px;
border: 1.5px solid var(--line);
border-radius: 999px;
background: transparent;
font-size: 0.875rem; font-weight: 600;
color: var(--iron);
}
.tlbtn:hover { border-color: var(--glaze); }
.tlbtn__chev { display: inline-block; font-size: 1rem; line-height: 1; transform: translateY(-2px); transition: transform 0.2s ease; }
body[data-timeline="closed"] .tlbtn__chev { transform: translateY(2px) rotate(180deg); }
.tl { padding-top: 2px; }
/* ---------- horizontal thread strips (A in the panel, C at the bottom) ---------- */
/* ---------- horizontal thread strips (the timeline, and the panel's pinned strip) ---------- */
.hs { display: grid; grid-template-columns: var(--label-w) auto; align-items: start; }
.hs__labels { display: flex; flex-direction: column; }
.hs__course-label { height: var(--course-h); display: flex; align-items: center; padding-bottom: calc(var(--course-h) - 32px); font: 600 0.75rem var(--serif); letter-spacing: 0.16em; text-transform: uppercase; color: var(--iron-soft); }
.hs__course-label { height: var(--course-h); display: flex; align-items: flex-start; padding-top: calc(var(--course-y) - 9px); font-size: 0.8125rem; font-weight: 600; line-height: 1.3; color: var(--iron-soft); }
.hs__course-label--slim { height: auto; padding-top: 28px; }
.hs__m {
display: flex; align-items: center; gap: 7px;
padding: 0 6px 0 0;
border: 0; border-radius: 3px;
background: transparent;
color: var(--c);
font-size: 0.78rem; font-weight: 600;
font-size: 0.8125rem; font-weight: 600; line-height: 1;
text-align: left; white-space: nowrap;
cursor: pointer;
transition: opacity 0.2s ease;
}
.hs__m i { flex: none; width: 9px; height: 9px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 1.5px var(--glaze); }
.hs__m i { flex: none; width: 10px; height: 10px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 1.5px var(--glaze); }
.hs__m:hover { text-decoration: underline; text-underline-offset: 3px; }
.hs__m.is-on { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; }
.hs__m.is-dim { opacity: 0.4; }
.hs__m.is-dim { opacity: 0.45; }
.hs__svg { display: block; overflow: visible; }
.hs__here-band { fill: rgb(196 106 51 / 0.14); }
.hs__here-band { fill: rgb(196 106 51 / 0.16); }
.hs__course { fill: none; stroke: var(--clay-deep); stroke-width: 1.6; stroke-dasharray: 5 3.5; }
.hs__saga { stroke: var(--glaze); stroke-width: 1.4; }
.hs__saga-name { font: 600 9.5px var(--serif); letter-spacing: 0.16em; fill: var(--iron-soft); }
.hs__saga-name { font: 600 13px var(--sans); fill: var(--clay-deep); }
.hs__stop circle { fill: var(--vellum-hi); stroke: var(--glaze); stroke-width: 1.6; }
.hs__stop.is-current circle { fill: var(--clay); stroke-width: 2; }
.hs__num { font: 600 10px var(--sans); fill: var(--iron-soft); text-anchor: middle; }
.hs__num.is-current { fill: var(--iron); }
.hs__title { font: 500 11px var(--sans); fill: var(--iron-soft); text-anchor: middle; }
.hs__num { font: 600 13px var(--sans); fill: var(--iron-soft); text-anchor: middle; font-variant-numeric: tabular-nums; }
.hs__num.is-current { fill: var(--glaze); }
.hs__stop--slim .hs__num { fill: var(--iron); }
.hs__title { font: 500 13px var(--sans); fill: var(--iron-soft); }
.hs__title.is-current { font-weight: 600; fill: var(--iron); }
.hs__ship-hull, .hs__ship-sail { fill: var(--clay); stroke: var(--glaze); stroke-width: 1.4; stroke-linejoin: round; }
.hs__ship-mast { stroke: var(--glaze); stroke-width: 1.6; }
.hs__here { font: italic 600 12px var(--serif); fill: var(--clay-deep); }
.hs__here { font: 600 13px var(--sans); fill: var(--clay-deep); paint-order: stroke; stroke: var(--vellum-hi); stroke-width: 4px; stroke-linejoin: round; }
.thread { transition: opacity 0.2s ease; }
.thread__line { fill: none; stroke: var(--c); stroke-width: 2.4; stroke-linecap: round; opacity: 0.85; }
.thread__knot { fill: var(--vellum-hi); stroke: var(--c); stroke-width: 2.2; }
@@ -300,33 +316,37 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
.thread.is-on .thread__knot { fill: var(--c); }
.thread.is-dim { opacity: 0.2; }
/* ---------- B: floating labels over the 3D ribbons ---------- */
/* ---------- threads on the sea: labels over the 3D ribbons, lifted on leader lines ---------- */
.wlabels { position: absolute; inset: 0; z-index: 5; pointer-events: none; transition: opacity 0.2s linear; }
.wl-leaders { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.wl-leaders path { stroke: var(--glaze); stroke-width: 1.6; fill: none; }
.wl-leaders circle { fill: var(--added-white); stroke: var(--glaze); stroke-width: 1.6; }
.wl {
position: absolute;
left: 0; top: 0;
translate: -50% calc(-100% - 14px);
display: grid;
gap: 0;
padding: 4px 10px 5px;
padding: 7px 11px 8px;
border: 1.5px solid var(--glaze);
border-left: 5px solid var(--c);
border-radius: 2px;
background: var(--vellum-hi);
color: var(--iron);
text-align: left;
white-space: nowrap;
pointer-events: auto;
cursor: pointer;
box-shadow: 0 6px 14px -8px rgb(0 0 0 / 0.6);
}
.wl::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 4px; background: var(--clay); }
.wl__head { margin: 2px 0 4px; font-size: 0.8125rem; font-weight: 600; color: var(--iron-soft); }
.wl__ms { display: flex; flex-direction: column; gap: 2px; margin: 0; }
.wl__m {
display: flex; align-items: center; gap: 7px;
padding: 1px 0;
border: 0; background: transparent;
font-size: 0.9375rem; font-weight: 600; text-align: left;
cursor: pointer;
transition: opacity 0.2s ease;
}
.wl::after { content: ""; position: absolute; left: 50%; top: 100%; width: 1.5px; height: 14px; background: var(--glaze); }
.wl__name { display: flex; align-items: center; gap: 6px; font: 700 0.8125rem/1.2 var(--serif); letter-spacing: 0.12em; text-transform: uppercase; }
.wl__name i { width: 9px; height: 9px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 1.5px var(--glaze); }
.wl__note { font-size: 0.75rem; color: var(--iron-soft); }
.wl.is-on { outline: 2px solid var(--clay); outline-offset: 1px; }
.wl.is-dim { opacity: 0.45; }
.wl__m i { width: 11px; height: 11px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 1.5px var(--glaze); }
.wl__m:hover, .wl__m.is-on { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; }
.wl__m.is-dim { opacity: 0.45; }
/* ---------- the panel ---------- */
.panel {
@@ -356,10 +376,29 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
}
.panel[data-open="false"] .panel__body { opacity: 0; transform: translateX(40px); pointer-events: none; }
.panel__head { display: flex; align-items: flex-start; gap: 12px; padding: 16px 22px 10px 24px; }
.panel__head { display: flex; align-items: flex-start; gap: 14px; padding: 18px 20px 12px 22px; }
.panel__song { flex: 1; min-width: 0; transition: opacity 0.18s ease, transform 0.18s ease; }
.panel__eyebrow { margin: 0; font-size: 0.8125rem; color: var(--clay-deep); }
.panel__title { display: flex; align-items: baseline; gap: 10px; margin: 4px 0 2px; font: 600 2rem/1.05 var(--serif); }
.panel__eyebrow { margin: 0; font-size: 0.8125rem; font-weight: 600; color: var(--clay-deep); font-variant-numeric: tabular-nums; }
.panel__eyebrow.is-live::before { content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--clay); box-shadow: 0 0 0 1.5px var(--glaze); vertical-align: 1px; }
.panel__title { margin: 2px 0 2px; font: 600 2rem/1.05 var(--serif); }
.panel__sub { margin: 0; font-size: 0.9375rem; color: var(--iron-soft); }
.pplay, .player__play {
flex: none;
display: grid; place-items: center;
width: 52px; height: 52px;
margin-top: 4px;
border: 2px solid var(--glaze);
border-radius: 50%;
background: var(--clay);
color: var(--glaze);
cursor: pointer;
box-shadow: 0 3px 0 var(--glaze);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.pplay svg, .player__play svg { width: 22px; height: 22px; fill: currentColor; }
.pplay:hover, .player__play:hover { transform: translateY(-1px); box-shadow: 0 4px 0 var(--glaze); }
.pplay:active, .player__play:active { transform: translateY(2px); box-shadow: 0 1px 0 var(--glaze); }
.pplay[aria-pressed="true"], .player__play[aria-pressed="true"] { background: var(--glaze); color: var(--added-white); }
.panel__n, .panel__tab-n {
flex: none;
display: inline-grid;
@@ -371,8 +410,6 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
color: var(--glaze);
font: 700 0.9375rem var(--sans);
}
.panel__n { transform: translateY(-3px); }
.panel__sub { margin: 0; color: var(--iron-soft); }
.panel__hide {
flex: none;
margin-top: 2px;
@@ -381,17 +418,19 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
border-radius: 999px;
background: transparent;
font-size: 0.875rem;
font-weight: 500;
color: var(--iron-soft);
cursor: pointer;
}
.panel__hide:hover { color: var(--iron); border-color: var(--glaze); }
/* A: the pinned strip */
.pin { margin: 2px 0 0; padding: 8px 22px 6px 24px; border-top: 1px solid var(--line); background: rgb(255 250 236 / 0.35); transition: opacity 0.18s ease; }
.pin__head { display: flex; align-items: baseline; gap: 10px; margin: 0 0 2px; font-size: 0.8125rem; color: var(--iron); }
.pin__head span { font: italic 500 0.95rem var(--serif); letter-spacing: 0; text-transform: none; color: var(--iron-soft); }
/* the strip pinned in the panel while the bottom timeline is collapsed */
.pin { margin: 0; padding: 10px 20px 8px 22px; border-top: 1px solid var(--line); background: rgb(255 250 236 / 0.4); transition: opacity 0.18s ease; }
.pin__head { display: flex; align-items: baseline; gap: 10px; margin: 0 0 4px; }
.pin__title { font-size: 0.875rem; font-weight: 600; color: var(--iron); }
.pin__sub { font-size: 0.8125rem; color: var(--iron-soft); }
.tabs { display: flex; gap: 4px; padding: 0 22px 0 24px; border-top: 1px solid var(--line); border-bottom: 1.5px solid var(--glaze); }
.tabs { display: flex; gap: 2px; padding: 0 20px 0 22px; border-top: 1px solid var(--line); border-bottom: 1.5px solid var(--glaze); }
.panel[data-pinned="false"] .tabs { border-top: 0; }
.tabs button {
position: relative;
@@ -399,18 +438,18 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
border: 0;
background: transparent;
color: var(--iron-soft);
font: 600 0.9375rem var(--serif);
letter-spacing: 0.1em;
font-size: 0.9375rem;
font-weight: 500;
white-space: nowrap;
text-transform: uppercase;
cursor: pointer;
}
.tabs button[aria-selected="true"] { color: var(--iron); font-weight: 700; }
.tabs button:hover { color: var(--iron); }
.tabs button[aria-selected="true"] { color: var(--iron); font-weight: 600; }
.tabs button[aria-selected="true"]::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: -1.5px; height: 4px; background: var(--clay); border: 1px solid var(--glaze); border-bottom: 0; }
.panel__view {
min-height: 0;
overflow: auto;
padding: 18px 22px 28px 24px;
padding: 18px 20px 28px 22px;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
transition: opacity 0.18s ease, transform 0.18s ease;
@@ -437,33 +476,42 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
}
.panel[data-open="false"] .panel__tab { opacity: 1; pointer-events: auto; }
.panel__tab-arrow { font-size: 1.5rem; line-height: 1; color: var(--clay-deep); }
.panel__tab-label { writing-mode: vertical-rl; font: 600 1rem var(--serif); letter-spacing: 0.16em; text-transform: uppercase; }
.panel__tab-label { writing-mode: vertical-rl; font-size: 0.9375rem; font-weight: 600; }
/* ---------- panel content ---------- */
.sec { margin: 22px 0 8px; font: 600 1.3rem/1.2 var(--serif); }
.sec { margin: 24px 0 8px; font: 600 1.375rem/1.2 var(--serif); }
.panel__view > div > .sec:first-child, .panel__view > div > div:first-child > .sec:first-child { margin-top: 0; }
.note {
margin: 0 0 16px;
padding: 9px 12px;
border: 1px dashed var(--line);
border: 1px dashed rgb(43 33 24 / 0.35);
border-radius: 4px;
color: var(--iron-soft);
font-size: 0.875rem;
}
.hint { margin: 0 0 10px; color: var(--iron-soft); font-size: 0.875rem; }
.hint { margin: 0 0 10px; color: var(--iron-soft); font-size: 0.9375rem; }
/* About */
.facts { margin: 0; display: grid; gap: 0; }
.facts div { display: grid; grid-template-columns: 112px 1fr; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }
.facts dt { padding-top: 2px; font: 600 0.8125rem var(--serif); letter-spacing: 0.14em; text-transform: uppercase; color: var(--iron-soft); }
.facts dt { font-size: 0.875rem; font-weight: 600; color: var(--iron-soft); }
.facts dd { margin: 0; }
.facts a, .about a { color: var(--clay-deep); text-underline-offset: 3px; }
.about__fact { margin: 14px 0 0; font: italic 500 1.2rem/1.35 var(--serif); }
.about__fact { margin: 14px 0 0; font: italic 500 1.25rem/1.35 var(--serif); }
.parts { margin: 0; padding: 0; list-style: none; }
.parts li { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--line); }
.parts__name { font-weight: 600; }
.parts__who { color: var(--iron-soft); text-align: right; }
.panel .audio-slot { padding: 12px 14px; border: 1px dashed var(--line); border-radius: 4px; color: var(--iron-soft); opacity: 1; font-size: 0.875rem; }
.golisten {
display: inline-flex; align-items: center; gap: 9px;
margin-top: 14px;
padding: 8px 16px 8px 12px;
border: 0; border-radius: 999px;
background: var(--glaze); color: var(--added-white);
font-size: 0.9375rem; font-weight: 600;
cursor: pointer;
}
.golisten svg { width: 15px; height: 15px; fill: var(--clay); }
.panel .video-cards { gap: 8px; }
.panel .video-card {
position: relative;
@@ -483,6 +531,81 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
transform: translateY(-50%);
}
/* Listen & lyrics: the player */
.player {
position: relative;
margin: 0 0 16px;
padding: 22px 16px 14px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
background: #f3ecd8;
overflow: hidden;
}
.player::before {
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: 10px;
background: var(--meander) 0 0 / 10px 10px repeat-x;
border-bottom: 1.5px solid var(--glaze);
}
.player__top { display: flex; align-items: center; gap: 14px; }
.player__play { margin-top: 0; width: 56px; height: 56px; }
.player__play svg { width: 24px; height: 24px; }
.player__meta { min-width: 0; }
.player__part { margin: 0; font-size: 1.0625rem; font-weight: 600; }
.player__part span { font-weight: 400; color: var(--iron-soft); font-size: 0.9375rem; }
.player__time { margin: 0; font-size: 0.9375rem; font-weight: 600; font-variant-numeric: tabular-nums; }
.player__of { font-weight: 400; color: var(--iron-soft); }
.scrub { position: relative; margin: 16px 0 0; padding: 8px 0; cursor: pointer; touch-action: none; }
.scrub:focus-visible { outline-offset: 0; }
.scrub__track { position: relative; height: 8px; border: 1.5px solid var(--glaze); border-radius: 999px; background: var(--vellum-lo); }
.scrub__fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: 999px; background: var(--clay); }
.scrub__tick { position: absolute; top: -6px; bottom: -6px; width: 2px; margin-left: -1px; background: var(--glaze); }
.scrub__head { position: absolute; top: 50%; left: 0; width: 18px; height: 18px; margin: -9px 0 0 -9px; border: 2px solid var(--glaze); border-radius: 50%; background: var(--added-white); }
.segs { display: flex; gap: 4px; margin-top: 4px; }
.seg {
flex: 1 1 0;
min-width: max-content;
display: flex; align-items: baseline; gap: 6px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 2px;
background: transparent;
text-align: left;
font-size: 0.875rem; font-weight: 600;
cursor: pointer;
}
.seg:hover { border-color: var(--glaze); }
.seg__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.seg__time { font-weight: 400; color: var(--iron-soft); font-variant-numeric: tabular-nums; }
.seg.is-now { background: var(--clay); border-color: var(--glaze); color: var(--glaze); }
.seg.is-now .seg__time { color: var(--glaze); }
.player__note { margin: 10px 0 0; font-size: 0.8125rem; color: var(--iron-soft); }
/* Listen & lyrics: the lyrics, by part */
.part { margin: 0 0 14px; padding: 8px 10px 10px 14px; border-left: 4px solid var(--c); border-radius: 0 2px 2px 0; transition: opacity 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease; scroll-margin-top: 12px; }
.part.is-on { background: color-mix(in srgb, var(--c) 9%, transparent); }
.part.is-dim { opacity: 0.5; }
.part.is-now { background: rgb(196 106 51 / 0.16); box-shadow: inset 0 0 0 1.5px var(--glaze); opacity: 1; }
.part__head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 4px; }
.part__jump { display: inline-flex; align-items: baseline; gap: 9px; padding: 0; border: 0; background: transparent; text-align: left; cursor: pointer; }
.part__jump h3 { margin: 0; font: 600 1.375rem/1.2 var(--serif); font-variant-numeric: lining-nums; }
.part__jump:hover h3 { text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 4px; }
.part__icon { align-self: center; display: grid; place-items: center; width: 24px; height: 24px; border: 1.5px solid var(--glaze); border-radius: 50%; background: var(--vellum-hi); }
.part__icon svg { width: 11px; height: 11px; fill: var(--glaze); margin-left: 1px; }
.part__at { font-size: 0.875rem; color: var(--iron-soft); font-variant-numeric: tabular-nums; }
.part__now { display: none; padding: 1px 9px; border: 1px solid var(--glaze); border-radius: 999px; background: var(--clay); color: var(--glaze); font-size: 0.8125rem; font-weight: 600; }
.part.is-now .part__now { display: inline-block; }
.part.is-now .part__icon { background: var(--clay); }
.part__who { display: flex; flex-wrap: wrap; gap: 5px; margin: 0 0 6px; }
.singer { padding: 1px 10px; border: 1px solid rgb(43 33 24 / 0.3); border-radius: 999px; font-size: 0.8125rem; font-weight: 600; color: var(--iron-soft); }
.part__lines p { margin: 2px 0; font: italic 500 1.1875rem/1.45 var(--serif); }
.part__motifs { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; }
.mtag { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px 2px 8px; border: 1px solid transparent; border-radius: 999px; background: color-mix(in srgb, var(--c) 16%, transparent); color: var(--c); font-size: 0.8125rem; font-weight: 600; cursor: pointer; }
.mtag i { width: 8px; height: 8px; border-radius: 50%; background: var(--c); }
.mtag:hover, .mtag.is-on { border-color: var(--c); }
.mtag.is-on { background: color-mix(in srgb, var(--c) 26%, transparent); }
/* Paths: route chart */
.route {
margin: 0;
@@ -492,7 +615,7 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
border: 1.5px solid var(--glaze);
border-radius: 2px;
}
.route figcaption { margin-top: 4px; font: italic 500 0.95rem var(--serif); color: var(--iron-soft); }
.route figcaption { margin-top: 4px; font-size: 0.875rem; color: var(--iron-soft); }
.chart__svg { display: block; width: 100%; height: auto; }
.c-vellum { fill: #e6dab9; }
.c-frame { fill: none; stroke: var(--iron); stroke-width: 0.8; }
@@ -501,14 +624,14 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
.c-ink { fill: none; stroke: var(--iron); stroke-width: 0.7; }
.c-course { fill: none; stroke: var(--clay-deep); stroke-width: 1.3; stroke-dasharray: 3 2.4; }
.c-port { fill: #f3ebd6; stroke: var(--iron); stroke-width: 0.9; }
.c-num { font: 600 7px var(--sans); fill: var(--iron); }
.c-place { font: 600 italic 14px var(--serif); fill: var(--iron); }
.c-sea { font: italic 500 9px var(--serif); fill: var(--clay-deep); }
.c-num { display: none; }
.c-place { font: 600 italic 18px var(--serif); fill: var(--iron); }
.c-sea { font: italic 600 14px var(--serif); fill: var(--clay-deep); }
.c-rose circle { fill: none; stroke: var(--iron-soft); stroke-width: 0.6; }
.c-rose path { fill: var(--iron-soft); }
.c-here__ring { fill: rgb(196 106 51 / 0.25); stroke: var(--clay-deep); stroke-width: 1.4; }
.c-here__dot { fill: var(--clay-deep); }
.c-here__label { font: 600 9.5px var(--sans); fill: var(--clay-deep); paint-order: stroke; stroke: #efe6cd; stroke-width: 2.5px; }
.c-here__label { font: 600 12px var(--sans); fill: var(--clay-deep); paint-order: stroke; stroke: #efe6cd; stroke-width: 3px; }
/* Paths: motif picks */
.mlist { margin: 0 0 12px; padding: 0; list-style: none; display: grid; gap: 6px; }
@@ -537,7 +660,7 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
.vstrip__rows { grid-column: 1 / -1; grid-row: 1; margin: 0; padding: 0; list-style: none; }
.vstrip__row {
width: 100%;
height: 32px;
height: 34px;
display: flex;
align-items: center;
gap: 10px;
@@ -553,36 +676,21 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
.vstrip__row:hover { background: var(--vellum-lo); }
.vstrip__row.is-current { color: var(--iron); font-weight: 600; background: rgb(196 106 51 / 0.14); }
.vstrip__row.is-picked { outline: 2px solid var(--clay); outline-offset: -2px; color: var(--iron); }
.vstrip__n { width: 18px; text-align: right; font-size: 0.8125rem; font-variant-numeric: tabular-nums; }
.vstrip__n { width: 20px; text-align: right; font-size: 0.875rem; font-variant-numeric: tabular-nums; }
.vstrip__t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vstrip__now { padding: 0 7px; border: 1px solid var(--glaze); border-radius: 999px; background: var(--clay); color: var(--glaze); font-size: 0.75rem; font-weight: 600; }
.vstrip__now { padding: 0 8px; border: 1px solid var(--glaze); border-radius: 999px; background: var(--clay); color: var(--glaze); font-size: 0.8125rem; font-weight: 600; }
.vstrip__threads { grid-column: 2; grid-row: 1; margin-right: 12px; pointer-events: none; overflow: visible; }
/* Lyrics */
.part { margin: 0 0 16px; padding: 4px 0 6px 14px; border-left: 4px solid var(--c); transition: opacity 0.2s ease, background-color 0.2s ease; }
.part.is-on { background: color-mix(in srgb, var(--c) 9%, transparent); }
.part.is-dim { opacity: 0.45; }
.part__head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px 10px; margin-bottom: 4px; }
.part__head h3 { margin: 0; font: 600 1.3rem/1.2 var(--serif); font-variant-numeric: lining-nums; }
.part__who { display: flex; flex-wrap: wrap; gap: 4px; }
.singer { padding: 1px 9px; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8125rem; font-weight: 600; color: var(--iron-soft); }
.part__lines p { margin: 2px 0; font: italic 500 1.15rem/1.45 var(--serif); }
.part__motifs { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 0; }
.mtag { display: inline-flex; align-items: center; gap: 6px; padding: 1px 9px 1px 7px; border: 1px solid transparent; border-radius: 999px; background: color-mix(in srgb, var(--c) 16%, transparent); color: var(--c); font-size: 0.8125rem; font-weight: 600; cursor: pointer; }
.mtag i { width: 8px; height: 8px; border-radius: 50%; background: var(--c); }
.mtag:hover, .mtag.is-on { border-color: var(--c); }
.mtag.is-on { background: color-mix(in srgb, var(--c) 26%, transparent); }
/* Fan works */
.fw { margin: 0; padding: 0; list-style: none; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.fw__card { display: grid; align-content: start; gap: 2px; padding: 0 0 10px; border-radius: 2px; background: #f3ecd8; border: 1.5px solid var(--glaze); overflow: hidden; }
.fw__card > p { margin: 0 10px; }
.fw__card { display: grid; align-content: start; gap: 3px; padding: 0 0 12px; border-radius: 2px; background: #f3ecd8; border: 1.5px solid var(--glaze); overflow: hidden; }
.fw__card > p { margin: 0 11px; }
.fw__thumb { display: block; width: 100%; aspect-ratio: 16 / 10; margin-bottom: 6px; border-bottom: 1.5px solid var(--glaze); }
.t-bg, .t-frame, .t-hole { fill: #d9c8a0; }
.t-ink { fill: var(--iron); }
.t-line { fill: none; stroke: #f3ead6; stroke-width: 2; stroke-linecap: round; }
.fw__type { font-size: 0.75rem; color: var(--clay-deep); }
.fw__title { font: 600 1.05rem/1.25 var(--serif); }
.fw__type { font-size: 0.8125rem; font-weight: 600; color: var(--clay-deep); }
.fw__title { font-size: 0.9375rem; font-weight: 600; line-height: 1.3; }
.fw__by { font-size: 0.875rem; color: var(--iron-soft); }
.fw__credit { margin-top: 4px !important; font-size: 0.8125rem; color: var(--iron-soft); text-decoration: underline dotted; text-underline-offset: 3px; }
.fw__credit span { text-decoration: none; display: inline-block; }
+236
View File
@@ -0,0 +1,236 @@
// Motif threads (12's chosen style: soft coloured threads, knotted where a motif plays), shared by the
// three placements, combined in 16: the bottom timeline (open, or slim when collapsed), the strip pinned in
// the panel while the timeline is collapsed, and the frieze register and 3D ribbons ("Threads on the sea").
// Everything here is a pure function of the song, the picked motif and a width.
import { motifsInSong } from "../../data/query";
import { motifs, sagas, songs } from "../../data/sample";
import type { Motif, Song } from "../../data/types";
export const byTrack = [...songs].sort((a, b) => a.track - b.track);
/** The shared motif colours are tuned for dark pages; on vellum and clay they are re-inked darker (12's INK). */
const INK: Record<string, string> = {
warrior: "#93650b", "open-arms": "#b2472c", "just-a-man": "#2e5d8b", remember: "#3b7347",
odysseus: "#5b3c20", athena: "#6a4a9d", polyphemus: "#8e1e24",
};
/** On the teal sea and the clay frieze the brighter shared colours pop; ink edges keep them legible. */
export const bright = (id: string) => motifs.find((m) => m.id === id)?.color ?? "#efe3c8";
export const inked = (id: string) => INK[id] ?? bright(id);
export const plays = (s: Song, id: string) => s.parts.some((p) => p.motifIds.includes(id));
/** Track indices (0-based, in track order) where a motif plays. */
export const hitsOf = (id: string) => byTrack.flatMap((s, i) => (plays(s, id) ? [i] : []));
export const indexOf = (song: Song) => byTrack.findIndex((s) => s.id === song.id);
export interface Route { from: string; next: string; prevSong?: Song; nextSong?: Song }
/** "comes from … / first heard here" and "returns in … / not heard again", in words. */
export function routeOf(song: Song, id: string): Route {
const hits = hitsOf(id).map((i) => byTrack[i]!);
const before = hits.filter((s) => s.track < song.track);
const after = hits.filter((s) => s.track > song.track);
const prevSong = before[before.length - 1];
const nextSong = after[0];
return {
from: prevSong ? `comes from ${prevSong.title}` : "first heard here",
next: nextSong ? `returns in ${nextSong.title}${after.length > 1 ? ` (+${after.length - 1} more)` : ""}` : "not heard again",
prevSong, nextSong,
};
}
export const songMotifs = (song: Song): Motif[] => motifsInSong(song);
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
const pickCls = (id: string, pick: string | null) => (pick === id ? " is-on" : pick ? " is-dim" : "");
/** The course line's y inside a strip; markers are translated to it. */
export const COURSE_Y = 28;
/** In the timeline the course sits lower, so saga names fit above the ship marker. */
export const COURSE_Y_C = 36;
/** In the slim (collapsed) timeline the stops are numbered discs; the ship floats above them. */
export const COURSE_Y_S = 38;
/** "panel": the strip pinned in the panel (numbers). "timeline": the open bottom timeline (titles). */
export type StripKind = "panel" | "timeline" | "slim";
export interface StripOpts {
song: Song;
pick: string | null;
/** Total width in px of the strip (labels + threads). */
width: number;
labelW: number;
rowH: number;
kind: StripKind;
}
const PAD: Record<StripKind, number> = { panel: 12, timeline: 46, slim: 22 };
const courseYOf = (k: StripKind) => (k === "panel" ? COURSE_Y : k === "timeline" ? COURSE_Y_C : COURSE_Y_S);
/* Timeline titles are laid out by their measured width (13px Inter, semibold to allow for the current
one), each split into the one or two lines that make it narrowest; stops are spaced so that neighbouring
titles never touch, and the slack is shared out evenly. */
let measureCtx: CanvasRenderingContext2D | null | undefined;
function textW(s: string): number {
if (measureCtx === undefined) measureCtx = typeof document === "undefined" ? null : document.createElement("canvas").getContext("2d");
if (!measureCtx) return s.length * 7.4;
measureCtx.font = '600 13px "Inter", system-ui, sans-serif';
return measureCtx.measureText(s).width;
}
function split(title: string): { lines: string[]; w: number } {
const words = title.split(" ");
let best = { lines: [title], w: textW(title) };
for (let i = 1; i < words.length; i++) {
const lines = [words.slice(0, i).join(" "), words.slice(i).join(" ")];
const w = Math.max(...lines.map(textW));
if (w < best.w - 0.5) best = { lines, w };
}
return best;
}
const TITLE_GAP = 12;
const xsCache = new Map<string, number[]>();
/** Forget measured layouts (call once the web font has loaded). */
export const resetMeasure = () => xsCache.clear();
/** x of every stop on a strip of width w (labels excluded). */
function stopXs(kind: StripKind, w: number): number[] {
const key = `${kind}:${w}`;
const hit = xsCache.get(key);
if (hit) return hit;
const xs = layoutXs(kind, w);
xsCache.set(key, xs);
return xs;
}
function layoutXs(kind: StripKind, w: number): number[] {
const n = byTrack.length;
if (kind !== "timeline") {
const pad = PAD[kind], step = (w - pad * 2) / (n - 1);
return byTrack.map((_, i) => pad + i * step);
}
const ws = byTrack.map((s) => split(s.title).w);
const need = ws.reduce((a, b) => a + b, 0) + TITLE_GAP * (n - 1);
const slack = Math.max(0, w - need) / n;
const xs: number[] = [];
let x = 0;
ws.forEach((tw) => { xs.push(x + (tw + slack) / 2); x += tw + slack + TITLE_GAP; });
return xs;
}
function shipMarker(x: number, y: number, flip: boolean, label: string, lift = 0): string {
return `<g class="hs__ship" data-ship transform="translate(${x.toFixed(1)},${y})"><g transform="translate(0,${-lift})">
<path class="hs__ship-hull" d="M-8,-8 Q0,-1 8,-8 Z"/><path class="hs__ship-mast" d="M0,-8 V-20"/><path class="hs__ship-sail" d="M0.5,-19 L7,-11.5 H0.5 Z"/>
<text class="hs__here" x="${flip ? -12 : 12}" y="-9"${flip ? ' text-anchor="end"' : ""}>${label}</text></g></g>`;
}
/**
* A horizontal thread strip: the voyage's songs as knots on a course line, "you are here" on the current
* song, then one thread per motif of the current song, knotted where it plays. Motif names are buttons.
*/
export function hStrip(o: StripOpts): string {
if (o.kind === "slim") return slimStrip(o);
const titles = o.kind === "timeline";
const ms = songMotifs(o.song);
const cur = indexOf(o.song);
const n = byTrack.length;
const w = o.width - o.labelW;
const xs = stopXs(o.kind, w);
const X = (i: number) => xs[i]!;
const courseY = courseYOf(o.kind);
const courseH = titles ? 76 : 54;
const H = courseH + ms.length * o.rowH + 2;
const wav = (x: number, k: number) => Math.sin(x / 26 + k * 1.7) * 1.6;
// the course: a dashed line through every song, saga boundaries as ticks
const bandL = cur > 0 ? (X(cur - 1) + X(cur)) / 2 : X(cur) - (X(1) - X(0)) / 2;
const bandR = cur < n - 1 ? (X(cur) + X(cur + 1)) / 2 : X(cur) + (X(n - 1) - X(n - 2)) / 2;
let svg = `<rect class="hs__here-band" x="${bandL.toFixed(1)}" y="0" width="${(bandR - bandL).toFixed(1)}" height="${H}" rx="3"/>`;
svg += `<path class="hs__course" d="M${X(0) - 8},${courseY}H${X(n - 1) + 8}"/>`;
byTrack.forEach((s, i) => {
if (i > 0 && s.sagaId !== byTrack[i - 1]!.sagaId) {
const x = (X(i) + X(i - 1)) / 2;
svg += `<path class="hs__saga" d="M${x.toFixed(1)},${titles ? 0 : courseY - 10}V${titles ? H : courseY + 10}"/>`;
if (titles) {
// the name sits right of its divider; where the saga is too short for it, " Saga" is dropped
const full = sagas.find((g) => g.id === s.sagaId)!.name.replace(/^The /, "");
const name = x + 6 + textW(full) > w ? full.replace(/ Saga$/, "") : full;
const tx = x + 6;
svg += `<text class="hs__saga-name" x="${tx.toFixed(1)}" y="11">${esc(name)}</text>`;
}
}
const isCur = i === cur;
svg += `<g class="hs__stop${isCur ? " is-current" : ""}"><title>${s.track}. ${esc(s.title)}</title>
<circle cx="${X(i).toFixed(1)}" cy="${courseY}" r="${isCur ? 6.5 : 5}"/>
${titles ? "" : `<text class="hs__num${isCur ? " is-current" : ""}" x="${X(i).toFixed(1)}" y="${courseY + 22}">${s.track}</text>`}</g>`;
if (titles) {
const tx = X(i).toFixed(1);
svg += `<text class="hs__title${isCur ? " is-current" : ""}" x="${tx}" y="${courseY + 20}" text-anchor="middle">${split(s.title).lines.map((l, j) => `<tspan x="${tx}" dy="${j ? 14 : 0}">${esc(l)}</tspan>`).join("")}</text>`;
}
});
svg += shipMarker(X(cur), courseY, cur > n * 0.6, "you are here");
// one thread per motif
ms.forEach((m, k) => {
const y = courseH + k * o.rowH + o.rowH / 2;
const hits = hitsOf(m.id);
const x0 = X(hits[0]!), x1 = X(hits[hits.length - 1]!);
let d = "";
for (let x = x0; x < x1; x += 5) d += `${d ? "L" : "M"}${x.toFixed(1)},${(y + wav(x, k)).toFixed(1)}`;
d += `${d ? "L" : "M"}${x1.toFixed(1)},${(y + wav(x1, k)).toFixed(1)}`;
svg += `<g class="thread${pickCls(m.id, o.pick)}" data-m="${m.id}" style="--c:${inked(m.id)}">
<path class="thread__line" d="${d}"/>
${hits.map((i) => `<circle class="thread__knot${i === cur ? " is-here" : ""}" cx="${X(i).toFixed(1)}" cy="${(y + wav(X(i), k)).toFixed(1)}" r="${i === cur ? 5.5 : 4.4}"><title>${esc(m.name)} in ${esc(byTrack[i]!.title)}</title></circle>`).join("")}
</g>`;
});
const labels = ms.map((m) => {
const r = routeOf(o.song, m.id);
return `<button type="button" class="hs__m${pickCls(m.id, o.pick)}" data-m="${m.id}" data-pick="${m.id}" aria-pressed="${o.pick === m.id}" style="--c:${inked(m.id)};height:${o.rowH}px" title="${esc(m.name)}: ${esc(r.from)} · ${esc(r.next)}"><i aria-hidden="true"></i>${esc(m.name)}</button>`;
}).join("");
return `<div class="hs hs--${o.kind}" style="--label-w:${o.labelW}px;--course-h:${courseH}px;--course-y:${courseY}px">
<div class="hs__labels"><span class="hs__course-label">${titles ? "The voyage" : "Songs 1–10"}</span>${labels}</div>
<svg class="hs__svg" width="${w}" height="${H}" viewBox="0 0 ${w} ${H}" aria-hidden="true">${svg}</svg>
</div>`;
}
/** The collapsed timeline: numbered stops on the course and the ship with "you are here", no threads. */
function slimStrip(o: StripOpts): string {
const cur = indexOf(o.song);
const n = byTrack.length;
const w = o.width - o.labelW;
const pad = PAD.slim;
const step = (w - pad * 2) / (n - 1);
const X = (i: number) => pad + i * step;
const y = COURSE_Y_S;
const H = y + 14;
let svg = `<path class="hs__course" d="M${X(0) - 12},${y}H${X(n - 1) + 12}"/>`;
byTrack.forEach((s, i) => {
if (i > 0 && s.sagaId !== byTrack[i - 1]!.sagaId) {
const x = (X(i) + X(i - 1)) / 2;
svg += `<path class="hs__saga" d="M${x.toFixed(1)},${y - 14}V${y + 14}"/>`;
}
const isCur = i === cur;
svg += `<g class="hs__stop hs__stop--slim${isCur ? " is-current" : ""}"><title>${s.track}. ${esc(s.title)}</title>
<circle cx="${X(i).toFixed(1)}" cy="${y}" r="11"/>
<text class="hs__num${isCur ? " is-current" : ""}" x="${X(i).toFixed(1)}" y="${y + 4.6}">${s.track}</text></g>`;
});
svg += shipMarker(X(cur), y, cur > n * 0.6, `you are here · ${esc(o.song.title)}`, 12);
return `<div class="hs hs--slim" style="--label-w:${o.labelW}px">
<div class="hs__labels"><span class="hs__course-label hs__course-label--slim">The voyage</span></div>
<svg class="hs__svg" width="${w}" height="${H}" viewBox="0 0 ${w} ${H}" aria-hidden="true">${svg}</svg>
</div>`;
}
/** Where the ship marker sits on a strip of this width, for a fractional song index. */
export function stripX(o: Pick<StripOpts, "width" | "labelW" | "kind">, idx: number): number {
const xs = stopXs(o.kind, o.width - o.labelW);
const i = Math.min(xs.length - 2, Math.max(0, Math.floor(idx)));
return xs[i]! + (xs[i + 1]! - xs[i]!) * (idx - i);
}
export const stripY = (k: StripKind) => courseYOf(k);
/** Toggle is-on / is-dim on every element that carries data-m, so one pick highlights everywhere. */
export function applyPick(root: ParentNode, pick: string | null) {
root.querySelectorAll<Element>("[data-m]").forEach((el) => {
const id = el.getAttribute("data-m");
el.classList.toggle("is-on", !!pick && id === pick);
el.classList.toggle("is-dim", !!pick && id !== pick);
if (el instanceof HTMLButtonElement && el.hasAttribute("aria-pressed")) el.setAttribute("aria-pressed", String(!!pick && id === pick));
});
}