From 42b51983c1d2cb8e004379131d855177494a5e5f Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 29 Oct 2026 02:31:33 +0100 Subject: [PATCH] feat(mockups): 08 sea chart + log line spike; fix registry count test Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- .../08-chart-logline-report.md | 133 +++-- src/mockups/08-chart-logline/main.ts | 525 +++++++++++++++++- src/mockups/08-chart-logline/style.css | 358 +++++++++++- tests/server.test.ts | 2 +- 5 files changed, 947 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b99fb17..5ae8239 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Deeper context on demand: `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/TOD Install: `bun install --frozen-lockfile` Dev: `bun run dev` (`PORT` env, default 3000) -Test: `bun test` | Single file: `bun test tests/data.test.ts` | Single case: `bun test -t "registry has 7"` +Test: `bun test` | Single file: `bun test tests/data.test.ts` | Single case: `bun test -t "registry has 11"` Typecheck: `bun run typecheck` Build: `bun run build` | Run the build: `bun run start` Docker: `docker build -t epic-timeline .` then `docker run --rm -p 3000:3000 epic-timeline` diff --git a/docs/design/002-sea-chart-spikes/08-chart-logline-report.md b/docs/design/002-sea-chart-spikes/08-chart-logline-report.md index e7b575f..ee7896b 100644 --- a/docs/design/002-sea-chart-spikes/08-chart-logline-report.md +++ b/docs/design/002-sea-chart-spikes/08-chart-logline-report.md @@ -1,72 +1,69 @@ -# 08 Sea chart + log line: status report (paused at usage limit) +# 08 Sea chart + log line: report -## What's done -- Read the round-2 brief, AGENTS.md, DESIGN.md, the round notes, the shared contract, and mockup 01's ink approach. Loaded the `frontend-design` skill. -- Design plan (not built yet): - - **Palette:** Admiralty-chart look. Water `#dfe9e6`, buff land `#e8d8a8`, navy ink `#1e2c3b`, chart magenta `#a3195b` for the course, ship and selection, and paper `#fbfbf7` for the log. - - **Motif colours:** darkened with `color-mix` against the ink, with a thin ink casing so the pale Odysseus colour stays readable. - - **Type:** Cormorant Garamond for titles and place names, with sea areas in italic. Inter for small numbers. - - **Layout at 1440×900:** - - 56px header; - - the chart fills the space below it (world 1600×600); - - a 268px log line with a sticky 200px motif legend on the left; - - the credits footer. - - **Detail panel:** 380px, slides over the chart's right edge. -- **`src/mockups/08-chart-logline/geo.ts` is written and complete (not yet compiled or typechecked).** It contains: - - the chart world size; - - the coastlines (Troy, Cicones coast, the Cyclops' island, Goat Island, a rock), drawn as closed Catmull-Rom paths; - - port positions, label offsets and waypoints per song; - - `buildRoute()`, which builds one smooth course split into port-to-port legs, with the years crossed on each leg; - - log line x positions: `LOG.hop` 156 per song plus `LOG.year` 300 per in-story year, giving a total width `LOG_W` of about 2550px; - - `xToIndex()`, which maps a log x position to a fractional song index for scrubbing; - - `yearsLabel()`, and the `rng()` helper for depth soundings. -- `index.html` is untouched (the stub is fine). `main.ts` and `style.css` are still the stubs. +**Status:** done. Not committed. -## What's left -- [ ] `main.ts` chart SVG: - - land with a hachured coast (stroke the same path at widths 30, 18 and 8 under the land fill); - - graticule, compass rose, soundings; - - saga sea-area names in italic, plus the place names Troy, Island of the Cyclopes and Land of the Cicones; - - the route: dashed in full, plus a solid magenta overlay up to the ship, using `stroke-dasharray` from cumulative leg lengths measured by `getTotalLength` on legs appended to the SVG; - - ports as circles with the track number, title labels from `ports[id].label`; - - "1 year at sea" and "2 years at sea" labels on the crossing legs. -- [ ] Ship marker at a fractional index: `getPointAtLength` on the leg, rotated to the tangent. -- [ ] View sync: - - animated `viewBox` `{cx, cy, z}`; - - while scrubbing the log, z≈1.5 centred on the ship; - - after leaving the log, return to the full view after 600ms; - - when a song is selected, focus the port at x≈36% so the panel doesn't cover it. -- [ ] Log line: - - saga brackets and a year ruler; - - the time axis, with wavy "N years at sea" gap labels; - - song buttons (track, title, story label); - - 7 motif lanes (16px apart) with a casing and a line from the first to the last occurrence; - - per-song part boxes, filled where the motif plays, so motifs read at part level; - - a scrub cursor with a year readout; - - a sticky legend of motif buttons. -- [ ] Clicking a chart port selects the song and smooth-scrolls the log line to its x. Clicking a log entry selects the song and focuses the chart on it. -- [ ] Detail panel: - - title, saga and track, story time, Odyssey link; - - parts with singers and motif chips, with the selected motif highlighted; - - `createAudioSlot`, `createVideoCards`, a close button. -- [ ] Motif selection: - - colour the route legs where both songs carry the motif, and dot them where it skips; - - ring the ports that carry it; - - fade the other log lanes. -- [ ] URL state (`?song`, `?motif`, `?overlay=1`) via `replaceState`. -- [ ] `?overlay=1`: - - dark sea gradient backdrop, with the app as a centred ~1180×740 panel; - - title bar reading "You are here: Survive", with the ship fixed at Survive; - - clicking a port opens a popover with a "Sail here" button that animates the ship there; - - the scrub shows a probe ring instead of moving the ship; - - the credits footer stays visible: fixed, light text on dark. -- [ ] `style.css` (unique). It needs the `@fontsource` imports: `cormorant-garamond/latin-{400,500,600,400-italic,500-italic}.css` and `inter/{400,500}.css`. -- [ ] Build to `/r2-08-chart-logline`, serve on port 4108, take the 4 screenshots, and look at them. Use `?overlay=1&song=my-goodbye` for the overlay shot so the popover shows. -- [ ] Run `bun test` and `bun run typecheck`, finalise this report, and reply in the brief's format. +## The navigation idea -## Known broken bits -- None at runtime. The page still renders the stub text, because `geo.ts` is not imported anywhere yet. -- The port label offsets in `geo.ts` are first guesses and need tuning against a screenshot. +The screen is split in two: -## Next step to resume -Write `src/mockups/08-chart-logline/main.ts` against `geo.ts`, following the "What's left" list in order, then `style.css`, then build and screenshot. +- **Top two thirds: an Admiralty-style chart that shows *where*.** It has buff land with hachure and shallow-water bands, soundings, a graticule and a compass rose. The course is drawn in chart magenta, and songs are numbered ports. +- **Bottom third: a horizontal log line in the style of 01 that shows *when*.** Gap length tracks in-story years (156px per song plus 300px per year). Years at sea are drawn as a magenta swell with a "1 year at sea" label. The year boundaries are marked "year 0/1/2/3" along the top. Seven motif lanes run underneath. + +The two halves are synced: + +- Hovering or dragging along the log moves the ship along the course, and the chart zooms to 1.5× around the ship. The cursor shows a readout such as "year 1, Warrior of the Mind" or "year 2, at sea". 600ms after you leave the log, the chart eases back out. +- Clicking a port selects the song and smooth-scrolls the log to it. +- Clicking a log entry selects the song and focuses the chart on that port. The port sits at about 36% of the chart width, so the 380px detail panel doesn't cover it. + +**Motifs** are selected from a sticky legend or from the chips in the detail panel. + +- Course legs where both songs carry the motif turn solid in the motif's colour. Legs where the motif skips a port are dotted. +- Ports that carry the motif get a ring, and the rest fade. +- The other lanes in the log fade. +- Each lane shows one box per part, filled where the motif plays, so motifs read at part level. + +**Overlay** (`?overlay=1`): the same chart and log sit in a compact 1180×740 panel over a dark sea gradient, under a "You are here: Survive" bar. + +- Clicking a port opens a popover beside it, with the port, title, the time ahead or back, and a **Sail here** button. The button animates the ship along the course and updates "You are here". +- Scrubbing the log shows a dashed probe ring instead of moving the ship. +- The credits are fixed at the bottom of the viewport. + +URL state is kept in `?song`, `?motif` and `?overlay` via `replaceState`. + +## What works + +- **Where and when are both readable at a glance, with no hunting.** The chart shows all 10 port titles and the saga sea-areas. The log shows every title, story label, saga bracket and year boundary. +- **Scrubbing is the strongest part.** It plays the voyage back like a film strip, and the long swell between Open Arms and Warrior of the Mind physically shows the lost year. +- **Motifs read well on both halves.** For example, Warrior of the Mind is solid only on Warrior→Polyphemus and dotted elsewhere. +- **The overlay proves that one chart serves both uses.** I verified "Sail here" from Survive to My Goodbye end to end. +- **Every sync path was verified in headless Chrome over CDP:** + - log scrub → chart `viewBox` and ship; + - port click → `?song=`, log `scrollLeft`, and the detail panel; + - overlay port → popover → sail. + +## What doesn't work (yet) + +- **The log is a scroll strip,** so only about 6 of the 10 songs are visible at once. "When" is never in full view the way "where" is. A minimap or a squeeze-to-fit mode would help. +- **Saga bracket names scroll under the sticky legend** and get clipped (for example "…Cyclops Saga" in the overlay). They should stick to the left edge. +- **Labels on steep course legs are hard to read.** The "1 year at sea" label on the chart follows the leg, so it reads at a steep angle. +- **The ship rides offset away from each port's label,** so at Survive it sits on the island's shore band. +- **The overlay has no idea of 3D camera context.** It is a gradient stand-in only. +- **Desktop 1440×900 only.** Nothing is responsive. +- **The port label offsets in `geo.ts` are hand-tuned.** Real data with more songs would need a label-placement pass. + +## Screenshots + +- `screenshots/08-chart-logline-default.png` +- `screenshots/08-chart-logline-motif.png` (`?motif=warrior`) +- `screenshots/08-chart-logline-song.png` (`?song=polyphemus`) +- `screenshots/08-chart-logline-overlay.png` (`?overlay=1&song=my-goodbye`) +- `screenshots/08-chart-logline-scrub.png` (extra: mid-scrub, chart zoomed onto the ship) + +## Checks + +- `bun run typecheck`: passes. +- `bun test`: 41 pass and 1 fail. The failure is `registry has 7 mockups` in `tests/server.test.ts`. It already fails at HEAD, because commit 52feed5 grew the registry to 11 without updating the test. That is outside this spike's folder. +- **Build:** a scratch outdir build of the real tree succeeds. + - Midway through, the build failed with "Multiple files share the same output path" because spikes 09, 10 and 11 had identical in-progress CSS. + - While that was happening, I built from a scratch copy, adding a marker rule to their CSS in the copy only. + - That collision has since cleared. diff --git a/src/mockups/08-chart-logline/main.ts b/src/mockups/08-chart-logline/main.ts index 150344a..1518d48 100644 --- a/src/mockups/08-chart-logline/main.ts +++ b/src/mockups/08-chart-logline/main.ts @@ -1,4 +1,527 @@ +import { motifs, sagas, songs } from "../../data/sample"; +import { sagaOf } from "../../data/query"; +import type { Motif, Song } from "../../data/types"; import { mountCredits } from "../../shared/credits"; +import { createAudioSlot } from "../../shared/audio-slot"; +import { createVideoCards } from "../../shared/video-cards"; +import { H, LOG, LOG_W, W, buildRoute, land, logX, ports, rng, xToIndex, yearsLabel } from "./geo"; mountCredits(); -document.getElementById("app")!.textContent = "Sea chart + log line (stub)"; + +/* ------------------------------------------------------------------ state */ +const params = new URLSearchParams(location.search); +const overlay = params.get("overlay") === "1"; +const HERE = songs.findIndex((s) => s.id === "survive"); +let selected: number = songs.findIndex((s) => s.id === params.get("song")); +let motifId: string | null = motifs.some((m) => m.id === params.get("motif")) ? params.get("motif") : null; +let shipAt = overlay ? HERE : songs.length - 1; // fractional song index +let probeAt: number | null = null; + +if (overlay) document.body.classList.add("is-overlay"); + +/* ------------------------------------------------------------------ helpers */ +const SVG = "http://www.w3.org/2000/svg"; +type Attrs = Record; +function s(tag: K, attrs: Attrs = {}, parent?: Element): SVGElementTagNameMap[K] { + const el = document.createElementNS(SVG, tag); + for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v)); + parent?.append(el); + return el; +} +function h(tag: K, attrs: Attrs = {}, ...kids: (Node | string)[]): HTMLElementTagNameMap[K] { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v)); + el.append(...kids); + return el; +} +function text(parent: Element, x: number, y: number, str: string, cls: string, anchor = "middle") { + const t = s("text", { x, y, class: cls, "text-anchor": anchor }, parent); + t.textContent = str; + return t; +} + +/** Shared motif colours are tuned for dark pages; on the chart they are mixed toward the navy ink. */ +function inkOf(m: Motif): string { + const hex = (c: string) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)); + const a = hex(m.color), b = hex("#1e2c3b"); + const k = m.id === "odysseus" ? 0.55 : 0.28; + return `rgb(${a.map((v, i) => Math.round(v * (1 - k) + b[i]! * k)).join(",")})`; +} +const motifById = new Map(motifs.map((m) => [m.id, m])); +const has = (song: Song, id: string) => song.parts.some((p) => p.motifIds.includes(id)); + +/* ------------------------------------------------------------------ shell */ +const app = document.getElementById("app")!; +const titleBar = h("header", { class: "bar" }); +if (overlay) { + titleBar.append( + h("p", { class: "bar__here" }, h("span", { class: "bar__pin", "aria-hidden": "true" }), "You are here: ", h("strong", {}, songs[HERE]!.title)), + h("p", { class: "bar__hint" }, "Pick a port to set a course. Scrub the log to look ahead without sailing."), + ); +} else { + titleBar.append( + h("h1", { class: "bar__title" }, "EPIC, charted"), + h("p", { class: "bar__hint" }, "The chart shows where. The log below shows when. Run a finger along the log and the ship follows."), + ); +} +const chartWrap = h("section", { class: "chart", "aria-label": "Sea chart of the voyage" }); +const logWrap = h("section", { class: "log", "aria-label": "Log line in story time" }); +const panel = h("aside", { class: "panel", "aria-live": "polite", hidden: "" }); +const pop = h("div", { class: "pop", hidden: "" }); +chartWrap.append(panel, pop); +app.append(titleBar, chartWrap, logWrap); + +/* ================================================================== CHART */ +const svg = s("svg", { class: "chart__svg", role: "img", "aria-label": "Stylised chart: Troy, the land of the Cicones, the island of the Cyclopes and the open sea" }, chartWrap); +const defs = s("defs", {}, svg); +const hatch = s("pattern", { id: "hatch", width: 7, height: 7, patternUnits: "userSpaceOnUse", patternTransform: "rotate(35)" }, defs); +s("line", { x1: 0, y1: 0, x2: 0, y2: 7, class: "hatch" }, hatch); + +// Sea, graticule, soundings +s("rect", { x: -400, y: -300, width: W + 800, height: H + 600, class: "sea" }, svg); +const grat = s("g", { class: "grat" }, svg); +for (let x = 0; x <= W; x += 200) s("line", { x1: x, y1: -300, x2: x, y2: H + 300 }, grat); +for (let y = 0; y <= H; y += 200) s("line", { x1: -400, y1: y, x2: W + 400, y2: y }, grat); +const sounds = s("g", { class: "sounds" }, svg); + +// Land: shallow-water bands, then the land fill and hachure +const landG = s("g", {}, svg); +const landPaths: SVGPathElement[] = []; +for (const [w, cls] of [[30, "shoal-1"], [16, "shoal-2"], [6, "shoal-3"]] as const) { + for (const d of Object.values(land)) s("path", { d, class: cls, "stroke-width": w }, landG); +} +for (const d of Object.values(land)) { + landPaths.push(s("path", { d, class: "land" }, landG)); + s("path", { d, class: "land-hatch" }, landG); +} + +// Names: land upright, sea areas in italic (chart convention) +const names = s("g", { class: "names" }, svg); +text(names, 70, 250, "Troy", "place place--big"); +text(names, 70, 274, "the burning city", "place-sub"); +text(names, 486, 34, "Land of the Cicones", "place"); +text(names, 918, 336, "Island of the", "place"); +text(names, 918, 358, "Cyclopes", "place"); +text(names, 1100, 150, "Goat Island", "place place--small"); +text(names, 420, 548, "The Troy Saga", "sea-area"); +text(names, 690, 470, "The Cyclops Saga", "sea-area"); +text(names, 1290, 200, "The Ocean Saga", "sea-area"); +text(names, 1290, 226, "open water, no landfall", "place-sub"); + +// Compass rose +const rose = s("g", { class: "rose", transform: "translate(1500 92)" }, svg); +s("circle", { r: 46, class: "rose__ring" }, rose); +s("circle", { r: 38, class: "rose__ring rose__ring--in" }, rose); +for (let a = 0; a < 360; a += 10) { + const r1 = a % 90 === 0 ? 30 : 38, rad = (a * Math.PI) / 180; + s("line", { x1: Math.sin(rad) * r1, y1: -Math.cos(rad) * r1, x2: Math.sin(rad) * 46, y2: -Math.cos(rad) * 46, class: "rose__tick" }, rose); +} +s("path", { d: "M0,-44 L7,0 L0,44 L-7,0Z M-44,0 L0,7 L44,0 L0,-7Z", class: "rose__star" }, rose); +s("path", { d: "M0,-44 L7,0 L-7,0Z", class: "rose__north" }, rose); +text(rose, 0, -54, "N", "rose__n"); + +// Route: one leg per pair of consecutive songs +const route = buildRoute(); +const routeG = s("g", { class: "route" }, svg); +const motifG = s("g", { class: "motif-route" }, svg); +const legs = route.map((seg) => { + const base = s("path", { d: seg.d, class: "leg leg--plan" }, routeG); + const sailed = s("path", { d: seg.d, class: "leg leg--sailed" }, routeG); + return { seg, base, sailed, len: 0 }; +}); +for (const l of legs) { + l.len = l.base.getTotalLength(); + if (l.seg.years > 0) { + const p = l.base.getPointAtLength(l.len * 0.5), q = l.base.getPointAtLength(l.len * 0.5 + 4); + let a = Math.atan2(q.y - p.y, q.x - p.x); + if (Math.cos(a) < 0) a += Math.PI; // keep text upright + const nx = Math.sin(a) * 16, ny = -Math.cos(a) * 16; // normal, on the upper side of the text + const lab = text(routeG, p.x + nx, p.y + ny, yearsLabel(l.seg.years), "crossing"); + lab.setAttribute("transform", `rotate(${((a * 180) / Math.PI).toFixed(1)} ${p.x + nx} ${p.y + ny})`); + } +} + +// Soundings: deterministic, kept off land and off the course +{ + const r = rng(8); + let placed = 0; + for (let k = 0; k < 600 && placed < 70; k++) { + const x = r() * W, y = r() * H; + const pt = new DOMPoint(x, y); + if (landPaths.some((p) => p.isPointInFill(pt) || p.isPointInStroke(pt))) continue; + const near = legs.some((l) => { for (let t = 0; t <= 1; t += 0.05) { const q = l.base.getPointAtLength(l.len * t); if (Math.hypot(q.x - x, q.y - y) < 34) return true; } return false; }); + if (near) continue; + const ns = Object.values(ports).some((g) => Math.hypot(g.at[0] - x, g.at[1] - y) < 90); + if (ns) continue; + text(sounds, x, y, String(Math.round(8 + r() * 60 * (x / W + 0.3))), "sound"); + placed++; + } +} + +// Ports +const portG = s("g", { class: "ports" }, svg); +const portEls = songs.map((song, i) => { + const g = ports[song.id]!; + const el = s("g", { class: "port", transform: `translate(${g.at[0]} ${g.at[1]})`, tabindex: 0, role: "button", "aria-label": `${song.track}. ${song.title}` }, portG); + s("circle", { r: 22, class: "port__ring" }, el); + s("circle", { r: 12, class: "port__dot" }, el); + text(el, 0, 4, String(song.track), "port__num"); + const [lx, ly, anchor] = g.label; + text(el, lx, ly, song.title, "port__label", anchor); + el.addEventListener("click", (e) => { e.stopPropagation(); onPort(i); }); + el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPort(i); } }); + return el; +}); + +// Ship and probe +const probe = s("circle", { r: 26, class: "probe", visibility: "hidden" }, svg); +const ship = s("g", { class: "ship" }, svg); +s("path", { d: "M-16,0 L-10,-7 L12,-7 L18,0 L12,7 L-10,7Z", class: "ship__hull" }, ship); +s("path", { d: "M-6,-7 L2,-24 L6,-7Z", class: "ship__sail" }, ship); + +function pointAt(f: number): { x: number; y: number; a: number } { + const i = Math.min(Math.floor(f), legs.length - 1); + if (f >= songs.length - 1) { const g = ports[songs[songs.length - 1]!.id]!; const l = legs[legs.length - 1]!; const q = l.base.getPointAtLength(l.len - 1); return { x: g.at[0], y: g.at[1], a: Math.atan2(g.at[1] - q.y, g.at[0] - q.x) }; } + const l = legs[i]!, t = f - i; + const p = l.base.getPointAtLength(l.len * t), q = l.base.getPointAtLength(Math.min(l.len, l.len * t + 2)); + return { x: p.x, y: p.y, a: Math.atan2(q.y - p.y, q.x - p.x) }; +} + +/** The ship rides beside the course, on the side away from each port's label. */ +const shipOff = songs.map((x) => { + const [lx, ly] = ports[x.id]!.label; + const n = Math.hypot(lx, ly) || 1; + return [(-lx / n) * 30, (-ly / n) * 30 - 6] as const; +}); +function drawShip() { + const p = pointAt(shipAt); + const i = Math.min(Math.floor(shipAt), songs.length - 1), t = shipAt - i; + const a = shipOff[i]!, b = shipOff[Math.min(i + 1, songs.length - 1)]!; + const ox = a[0] + (b[0] - a[0]) * t, oy = a[1] + (b[1] - a[1]) * t; + ship.setAttribute("transform", `translate(${(p.x + ox).toFixed(1)} ${(p.y + oy).toFixed(1)})${Math.cos(p.a) < 0 ? " scale(-1 1)" : ""}`); + legs.forEach((l, i) => { + const done = shipAt >= i + 1 ? 1 : shipAt <= i ? 0 : shipAt - i; + l.sailed.setAttribute("stroke-dasharray", `${l.len * done} ${l.len + 10}`); + }); + if (probeAt === null) probe.setAttribute("visibility", "hidden"); + else { const q = pointAt(probeAt); probe.setAttribute("cx", String(q.x)); probe.setAttribute("cy", String(q.y)); probe.setAttribute("visibility", "visible"); } +} + +/* ------------------------------------------------------------------ view (animated viewBox) */ +const view = { cx: W / 2, cy: H / 2, z: 1 }; +let target = { ...view }; +let raf = 0; +function applyView() { + const cw = svg.clientWidth || 1, ch = svg.clientHeight || 1; + const scale = Math.min(cw / W, ch / H) * view.z; + const vw = cw / scale, vh = ch / scale; + svg.setAttribute("viewBox", `${(view.cx - vw / 2).toFixed(1)} ${(view.cy - vh / 2).toFixed(1)} ${vw.toFixed(1)} ${vh.toFixed(1)}`); + svg.style.setProperty("--z", String(view.z)); +} +function tick() { + const k = 0.16; + view.cx += (target.cx - view.cx) * k; + view.cy += (target.cy - view.cy) * k; + view.z += (target.z - view.z) * k; + applyView(); + if (Math.abs(target.cx - view.cx) + Math.abs(target.cy - view.cy) + Math.abs(target.z - view.z) * 100 > 0.3) raf = requestAnimationFrame(tick); + else { Object.assign(view, target); applyView(); raf = 0; } +} +function goTo(t: { cx: number; cy: number; z: number }, instant = false) { + target = t; + if (instant || matchMedia("(prefers-reduced-motion: reduce)").matches) { Object.assign(view, t); applyView(); return; } + if (!raf) raf = requestAnimationFrame(tick); +} +const fullView = () => ({ cx: W / 2, cy: H / 2, z: 1 }); +/** Centre on a world point, shifted so it lands at `frac` of the chart width. */ +function focusOn(x: number, y: number, z: number, frac = 0.5) { + const cw = svg.clientWidth || 1, ch = svg.clientHeight || 1; + const vw = cw / (Math.min(cw / W, ch / H) * z); + return { cx: x + (0.5 - frac) * vw, cy: y, z }; +} +function restView() { + if (selected >= 0 && !overlay) { const g = ports[songs[selected]!.id]!; return focusOn(g.at[0], g.at[1], 1.55, 0.36); } + return fullView(); +} +new ResizeObserver(() => applyView()).observe(svg); + +/* ================================================================== LOG LINE */ +const AXIS_Y = 92, LANE_Y = 140, LANE = 16; +const logTrack = h("div", { class: "log__track" }); +const legend = h("div", { class: "log__legend" }); +const logBody = h("div", { class: "log__body", style: `width:${LOG_W}px` }); +logTrack.append(legend, logBody); +logWrap.append(logTrack); +const logSvg = s("svg", { class: "log__svg", width: LOG_W, height: LANE_Y + motifs.length * LANE }, logBody); + +// Saga brackets +for (const saga of sagas) { + const idx = songs.map((x, i) => (x.sagaId === saga.id ? i : -1)).filter((i) => i >= 0); + if (!idx.length) continue; + const x0 = logX[idx[0]!]! - 62, x1 = logX[idx[idx.length - 1]!]! + 62; + s("path", { d: `M${x0},24 V18 H${x1} V24`, class: "bracket" }, logSvg); + const lab = text(logSvg, x0 + 4, 12, saga.name, "bracket__name", "start"); + lab.setAttribute("dy", "0"); +} + +// Axis: straight within a year, a long swell across the years at sea +for (let i = 0; i < songs.length - 1; i++) { + const a = logX[i]!, b = logX[i + 1]!, gap = route[i]!.years; + if (gap <= 0) { s("line", { x1: a, y1: AXIS_Y, x2: b, y2: AXIS_Y, class: "axis" }, logSvg); continue; } + const g0 = a + LOG.hop / 2, g1 = b - LOG.hop / 2; + let d = `M${a},${AXIS_Y} H${g0}`; + const waves = Math.round((g1 - g0) / 30); + for (let k = 0; k < waves; k++) { const x = g0 + ((g1 - g0) * (k + 0.5)) / waves, x2 = g0 + ((g1 - g0) * (k + 1)) / waves; d += ` Q${x},${AXIS_Y + (k % 2 ? 5 : -5)} ${x2},${AXIS_Y}`; } + d += ` H${b}`; + s("path", { d, class: "axis axis--sea" }, logSvg); + text(logSvg, (g0 + g1) / 2, AXIS_Y - 14, yearsLabel(gap), "gap-label"); + for (let k = 1; k <= gap; k++) { + const x = g0 + ((g1 - g0) * k) / gap; + s("line", { x1: x, y1: 30, x2: x, y2: AXIS_Y + 6, class: "year-tick" }, logSvg); + text(logSvg, x - 4, 38, `year ${songs[i]!.storyTime.yearsAfterTroy + k}`, "year-label", "end"); + } +} +s("line", { x1: logX[0]! - 50, y1: AXIS_Y, x2: logX[0]!, y2: AXIS_Y, class: "axis" }, logSvg); +s("line", { x1: logX[logX.length - 1]!, y1: AXIS_Y, x2: LOG_W - 20, y2: AXIS_Y, class: "axis axis--fade" }, logSvg); +text(logSvg, logX[0]! - 56, 38, "year 0", "year-label", "start"); + +// Motif lanes with part boxes +const laneEls = new Map(); +motifs.forEach((m, mi) => { + const y = LANE_Y + mi * LANE; + const g = s("g", { class: "lane", "data-motif": m.id, style: `--c:${inkOf(m)}` }, logSvg); + laneEls.set(m.id, g); + const occ = songs.map((x, i) => (has(x, m.id) ? i : -1)).filter((i) => i >= 0); + if (occ.length) { + s("line", { x1: logX[occ[0]!]!, y1: y, x2: logX[occ[occ.length - 1]!]!, y2: y, class: "lane__case" }, g); + s("line", { x1: logX[occ[0]!]!, y1: y, x2: logX[occ[occ.length - 1]!]!, y2: y, class: "lane__line" }, g); + } + for (const i of occ) { + const parts = songs[i]!.parts, bw = 8, gap = 2, tot = parts.length * bw + (parts.length - 1) * gap; + parts.forEach((p, k) => { + s("rect", { x: logX[i]! - tot / 2 + k * (bw + gap), y: y - 4, width: bw, height: 8, class: p.motifIds.includes(m.id) ? "box box--on" : "box" }, g); + }); + } +}); + +// Song entries (HTML, so titles can wrap) +const entryEls = songs.map((song, i) => { + const b = h("button", { type: "button", class: "entry", style: `left:${logX[i]}px` }, + h("span", { class: "entry__num" }, String(song.track)), + h("span", { class: "entry__title" }, song.title), + ); + b.addEventListener("click", () => select(i, "log")); + logBody.append(b, h("span", { class: "entry__when", style: `left:${logX[i]}px` }, song.storyTime.label)); + s("circle", { cx: logX[i]!, cy: AXIS_Y, r: 4.5, class: "stop", "data-i": i }, logSvg); + return b; +}); + +// Scrub cursor +const cursor = h("div", { class: "cursor", hidden: "" }, h("span", { class: "cursor__read" })); +logBody.append(cursor); + +// Legend +legend.append(h("p", { class: "legend__head" }, "Motifs, sung where filled")); +const legendBtns = new Map(); +for (const m of motifs) { + const b = h("button", { type: "button", class: "legend__btn", style: `--c:${inkOf(m)}`, "aria-pressed": "false" }, + h("span", { class: "legend__sw", "aria-hidden": "true" }), m.name); + b.addEventListener("click", () => setMotif(motifId === m.id ? null : m.id)); + legend.append(b); + legendBtns.set(m.id, b); +} + +/* ------------------------------------------------------------------ scrubbing */ +let leaveTimer = 0; +let lastClientX: number | null = null; +function scrubAt(clientX: number) { + const r = logBody.getBoundingClientRect(); + const x = Math.max(logX[0]!, Math.min(logX[logX.length - 1]!, clientX - r.left)); + const f = xToIndex(x); + cursor.hidden = false; + cursor.style.left = `${x}px`; + const i = Math.round(f); + const atPort = Math.abs(f - i) < 0.12; + const yrs = atPort ? songs[i]!.storyTime.yearsAfterTroy : Math.floor(interpYears(f)); + (cursor.firstChild as HTMLElement).textContent = atPort ? `year ${yrs}, ${songs[i]!.title}` : `year ${yrs}, at sea`; + if (overlay) probeAt = f; else shipAt = f; + drawShip(); + const p = pointAt(f); + goTo(focusOn(p.x, p.y, 1.5, selected >= 0 && !overlay ? 0.36 : 0.5)); +} +function interpYears(f: number) { + const i = Math.floor(f), a = songs[i]!, b = songs[Math.min(i + 1, songs.length - 1)]!; + return a.storyTime.yearsAfterTroy + (b.storyTime.yearsAfterTroy - a.storyTime.yearsAfterTroy) * (f - i); +} +logBody.addEventListener("pointermove", (e) => { clearTimeout(leaveTimer); lastClientX = e.clientX; scrubAt(e.clientX); }); +logWrap.addEventListener("scroll", () => { if (lastClientX !== null) scrubAt(lastClientX); }, { passive: true }); +logBody.addEventListener("pointerleave", () => { + lastClientX = null; + leaveTimer = window.setTimeout(() => { + cursor.hidden = true; + probeAt = null; + drawShip(); + goTo(restView()); + }, 600); +}); + +/* ================================================================== SELECTION */ +function scrollLogTo(i: number, instant = false) { + const visible = logWrap.clientWidth - legend.offsetWidth; + const still = instant || matchMedia("(prefers-reduced-motion: reduce)").matches; + logWrap.scrollTo({ left: Math.max(0, logX[i]! - visible / 2), behavior: still ? "auto" : "smooth" }); +} + +function onPort(i: number) { + if (overlay) { showPop(i); scrollLogTo(i); return; } + select(i, "chart"); +} + +function select(i: number, from: "chart" | "log" | "init") { + selected = i; + if (overlay) { showPop(i); syncUrl(); return; } + shipAt = i; + drawShip(); + renderPanel(); + if (from !== "log") scrollLogTo(i, from === "init"); + goTo(restView(), from === "init"); + syncUrl(); + paint(); +} + +function close() { + selected = -1; + panel.hidden = true; + pop.hidden = true; + goTo(restView()); + syncUrl(); + paint(); +} +document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); }); +chartWrap.addEventListener("click", (e) => { if (e.target === svg || (e.target as Element).classList?.contains("sea")) { if (overlay) { pop.hidden = true; selected = -1; syncUrl(); paint(); } } }); + +function renderPanel() { + const song = songs[selected]; + if (!song) { panel.hidden = true; return; } + const closeBtn = h("button", { type: "button", class: "panel__close", "aria-label": "Close notes" }, "Close"); + closeBtn.addEventListener("click", close); + const odyssey = song.odyssey + ? h("a", { href: song.odyssey.url, target: "_blank", rel: "noopener noreferrer" }, `Odyssey, book ${song.odyssey.book}, lines ${song.odyssey.lines}`) + : h("span", { class: "panel__none" }, "No counterpart in Homer"); + const parts = h("ol", { class: "parts" }); + for (const p of song.parts) { + const chips = h("span", { class: "chips" }); + for (const id of p.motifIds) { + const m = motifById.get(id)!; + const c = h("button", { type: "button", class: `chip${id === motifId ? " is-on" : ""}`, style: `--c:${inkOf(m)}` }, m.name); + c.addEventListener("click", () => setMotif(motifId === id ? null : id)); + chips.append(c); + } + parts.append(h("li", {}, h("span", { class: "parts__name" }, p.name), h("span", { class: "parts__who" }, p.singers.join(" and ")), chips)); + } + panel.replaceChildren( + closeBtn, + h("p", { class: "panel__kicker" }, `${sagaOf(song).name}, track ${song.track}`), + h("h2", { class: "panel__title" }, song.title), + h("p", { class: "panel__when" }, `${song.storyTime.label}, year ${song.storyTime.yearsAfterTroy} after Troy`), + h("p", { class: "panel__ody" }, odyssey), + parts, + createAudioSlot(song), + createVideoCards(song), + ); + panel.hidden = false; +} + +/* ------------------------------------------------------------------ overlay popover */ +function showPop(i: number) { + selected = i; + const song = songs[i]!; + const g = ports[song.id]!; + const isHere = Math.round(shipAt) === i && Math.abs(shipAt - i) < 0.01; + const sail = h("button", { type: "button", class: "pop__sail" }, isHere ? "You are moored here" : "Sail here"); + if (isHere) sail.setAttribute("disabled", ""); + sail.addEventListener("click", () => sailTo(i)); + const yrs = song.storyTime.yearsAfterTroy - songs[Math.round(shipAt)]!.storyTime.yearsAfterTroy; + pop.replaceChildren( + h("p", { class: "pop__num" }, `Port ${song.track}`), + h("p", { class: "pop__title" }, song.title), + h("p", { class: "pop__when" }, song.storyTime.label + (yrs ? `, ${Math.abs(yrs)} ${Math.abs(yrs) === 1 ? "year" : "years"} ${yrs > 0 ? "ahead" : "back"}` : "")), + sail, + ); + pop.hidden = false; + placePop(g.at[0], g.at[1]); + syncUrl(); + paint(); +} +function placePop(x: number, y: number) { + const m = svg.getScreenCTM(); + if (!m) return; + const p = new DOMPoint(x, y).matrixTransform(m); + const r = chartWrap.getBoundingClientRect(); + pop.style.left = `${p.x - r.left}px`; + pop.style.top = `${p.y - r.top}px`; +} +function sailTo(i: number) { + const from = shipAt, t0 = performance.now(), dur = 900 + Math.abs(i - from) * 260; + pop.hidden = true; + const step = (now: number) => { + const t = Math.min(1, (now - t0) / dur), e = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2; + shipAt = from + (i - from) * e; + drawShip(); + if (t < 1) requestAnimationFrame(step); + else { const here = titleBar.querySelector("strong"); if (here) here.textContent = songs[i]!.title; selected = -1; paint(); syncUrl(); } + }; + requestAnimationFrame(step); +} + +/* ================================================================== MOTIF */ +function setMotif(id: string | null) { + motifId = id; + syncUrl(); + paint(); + if (selected >= 0 && !overlay) renderPanel(); +} + +function paint() { + document.body.classList.toggle("has-motif", !!motifId); + // Chart: course coloured where the motif carries on, dotted where it skips a port + motifG.replaceChildren(); + const m = motifId ? motifById.get(motifId) : undefined; + if (m) { + const occ = songs.map((x, i) => (has(x, m.id) ? i : -1)).filter((i) => i >= 0); + const c = inkOf(m); + for (let i = occ[0]!; i < occ[occ.length - 1]!; i++) { + const both = has(songs[i]!, m.id) && has(songs[i + 1]!, m.id); + s("path", { d: legs[i]!.seg.d, class: both ? "mleg" : "mleg mleg--skip", style: `--c:${c}` }, motifG); + } + } + portEls.forEach((el, i) => { + el.classList.toggle("is-motif", !!m && has(songs[i]!, m.id)); + el.classList.toggle("is-dim", !!m && !has(songs[i]!, m.id)); + el.classList.toggle("is-selected", i === selected); + if (m) el.style.setProperty("--c", inkOf(m)); else el.style.removeProperty("--c"); + }); + entryEls.forEach((el, i) => { + el.classList.toggle("is-selected", i === selected); + el.classList.toggle("is-dim", !!m && !has(songs[i]!, m.id)); + el.setAttribute("aria-pressed", String(i === selected)); + }); + for (const [id, g] of laneEls) g.classList.toggle("is-faded", !!m && id !== m.id); + for (const [id, b] of legendBtns) { b.setAttribute("aria-pressed", String(id === motifId)); b.classList.toggle("is-faded", !!m && id !== m.id); } +} + +function syncUrl() { + const p = new URLSearchParams(); + if (overlay) p.set("overlay", "1"); + if (selected >= 0) p.set("song", songs[selected]!.id); + if (motifId) p.set("motif", motifId); + const q = p.toString(); + history.replaceState(null, "", q ? `?${q}` : location.pathname); +} + +/* ------------------------------------------------------------------ boot */ +applyView(); +drawShip(); +paint(); +if (selected >= 0) requestAnimationFrame(() => select(selected, "init")); +if (overlay) requestAnimationFrame(() => scrollLogTo(selected >= 0 ? selected : HERE, true)); diff --git a/src/mockups/08-chart-logline/style.css b/src/mockups/08-chart-logline/style.css index 2e2a34c..19cc37a 100644 --- a/src/mockups/08-chart-logline/style.css +++ b/src/mockups/08-chart-logline/style.css @@ -1,2 +1,356 @@ -/* 08-chart-logline stub */ -body { margin: 0; } +/* 08 · Sea chart + log line: an Admiralty chart above, a ship's log in story time below. */ +@import "@fontsource/cormorant-garamond/latin-400.css"; +@import "@fontsource/cormorant-garamond/latin-500.css"; +@import "@fontsource/cormorant-garamond/latin-600.css"; +@import "@fontsource/cormorant-garamond/latin-400-italic.css"; +@import "@fontsource/cormorant-garamond/latin-500-italic.css"; +@import "@fontsource/inter/400.css"; +@import "@fontsource/inter/500.css"; + +:root { + color-scheme: light; + --water: #dfe9e6; + --shoal: #c9dcd8; + --shoal-deep: #b5cfcc; + --land: #e8d8a8; + --ink: #1e2c3b; + --ink-soft: #4d5b69; + --ink-faint: #8794a0; + --course: #a3195b; + --paper: #fbfbf7; + --serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif; + --sans: "Inter", system-ui, sans-serif; + --log-h: 268px; +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + display: flex; + flex-direction: column; + background: var(--paper); + color: var(--ink); + font-family: var(--serif); + overflow: hidden; +} +button { font: inherit; color: inherit; } +:focus-visible { outline: 2px solid var(--course); outline-offset: 2px; } + +#app { + flex: 1; + min-height: 0; + display: grid; + grid-template-rows: 56px minmax(0, 1fr) var(--log-h); +} + +/* ------------------------------------------------------------------ title bar */ +.bar { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 2rem; + padding: 0 28px; + border-bottom: 1.5px solid var(--ink); + background: var(--paper); +} +.bar__title { margin: 0; align-self: center; font-weight: 500; font-size: 28px; letter-spacing: 0.01em; } +.bar__hint { margin: 0; align-self: center; font-style: italic; font-size: 17px; color: var(--ink-soft); } + +/* ------------------------------------------------------------------ chart */ +.chart { position: relative; overflow: hidden; background: var(--water); } +.chart__svg { display: block; width: 100%; height: 100%; } +.sea { fill: var(--water); } +.grat line { stroke: var(--ink); stroke-opacity: 0.08; stroke-width: 1; vector-effect: non-scaling-stroke; } +.sound { font: italic 400 11px var(--serif); fill: var(--ink-faint); text-anchor: middle; } +.hatch { stroke: #b49a5c; stroke-width: 1.2; } +.shoal-1, .shoal-2, .shoal-3 { fill: none; stroke-linejoin: round; } +.shoal-1 { stroke: var(--shoal); } +.shoal-2 { stroke: var(--shoal-deep); } +.shoal-3 { stroke: #9fbfbd; } +.land { fill: var(--land); stroke: var(--ink); stroke-width: 1.3; } +.land-hatch { fill: url(#hatch); opacity: 0.35; pointer-events: none; } + +.names text { pointer-events: none; } +.place { font: 500 17px var(--serif); fill: var(--ink); letter-spacing: 0.06em; } +.place--big { font-size: 30px; letter-spacing: 0.14em; } +.place--small { font-size: 13px; } +.place-sub { font: italic 400 13px var(--serif); fill: var(--ink-soft); } +.sea-area { font: italic 400 30px var(--serif); fill: #3e6378; letter-spacing: 0.12em; } + +.rose__ring { fill: none; stroke: var(--course); stroke-width: 1; } +.rose__ring--in { stroke-opacity: 0.5; } +.rose__tick { stroke: var(--course); stroke-width: 0.8; } +.rose__star { fill: var(--paper); stroke: var(--course); stroke-width: 1; } +.rose__north { fill: var(--course); } +.rose__n { font: 600 16px var(--serif); fill: var(--course); } + +.leg { fill: none; stroke-linecap: round; } +.leg--plan { stroke: var(--course); stroke-width: 2; stroke-dasharray: 9 7; opacity: 0.55; } +.leg--sailed { stroke: var(--course); stroke-width: 3; } +.crossing { font: italic 500 17px var(--serif); fill: var(--course); } + +.mleg { fill: none; stroke: var(--c); stroke-width: 9; stroke-linecap: round; opacity: 0.85; } +.mleg--skip { stroke-width: 4; stroke-dasharray: 1 9; opacity: 1; } +.has-motif .leg--sailed, .has-motif .leg--plan { opacity: 0.18; } +.has-motif .crossing { opacity: 0.45; } + +.port { cursor: pointer; outline: none; } +.port__ring { fill: none; stroke: transparent; stroke-width: 3; } +.port__dot { fill: var(--paper); stroke: var(--ink); stroke-width: 2; } +.port__num { font: 500 12px var(--sans); fill: var(--ink); text-anchor: middle; pointer-events: none; } +.port__label { + font: 600 19px var(--serif); + fill: var(--ink); + paint-order: stroke; + stroke: var(--water); + stroke-width: 5px; + stroke-linejoin: round; +} +.port:hover .port__dot, .port:focus-visible .port__dot { fill: #fff3d6; } +.port:focus-visible .port__ring { stroke: var(--course); stroke-dasharray: 3 3; } +.port.is-selected .port__dot { fill: var(--course); stroke: var(--course); } +.port.is-selected .port__num { fill: var(--paper); } +.port.is-motif .port__ring { stroke: var(--c); } +.port.is-dim { opacity: 0.38; } + +.ship__hull { fill: var(--ink); } +.ship__sail { fill: var(--paper); stroke: var(--ink); stroke-width: 1.4; } +.probe { fill: none; stroke: var(--course); stroke-width: 2.5; stroke-dasharray: 4 4; } + +/* ------------------------------------------------------------------ detail panel */ +.panel { + position: absolute; + top: 16px; + right: 16px; + bottom: 16px; + width: 380px; + overflow: auto; + padding: 22px 24px 24px; + background: var(--paper); + border: 1.5px solid var(--ink); + box-shadow: 6px 6px 0 rgb(30 44 59 / 0.12); + font-family: var(--serif); +} +.panel[hidden] { display: none; } +.panel__close { + float: right; + padding: 4px 10px; + border: 1px solid var(--ink-faint); + background: none; + font: 400 12px var(--sans); + cursor: pointer; +} +.panel__kicker { margin: 0; font-style: italic; font-size: 16px; color: var(--ink-soft); } +.panel__title { margin: 4px 0 6px; font-weight: 500; font-size: 36px; line-height: 1.05; } +.panel__when { margin: 0 0 4px; font-size: 18px; } +.panel__ody { margin: 0 0 16px; font-size: 16px; } +.panel__ody a { color: var(--course); } +.panel__none { color: var(--ink-faint); font-style: italic; } +.parts { list-style: none; margin: 0 0 16px; padding: 0; border-top: 1px solid rgb(30 44 59 / 0.2); } +.parts li { display: grid; grid-template-columns: 70px 1fr; gap: 2px 10px; padding: 8px 0; border-bottom: 1px solid rgb(30 44 59 / 0.2); } +.parts__name { font: 500 12px var(--sans); color: var(--ink-soft); padding-top: 3px; } +.parts__who { font-size: 18px; } +.chips { grid-column: 2; display: flex; flex-wrap: wrap; gap: 4px; } +.chip { + padding: 1px 8px 2px; + border: 1px solid var(--c); + border-radius: 10px; + background: none; + color: var(--c); + font: 400 12px var(--sans); + cursor: pointer; +} +.chip.is-on { background: var(--c); color: var(--paper); } +.panel .audio-slot { margin-bottom: 12px; font: 400 13px var(--sans); color: var(--ink-soft); border-radius: 0; } +.panel .video-card { border-radius: 0; font: 400 13px var(--sans); color: var(--ink); } + +/* ------------------------------------------------------------------ log line */ +.log { + overflow-x: auto; + overflow-y: hidden; + background: var(--paper); + border-top: 1.5px solid var(--ink); + scrollbar-width: thin; + scrollbar-color: var(--ink-faint) transparent; +} +.log__track { display: flex; width: max-content; height: 100%; } +.log__legend { + position: sticky; + left: 0; + z-index: 3; + flex: 0 0 200px; + padding: 0 14px 0 24px; + padding-top: 109px; + background: linear-gradient(90deg, var(--paper) 88%, rgb(251 251 247 / 0)); +} +.legend__head { margin: 0 0 7px; font: italic 400 14px var(--serif); color: var(--ink-soft); } +.legend__btn { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 16px; + padding: 0; + border: 0; + background: none; + font: 400 11.5px var(--sans); + color: var(--ink); + text-align: left; + cursor: pointer; + white-space: nowrap; +} +.legend__sw { width: 22px; height: 3px; background: var(--c); box-shadow: 0 0 0 1px var(--ink); flex: none; } +.legend__btn[aria-pressed="true"] { font-weight: 500; color: var(--c); } +.legend__btn[aria-pressed="true"] .legend__sw { height: 6px; } +.legend__btn.is-faded { opacity: 0.4; } +.legend__btn:hover { color: var(--c); } + +.log__body { position: relative; flex: none; height: 100%; cursor: ew-resize; } +.log__svg { position: absolute; top: 0; left: 0; overflow: visible; } +.bracket { fill: none; stroke: var(--ink); stroke-width: 1; } +.bracket__name { font: italic 500 15px var(--serif); fill: var(--ink); } +.axis { fill: none; stroke: var(--ink); stroke-width: 1.5; } +.axis--sea { stroke: var(--course); stroke-width: 1.5; } +.axis--fade { stroke-dasharray: 2 5; } +.gap-label { font: italic 500 17px var(--serif); fill: var(--course); text-anchor: middle; } +.year-tick { stroke: var(--course); stroke-width: 1; stroke-dasharray: 2 3; stroke-opacity: 0.6; } +.year-label { font: 500 11px var(--sans); fill: var(--course); } +.stop { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; } + +.lane__case { stroke: var(--ink); stroke-width: 4; stroke-opacity: 0.7; } +.lane__line { stroke: var(--c); stroke-width: 2.4; } +.box { fill: var(--paper); stroke: var(--ink); stroke-width: 1; } +.box--on { fill: var(--c); } +.lane { transition: opacity 0.2s; } +.lane.is-faded { opacity: 0.14; } + +.entry { + position: absolute; + bottom: calc(100% - 84px); + transform: translateX(-50%); + width: 140px; + padding: 0; + border: 0; + background: none; + text-align: center; + cursor: pointer; + line-height: 1; +} +.entry__num { display: block; font: 400 10.5px var(--sans); color: var(--ink-faint); margin-bottom: 3px; } +.entry__title { font-weight: 600; font-size: 18px; line-height: 1.02; } +.entry:hover .entry__title { color: var(--course); } +.entry.is-selected .entry__title { color: var(--course); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; } +.entry.is-dim { opacity: 0.35; } +.entry__when { + position: absolute; + top: 101px; + transform: translateX(-50%); + width: 140px; + font-style: italic; + font-size: 13px; + line-height: 1.05; + color: var(--ink-soft); + text-align: center; + pointer-events: none; +} + +.cursor { position: absolute; top: 26px; bottom: 6px; width: 0; border-left: 1.5px solid var(--course); pointer-events: none; z-index: 2; } +.cursor[hidden] { display: none; } +.cursor__read { + position: absolute; + top: -2px; + left: 6px; + padding: 1px 6px; + background: var(--course); + color: var(--paper); + font: 500 11px var(--sans); + white-space: nowrap; +} + +/* ------------------------------------------------------------------ credits */ +.fan-credits { flex: none; padding: 10px 28px; border-top: 1px solid rgb(30 44 59 / 0.2); background: var(--paper); font-style: italic; font-size: 13.5px; opacity: 1; color: var(--ink-soft); text-align: left; } + +/* ================================================================== in-3D overlay state */ +body.is-overlay { + background: + radial-gradient(ellipse 70% 50% at 50% 115%, rgb(120 160 170 / 0.35), transparent 70%), + linear-gradient(180deg, #0f2231 0%, #183748 55%, #0d1c28 100%); + align-items: center; + justify-content: center; +} +body.is-overlay::before { + content: ""; + position: fixed; + inset: 0; + background: rgb(5 12 20 / 0.45); + pointer-events: none; +} +.is-overlay #app { + position: relative; + flex: none; + width: 1180px; + height: 740px; + margin-bottom: 40px; + grid-template-rows: 46px minmax(0, 1fr) var(--log-h); + border: 1.5px solid #e8d8a8; + box-shadow: 0 30px 80px rgb(0 0 0 / 0.55); +} +.is-overlay .bar { background: var(--ink); color: var(--paper); border-bottom: 0; } +.is-overlay .bar__here { margin: 0; align-self: center; font-size: 19px; display: flex; align-items: center; gap: 6px; } +.is-overlay .bar__here strong { font-weight: 600; color: #f3c9dd; } +.bar__pin { width: 9px; height: 9px; border-radius: 50%; background: #e5579a; box-shadow: 0 0 0 4px rgb(229 87 154 / 0.3); margin-right: 4px; } +.is-overlay .bar__hint { color: #b9c4cc; font-size: 15px; } +.is-overlay .fan-credits { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: rgb(5 12 20 / 0.6); + border-top: 1px solid rgb(232 216 168 / 0.25); + color: #e6e1d2; + text-align: center; + z-index: 10; +} + +.pop { + position: absolute; + z-index: 4; + transform: translate(30px, -50%); + min-width: 200px; + padding: 10px 14px 12px; + background: var(--paper); + border: 1.5px solid var(--ink); + box-shadow: 4px 4px 0 rgb(30 44 59 / 0.18); + text-align: center; +} +.pop[hidden] { display: none; } +.pop::after { + content: ""; + position: absolute; + left: -8px; + top: 50%; + width: 12px; + height: 12px; + background: var(--paper); + border-left: 1.5px solid var(--ink); + border-bottom: 1.5px solid var(--ink); + transform: translateY(-50%) rotate(45deg); +} +.pop p { margin: 0; } +.pop__num { font: 400 11px var(--sans); color: var(--ink-faint); } +.pop__title { font-weight: 600; font-size: 22px; } +.pop__when { font-style: italic; font-size: 14px; color: var(--ink-soft); margin-bottom: 8px !important; } +.pop__sail { + padding: 5px 16px; + border: 0; + background: var(--course); + color: var(--paper); + font: 500 13px var(--sans); + cursor: pointer; +} +.pop__sail:disabled { background: var(--ink-faint); cursor: default; } + +@media (prefers-reduced-motion: reduce) { + .lane { transition: none; } +} diff --git a/tests/server.test.ts b/tests/server.test.ts index 6050ae3..6d8e0ee 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -8,7 +8,7 @@ let base: string; beforeAll(() => { server = startServer(0); base = `http://localhost:${server.port}`; }); afterAll(() => server.stop(true)); -test("registry has 7 mockups", () => expect(mockups).toHaveLength(7)); +test("registry has 11 mockups", () => expect(mockups).toHaveLength(11)); test("index links every mockup", async () => { const res = await fetch(base + "/");