feat(mockups): 06 constellation (three.js)
Songs as stars along a time-proportional path through deep space, saga nebulae, motif constellation arcs, fly-through camera, WebGL fallback. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3bfd4ab456
commit
b7f423d08c
@@ -3,12 +3,30 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Constellation (3D) — EPIC Timeline mockup</title>
|
||||
<link rel="stylesheet" href="../../shared/credits.css" />
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
<main id="app" class="atlas">
|
||||
<div class="sky" id="sky">
|
||||
<div class="sky-labels" id="sky-labels"></div>
|
||||
</div>
|
||||
|
||||
<header class="masthead">
|
||||
<h1 class="masthead__title">EPIC, charted</h1>
|
||||
<p class="masthead__lede">Every song is a star on the voyage home. Every recurring theme is a constellation.</p>
|
||||
</header>
|
||||
|
||||
<nav class="legend" id="legend" aria-label="Motif constellations"></nav>
|
||||
|
||||
<section class="ruler" id="ruler" aria-label="Voyage timeline"></section>
|
||||
|
||||
<aside class="panel" id="panel" aria-label="Song details" hidden></aside>
|
||||
|
||||
<section class="fallback" id="fallback" hidden></section>
|
||||
</main>
|
||||
<footer data-fan-credits></footer>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Pure layout for the constellation: where each song-star sits along the voyage path.
|
||||
// Path distance is proportional to in-story time (YEAR units per year after Troy). Songs that share
|
||||
// a year are staggered by STEP along the path in track order so they stay distinct but clustered.
|
||||
import { songs, sagas } from "../../data/sample";
|
||||
import { motifsInSong } from "../../data/query";
|
||||
import type { Song } from "../../data/types";
|
||||
|
||||
export const YEAR = 64; // path units per in-story year
|
||||
export const STEP = 9; // stagger between songs in the same year
|
||||
export const START = 36; // path units before the first song (room for the intro flight)
|
||||
export const TAIL = 48; // path units after the last song
|
||||
|
||||
export interface StarSpec {
|
||||
song: Song;
|
||||
index: number;
|
||||
dist: number; // distance along the path
|
||||
lateral: [number, number]; // offset from the path in (normal, binormal) units
|
||||
magnitude: number; // 1..n = number of distinct motifs (brightness)
|
||||
}
|
||||
|
||||
export function buildStars(): StarSpec[] {
|
||||
const ordered = [...songs].sort((a, b) => a.track - b.track);
|
||||
const t0 = ordered[0]?.storyTime.yearsAfterTroy ?? 0;
|
||||
const seen = new Map<number, number>();
|
||||
return ordered.map((song, index) => {
|
||||
const y = song.storyTime.yearsAfterTroy;
|
||||
const k = seen.get(y) ?? 0;
|
||||
seen.set(y, k + 1);
|
||||
const angle = index * 2.399963; // golden-angle scatter around the path
|
||||
const r = 3.2 + (index % 3) * 1.6;
|
||||
return {
|
||||
song,
|
||||
index,
|
||||
dist: START + (y - t0) * YEAR + k * STEP,
|
||||
lateral: [Math.cos(angle) * r, Math.sin(angle) * r * 0.7],
|
||||
magnitude: motifsInSong(song).length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function pathLength(stars: StarSpec[]): number {
|
||||
const last = stars[stars.length - 1];
|
||||
return (last?.dist ?? 0) + TAIL;
|
||||
}
|
||||
|
||||
/** Integer in-story years spanned by the songs, with their path distance. */
|
||||
export function yearMarks(stars: StarSpec[]): { year: number; dist: number }[] {
|
||||
const first = stars[0];
|
||||
const last = stars[stars.length - 1];
|
||||
if (!first || !last) return [];
|
||||
const y0 = first.song.storyTime.yearsAfterTroy;
|
||||
const y1 = last.song.storyTime.yearsAfterTroy;
|
||||
const out: { year: number; dist: number }[] = [];
|
||||
for (let y = y0; y <= y1; y++) out.push({ year: y, dist: START + (y - y0) * YEAR });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Gaps of more than one year between consecutive songs, labelled in the scene. */
|
||||
export function longGaps(stars: StarSpec[]): { years: number; from: number; to: number }[] {
|
||||
const out: { years: number; from: number; to: number }[] = [];
|
||||
for (let i = 1; i < stars.length; i++) {
|
||||
const a = stars[i - 1]!;
|
||||
const b = stars[i]!;
|
||||
const years = b.song.storyTime.yearsAfterTroy - a.song.storyTime.yearsAfterTroy;
|
||||
if (years >= 1) out.push({ years, from: a.dist, to: b.dist });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sagaRanges(stars: StarSpec[]) {
|
||||
return [...sagas]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((saga) => {
|
||||
const own = stars.filter((s) => s.song.sagaId === saga.id);
|
||||
const from = Math.min(...own.map((s) => s.dist));
|
||||
const to = Math.max(...own.map((s) => s.dist));
|
||||
return { saga, from, to, stars: own };
|
||||
})
|
||||
.filter((r) => r.stars.length > 0);
|
||||
}
|
||||
@@ -1,5 +1,383 @@
|
||||
import { motifs } from "../../data/sample";
|
||||
import { motifsInSong, sagaOf, songsWithMotif } 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 { buildStars, longGaps, pathLength, yearMarks, type StarSpec } from "./layout";
|
||||
import { ConstellationScene, webglAvailable } from "./scene";
|
||||
|
||||
mountCredits();
|
||||
const app = document.getElementById("app")!;
|
||||
app.innerHTML = `<h1>Constellation (3D)</h1><p>stub</p>`;
|
||||
const credits = mountCredits();
|
||||
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const sky = $("sky");
|
||||
const labelLayer = $("sky-labels");
|
||||
const legend = $("legend");
|
||||
const ruler = $("ruler");
|
||||
const panel = $("panel");
|
||||
const fallback = $("fallback");
|
||||
|
||||
const stars = buildStars();
|
||||
const reducedMq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const narrowMq = window.matchMedia("(max-width: 760px)");
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
let openIdx: number | null = null;
|
||||
let motifId: string | null = null;
|
||||
let nearestIdx = 0;
|
||||
let scene: ConstellationScene | null = null;
|
||||
|
||||
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
const motifById = (id: string) => motifs.find((m) => m.id === id);
|
||||
const yearsText = (y: number) => (y === 0 ? "Year of the fall of Troy" : y === 1 ? "1 year after Troy" : `${y} years after Troy`);
|
||||
|
||||
// Keep panels clear of the credits bar, whatever its wrapped height.
|
||||
new ResizeObserver(() => {
|
||||
document.documentElement.style.setProperty("--credits-h", `${credits.offsetHeight}px`);
|
||||
}).observe(credits);
|
||||
|
||||
// ------------------------------------------------------------------ legend
|
||||
|
||||
function lineGlyph(m: Motif) {
|
||||
const dash = m.kind === "character" ? ` stroke-dasharray="4 3"` : "";
|
||||
return `<svg class="glyph" viewBox="0 0 36 12" aria-hidden="true"><path d="M2 9 Q18 -2 34 7" fill="none" stroke="${m.color}" stroke-width="1.6"${dash}/><circle cx="2" cy="9" r="1.8" fill="${m.color}"/><circle cx="34" cy="7" r="1.8" fill="${m.color}"/></svg>`;
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
const groups: [string, Motif[]][] = [
|
||||
["Musical motifs", motifs.filter((m) => m.kind === "motif")],
|
||||
["Character themes", motifs.filter((m) => m.kind === "character")],
|
||||
];
|
||||
const sel = motifId ? motifById(motifId) : undefined;
|
||||
legend.innerHTML = `
|
||||
<h2 class="legend__title">Constellations</h2>
|
||||
${groups.map(([name, list]) => `
|
||||
<h3 class="legend__group">${name}</h3>
|
||||
<ul class="legend__list">
|
||||
${list.map((m) => `<li><button type="button" class="legend__motif" data-motif="${m.id}" aria-pressed="${m.id === motifId}" style="--c:${m.color}">
|
||||
${lineGlyph(m)}<span class="legend__name">${esc(m.name)}</span><span class="legend__count">${songsWithMotif(m.id).length}</span>
|
||||
</button></li>`).join("")}
|
||||
</ul>`).join("")}
|
||||
${sel ? `<div class="legend__detail" style="--c:${sel.color}">
|
||||
<p>${esc(sel.description)}</p>
|
||||
<p class="legend__appears">Heard in ${songsWithMotif(sel.id).length} songs:</p>
|
||||
<ol class="legend__songs">${songsWithMotif(sel.id).map((s) => {
|
||||
const parts = s.parts.filter((p) => p.motifIds.includes(sel.id)).map((p) => p.name).join(", ");
|
||||
return `<li><button type="button" data-goto="${s.id}"><span class="legend__song">${esc(s.title)}</span><span class="legend__parts">${esc(parts)}</span></button></li>`;
|
||||
}).join("")}</ol>
|
||||
<button type="button" class="legend__clear" data-motif="">Show all constellations</button>
|
||||
</div>`
|
||||
: `<p class="legend__key">Choose a constellation to trace a theme through the voyage. Brighter stars carry more themes.</p>`}
|
||||
`;
|
||||
}
|
||||
|
||||
legend.addEventListener("click", (e) => {
|
||||
const t = (e.target as HTMLElement).closest<HTMLElement>("[data-motif],[data-goto]");
|
||||
if (!t) return;
|
||||
if (t.dataset.goto) {
|
||||
const i = stars.findIndex((s) => s.song.id === t.dataset.goto);
|
||||
if (i >= 0) openSong(i);
|
||||
return;
|
||||
}
|
||||
selectMotif(t.dataset.motif && t.dataset.motif !== motifId ? t.dataset.motif : null);
|
||||
});
|
||||
|
||||
function selectMotif(id: string | null) {
|
||||
motifId = id;
|
||||
renderLegend();
|
||||
scene?.setMotif(id);
|
||||
// label each lit star with the parts that carry the motif
|
||||
const m = id ? motifById(id) : undefined;
|
||||
(scene?.starLabels ?? []).forEach((el, i) => {
|
||||
const parts = m ? stars[i]!.song.parts.filter((p) => p.motifIds.includes(m.id)).map((p) => p.name) : [];
|
||||
el.querySelector(".sky-label__parts")!.textContent = parts.join(", ");
|
||||
el.style.setProperty("--c", m?.color ?? "");
|
||||
});
|
||||
for (const el of document.querySelectorAll<HTMLElement>(".catalogue__star")) {
|
||||
const s = stars.find((x) => x.song.id === el.dataset.song)!;
|
||||
const has = !!m && s.song.parts.some((p) => p.motifIds.includes(m.id));
|
||||
el.classList.toggle("is-lit", has);
|
||||
el.classList.toggle("is-dim", !!m && !has);
|
||||
el.style.setProperty("--c", m?.color ?? "");
|
||||
}
|
||||
syncPanelMotif();
|
||||
renderRuler();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ ruler (time scale)
|
||||
|
||||
function renderRuler() {
|
||||
const len = pathLength(stars);
|
||||
const first = stars[0]!.dist - 10;
|
||||
const pct = (d: number) => (((d - first) / (len - 6 - first)) * 100).toFixed(2);
|
||||
const m = motifId ? motifById(motifId) : undefined;
|
||||
ruler.innerHTML = `
|
||||
<div class="ruler__track">
|
||||
${yearMarks(stars).map((y) => `<span class="ruler__year" style="left:${pct(y.dist)}%">${y.year === 0 ? "Troy falls" : `Year ${y.year}`}</span>`).join("")}
|
||||
${longGaps(stars).filter((g) => g.years > 1).map((g) => `<span class="ruler__gap" style="left:${pct(g.from)}%;width:${(+pct(g.to) - +pct(g.from)).toFixed(2)}%">${g.years} years pass</span>`).join("")}
|
||||
${stars.map((s, i) => {
|
||||
const lit = !!m && s.song.parts.some((p) => p.motifIds.includes(m.id));
|
||||
return `<button type="button" class="ruler__star${i === openIdx ? " is-open" : ""}${lit ? " is-lit" : ""}${m && !lit ? " is-dim" : ""}" data-i="${i}" style="left:${pct(s.dist)}%;--c:${m?.color ?? ""}" aria-label="${esc(s.song.title)}, ${esc(s.song.storyTime.label)}"><span class="ruler__dot"></span></button>`;
|
||||
}).join("")}
|
||||
<span class="ruler__cursor" id="ruler-cursor"></span>
|
||||
</div>
|
||||
<p class="ruler__hint"><span>Scroll or drag to travel</span><span>Arrow keys hop star to star</span><span>Click a star to open its song</span></p>`;
|
||||
updateCursor(scene?.progress ?? stars[0]!.dist);
|
||||
}
|
||||
|
||||
function updateCursor(p: number) {
|
||||
const cur = document.getElementById("ruler-cursor");
|
||||
if (!cur) return;
|
||||
const len = pathLength(stars);
|
||||
const first = stars[0]!.dist - 10;
|
||||
cur.style.left = `${Math.max(0, Math.min(100, ((p + 4 - first) / (len - 6 - first)) * 100))}%`;
|
||||
}
|
||||
|
||||
// keyboard focus on a ruler star shows the same hover label as the pointer does
|
||||
ruler.addEventListener("focusin", (e) => {
|
||||
const b = (e.target as HTMLElement).closest<HTMLElement>("[data-i]");
|
||||
if (b) scene?.setHover(Number(b.dataset.i));
|
||||
});
|
||||
ruler.addEventListener("focusout", () => scene?.setHover(null));
|
||||
|
||||
ruler.addEventListener("click", (e) => {
|
||||
const b = (e.target as HTMLElement).closest<HTMLElement>("[data-i]");
|
||||
if (b) { openSong(Number(b.dataset.i)); return; }
|
||||
const track = ruler.querySelector(".ruler__track")!.getBoundingClientRect();
|
||||
if (e.clientY < track.top - 8 || e.clientY > track.bottom + 8) return;
|
||||
const len = pathLength(stars);
|
||||
const first = stars[0]!.dist - 10;
|
||||
const f = (e.clientX - track.left) / track.width;
|
||||
scene?.setProgress(first + f * (len - 6 - first) - 4);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ song panel
|
||||
|
||||
function partHtml(song: Song) {
|
||||
return song.parts.map((p) => {
|
||||
const chips = p.motifIds.map((id) => motifById(id)).filter((m): m is Motif => !!m)
|
||||
.map((m) => `<button type="button" class="chip${m.kind === "character" ? " chip--character" : ""}" data-chip="${m.id}" aria-pressed="${m.id === motifId}" style="--c:${m.color}">${esc(m.name)}</button>`).join("");
|
||||
return `<li class="part" data-motifs="${p.motifIds.join(" ")}">
|
||||
<div class="part__head"><span class="part__name">${esc(p.name)}</span><span class="part__singers">${esc(p.singers.join(" and "))}</span></div>
|
||||
<div class="part__lines">${p.lines.map((l) => `<p>${esc(l)}</p>`).join("")}</div>
|
||||
<div class="part__chips">${chips}</div>
|
||||
</li>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderPanel(i: number) {
|
||||
const s = stars[i]!;
|
||||
const song = s.song;
|
||||
const saga = sagaOf(song);
|
||||
const prev = stars[i - 1], next = stars[i + 1];
|
||||
const ref = song.odyssey
|
||||
? `<a href="${esc(song.odyssey.url)}" target="_blank" rel="noopener noreferrer">Book ${song.odyssey.book}, lines ${esc(song.odyssey.lines.replace("-", "–"))}</a>`
|
||||
: `<span class="panel__muted">No direct counterpart in Homer</span>`;
|
||||
panel.innerHTML = `
|
||||
<div class="panel__scroll">
|
||||
<header class="panel__head">
|
||||
<p class="panel__where">Star ${song.track} of ${stars.length}, in ${esc(saga.name)}</p>
|
||||
<h2 class="panel__title" id="panel-title">${esc(song.title)}</h2>
|
||||
<button type="button" class="panel__close" data-close aria-label="Close song">×</button>
|
||||
</header>
|
||||
<dl class="panel__meta">
|
||||
<div><dt>In the story</dt><dd>${esc(song.storyTime.label)}<small>${yearsText(song.storyTime.yearsAfterTroy)}</small></dd></div>
|
||||
<div><dt>In the Odyssey</dt><dd>${ref}</dd></div>
|
||||
<div><dt>Themes</dt><dd class="panel__themes">${motifsInSong(song).map((m) => `<span class="dot" style="--c:${m.color}" title="${esc(m.name)}"></span>`).join("")}<small>${motifsInSong(song).length} recurring</small></dd></div>
|
||||
</dl>
|
||||
<section class="panel__sec">
|
||||
<h3>Parts</h3>
|
||||
<ol class="parts">${partHtml(song)}</ol>
|
||||
</section>
|
||||
<section class="panel__sec panel__fact">
|
||||
<h3>A fact</h3>
|
||||
${song.facts.map((f) => `<p>${esc(f)}</p>`).join("")}
|
||||
</section>
|
||||
<section class="panel__sec">
|
||||
<h3>Listen</h3>
|
||||
<div data-slot="audio"></div>
|
||||
</section>
|
||||
<section class="panel__sec">
|
||||
<h3>Watch</h3>
|
||||
<div data-slot="video"></div>
|
||||
</section>
|
||||
</div>
|
||||
<nav class="panel__nav" aria-label="Neighbouring stars">
|
||||
<button type="button" data-step="${i - 1}" ${prev ? "" : "disabled"}>${prev ? `<small>Previous</small>${esc(prev.song.title)}` : "<small>Previous</small>Start of the voyage"}</button>
|
||||
<button type="button" data-step="${i + 1}" ${next ? "" : "disabled"}>${next ? `<small>Next</small>${esc(next.song.title)}` : "<small>Next</small>End of the chart"}</button>
|
||||
</nav>`;
|
||||
panel.setAttribute("aria-labelledby", "panel-title");
|
||||
panel.querySelector('[data-slot="audio"]')!.replaceWith(createAudioSlot(song));
|
||||
panel.querySelector('[data-slot="video"]')!.replaceWith(createVideoCards(song));
|
||||
syncPanelMotif();
|
||||
}
|
||||
|
||||
function syncPanelMotif() {
|
||||
for (const li of panel.querySelectorAll<HTMLElement>(".part")) {
|
||||
const has = !!motifId && li.dataset.motifs!.split(" ").includes(motifId);
|
||||
li.classList.toggle("is-lit", has);
|
||||
li.classList.toggle("is-dim", !!motifId && !has);
|
||||
li.style.setProperty("--c", motifId ? motifById(motifId)!.color : "");
|
||||
}
|
||||
for (const c of panel.querySelectorAll<HTMLElement>("[data-chip]")) c.setAttribute("aria-pressed", String(c.dataset.chip === motifId));
|
||||
}
|
||||
|
||||
panel.addEventListener("click", (e) => {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.closest("[data-close]")) { closeSong(); return; }
|
||||
const chip = t.closest<HTMLElement>("[data-chip]");
|
||||
if (chip) { selectMotif(chip.dataset.chip === motifId ? null : chip.dataset.chip!); return; }
|
||||
const step = t.closest<HTMLElement>("[data-step]");
|
||||
if (step) openSong(Number(step.dataset.step));
|
||||
});
|
||||
|
||||
function panelShift(): [number, number] {
|
||||
if (panel.hidden) return [0, 0];
|
||||
if (narrowMq.matches) return [0, panel.offsetHeight / 2];
|
||||
return [panel.offsetWidth / 2, 0];
|
||||
}
|
||||
|
||||
function openSong(i: number, instant = false) {
|
||||
if (!stars[i]) return;
|
||||
const wasOpen = openIdx !== null;
|
||||
openIdx = i;
|
||||
renderPanel(i);
|
||||
panel.hidden = false;
|
||||
document.body.classList.add("has-panel");
|
||||
scene?.setOpen(i);
|
||||
scene?.flyTo(i, true, instant);
|
||||
const [x, y] = panelShift();
|
||||
scene?.setViewShift(x, y, instant);
|
||||
renderRuler();
|
||||
highlightCatalogue();
|
||||
if (!wasOpen) (panel.querySelector(".panel__close") as HTMLElement | null)?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function closeSong() {
|
||||
if (openIdx === null) return;
|
||||
const i = openIdx;
|
||||
openIdx = null;
|
||||
panel.hidden = true;
|
||||
document.body.classList.remove("has-panel");
|
||||
scene?.setOpen(null);
|
||||
scene?.setViewShift(0, 0);
|
||||
scene?.flyTo(i, false);
|
||||
renderRuler();
|
||||
highlightCatalogue();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ keyboard
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("input, textarea, select")) return;
|
||||
const inUi = !!target.closest(".panel, .legend, .ruler");
|
||||
const cur = openIdx ?? nearestIdx;
|
||||
switch (e.key) {
|
||||
case "ArrowRight": case "ArrowDown": case "PageDown":
|
||||
if (inUi && e.key === "ArrowDown") return;
|
||||
e.preventDefault();
|
||||
openIdx !== null ? openSong(Math.min(stars.length - 1, cur + 1)) : scene?.flyTo(Math.min(stars.length - 1, cur + 1), false);
|
||||
break;
|
||||
case "ArrowLeft": case "ArrowUp": case "PageUp":
|
||||
if (inUi && e.key === "ArrowUp") return;
|
||||
e.preventDefault();
|
||||
openIdx !== null ? openSong(Math.max(0, cur - 1)) : scene?.flyTo(Math.max(0, cur - 1), false);
|
||||
break;
|
||||
case "Home": e.preventDefault(); scene?.flyTo(0, false); break;
|
||||
case "End": e.preventDefault(); scene?.flyTo(stars.length - 1, false); break;
|
||||
case "Enter":
|
||||
if (inUi) return;
|
||||
openSong(cur);
|
||||
break;
|
||||
case "Escape":
|
||||
if (openIdx !== null) closeSong();
|
||||
else if (motifId) selectMotif(null);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ WebGL fallback: a printed star catalogue
|
||||
|
||||
function highlightCatalogue() {
|
||||
for (const el of document.querySelectorAll<HTMLElement>(".catalogue__star")) {
|
||||
el.setAttribute("aria-current", String(stars[openIdx ?? -1]?.song.id === el.dataset.song));
|
||||
}
|
||||
}
|
||||
|
||||
function renderFallback(list: StarSpec[]) {
|
||||
document.body.classList.add("no-webgl");
|
||||
fallback.hidden = false;
|
||||
const years = [...new Set(list.map((s) => s.song.storyTime.yearsAfterTroy))];
|
||||
fallback.innerHTML = `
|
||||
<div class="fallback__note" role="status">
|
||||
<h2>The 3D star chart can’t start here</h2>
|
||||
<p>Your browser didn’t give this page WebGL, which the flight through the constellation needs. Turn on hardware acceleration in your browser settings, or open the page in another browser.</p>
|
||||
<p>Meanwhile, here is the same chart as a flat star catalogue. Every song and theme still works.</p>
|
||||
</div>
|
||||
<ol class="catalogue">${years.map((y) => `
|
||||
<li class="catalogue__year"><h3>${yearsText(y)}</h3>
|
||||
<ol>${list.filter((s) => s.song.storyTime.yearsAfterTroy === y).map((s) => `
|
||||
<li><button type="button" class="catalogue__star" data-song="${s.song.id}" data-i="${s.index}">
|
||||
<span class="catalogue__mag" style="--m:${s.magnitude}"></span>
|
||||
<span class="catalogue__num">${s.song.track}</span>
|
||||
<span class="catalogue__name">${esc(s.song.title)}</span>
|
||||
<span class="catalogue__saga">${esc(sagaOf(s.song).name)}</span>
|
||||
</button></li>`).join("")}
|
||||
</ol></li>`).join("")}
|
||||
</ol>`;
|
||||
fallback.addEventListener("click", (e) => {
|
||||
const b = (e.target as HTMLElement).closest<HTMLElement>("[data-i]");
|
||||
if (b) openSong(Number(b.dataset.i));
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ boot
|
||||
|
||||
const intro = !params.has("song") && !params.has("hover") && !params.has("star");
|
||||
if (webglAvailable()) {
|
||||
try {
|
||||
scene = new ConstellationScene(sky, labelLayer, stars, {
|
||||
onHover: () => {},
|
||||
onPick: (i) => openSong(i),
|
||||
onProgress: (p, nearest) => {
|
||||
nearestIdx = nearest;
|
||||
updateCursor(p);
|
||||
},
|
||||
}, { reducedMotion: reducedMq.matches, intro });
|
||||
// enrich star labels with the hover details
|
||||
scene.starLabels.forEach((el, i) => {
|
||||
const s = stars[i]!.song;
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "sky-label__meta";
|
||||
meta.innerHTML = `<span>${esc(s.storyTime.label)}</span><span>${esc(sagaOf(s).name)}</span><span class="sky-label__cta">Click to open</span>`;
|
||||
el.append(meta);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("WebGL scene failed, showing the catalogue instead", err);
|
||||
scene?.dispose();
|
||||
scene = null;
|
||||
}
|
||||
}
|
||||
if (!scene) renderFallback(stars);
|
||||
|
||||
renderLegend();
|
||||
renderRuler();
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (openIdx === null) return;
|
||||
const [x, y] = panelShift();
|
||||
scene?.setViewShift(x, y, true);
|
||||
});
|
||||
window.addEventListener("pagehide", () => { scene?.dispose(); scene = null; });
|
||||
|
||||
// Screenshot / demo states: ?star=<id> flies there, ?hover=<id> shows a hovered star,
|
||||
// ?motif=<id> lights a constellation, ?song=<id> opens a song.
|
||||
const findIdx = (id: string | null) => stars.findIndex((s) => s.song.id === id);
|
||||
const starAt = findIdx(params.get("star") ?? params.get("hover"));
|
||||
if (starAt >= 0) scene?.flyTo(starAt, false, true);
|
||||
const hoverAt = findIdx(params.get("hover"));
|
||||
if (hoverAt >= 0) scene?.setHover(hoverAt);
|
||||
const pm = params.get("motif");
|
||||
if (pm && motifById(pm)) selectMotif(pm);
|
||||
const songAt = findIdx(params.get("song"));
|
||||
if (songAt >= 0) openSong(songAt, true);
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
// three.js star chart: the voyage path through deep space, song-stars, saga nebulae and motif
|
||||
// constellation arcs. All DOM outside the canvas (panel, legend, ruler) lives in main.ts.
|
||||
import * as THREE from "three";
|
||||
import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
|
||||
import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
|
||||
import { LineMaterial } from "three/addons/lines/LineMaterial.js";
|
||||
import { motifs } from "../../data/sample";
|
||||
import type { Motif } from "../../data/types";
|
||||
import { longGaps, pathLength, sagaRanges, yearMarks, type StarSpec } from "./layout";
|
||||
|
||||
const BACK = 12; // camera trails the progress point by this much path distance
|
||||
const AHEAD = 12; // and looks this far ahead
|
||||
const HEIGHT = 8; // above the path
|
||||
const SIDE = 17; // and off to its side, so the voyage reads left to right like a timeline
|
||||
const PRE = 60; // curve extends this far before path distance 0
|
||||
|
||||
const SAGA_HUES: Record<string, [string, string]> = {
|
||||
troy: ["#c4893f", "#e7c27a"],
|
||||
cyclops: ["#7c2d5c", "#c0486f"],
|
||||
ocean: ["#25698a", "#47a9ac"],
|
||||
};
|
||||
const FALLBACK_HUES: [string, string] = ["#6a6fa8", "#a7acd8"];
|
||||
|
||||
export interface SceneCallbacks {
|
||||
onHover(index: number | null): void;
|
||||
onPick(index: number): void;
|
||||
onProgress(progress: number, nearest: number): void;
|
||||
}
|
||||
|
||||
/** A DOM label pinned to a world position. Lower rank wins when labels collide on screen. */
|
||||
export interface SceneLabel {
|
||||
el: HTMLElement; pos: THREE.Vector3; fade: number; rank: number; star: boolean;
|
||||
box?: { ox: number; oy: number; w: number; h: number };
|
||||
}
|
||||
|
||||
/** Returns null when WebGL is unavailable so the caller can render the fallback. */
|
||||
export function webglAvailable(): boolean {
|
||||
if (new URLSearchParams(location.search).has("nowebgl")) return false;
|
||||
try {
|
||||
const c = document.createElement("canvas");
|
||||
return !!(c.getContext("webgl2") ?? c.getContext("webgl"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Seeded RNG so the sky is the same on every load (and in every screenshot).
|
||||
function rng(seed: number) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const starVert = /* glsl */ `
|
||||
attribute float aSize; attribute vec3 aColor; attribute float aState; attribute vec4 aRing;
|
||||
uniform float uScale; uniform float uTime; uniform float uPixelRatio;
|
||||
varying vec3 vColor; varying float vState; varying vec4 vRing;
|
||||
void main() {
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_Position = projectionMatrix * mv;
|
||||
float tw = 1.0 + 0.07 * sin(uTime * 1.3 + position.x * 2.7 + position.z);
|
||||
float grow = aState > 1.5 ? 1.35 : 1.0;
|
||||
gl_PointSize = clamp(aSize * grow * tw * uScale / -mv.z, 3.0, 110.0 * uPixelRatio);
|
||||
vColor = aColor; vState = aState; vRing = aRing;
|
||||
}`;
|
||||
|
||||
const starFrag = /* glsl */ `
|
||||
varying vec3 vColor; varying float vState; varying vec4 vRing;
|
||||
void main() {
|
||||
vec2 p = gl_PointCoord * 2.0 - 1.0;
|
||||
float r = length(p);
|
||||
float core = smoothstep(0.13, 0.0, r);
|
||||
float halo = exp(-r * r * 9.0) * 0.6;
|
||||
float spikes = (exp(-abs(p.x) * 30.0) * exp(-abs(p.y) * 3.2) + exp(-abs(p.y) * 30.0) * exp(-abs(p.x) * 3.2)) * 0.5;
|
||||
float k = vState < 0.5 ? 0.22 : (vState < 1.5 ? 0.85 : 1.25);
|
||||
float a = (core + halo + spikes) * k;
|
||||
float ring = (1.0 - smoothstep(0.0, 0.035, abs(r - 0.6))) * vRing.a;
|
||||
a *= smoothstep(1.0, 0.8, r);
|
||||
vec3 c = mix(vColor, vec3(1.0), core * 0.85) * a + vRing.rgb * ring;
|
||||
gl_FragColor = vec4(c, clamp(a + ring, 0.0, 1.0));
|
||||
}`;
|
||||
|
||||
const dustVert = /* glsl */ `
|
||||
attribute float aSize; attribute vec3 aColor; attribute float aAlpha;
|
||||
uniform float uScale; uniform float uAttenuate; uniform float uPixelRatio;
|
||||
varying vec3 vColor; varying float vAlpha;
|
||||
void main() {
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_Position = projectionMatrix * mv;
|
||||
float s = uAttenuate > 0.5 ? aSize * uScale / -mv.z : aSize * uPixelRatio;
|
||||
gl_PointSize = clamp(s, 1.0, 220.0 * uPixelRatio);
|
||||
vColor = aColor; vAlpha = aAlpha;
|
||||
}`;
|
||||
|
||||
const dustFrag = /* glsl */ `
|
||||
uniform float uBoost;
|
||||
varying vec3 vColor; varying float vAlpha;
|
||||
void main() {
|
||||
vec2 p = gl_PointCoord * 2.0 - 1.0;
|
||||
float r2 = dot(p, p);
|
||||
if (r2 > 1.0) discard;
|
||||
float a = exp(-r2 * 4.0) * vAlpha * uBoost;
|
||||
gl_FragColor = vec4(vColor * a, a);
|
||||
}`;
|
||||
|
||||
export class ConstellationScene {
|
||||
readonly stars: StarSpec[];
|
||||
readonly starPos: THREE.Vector3[] = [];
|
||||
readonly starLabels: HTMLElement[] = [];
|
||||
|
||||
private renderer: THREE.WebGLRenderer;
|
||||
private scene = new THREE.Scene();
|
||||
private camera = new THREE.PerspectiveCamera(52, 1, 0.1, 2400);
|
||||
private curve: THREE.CatmullRomCurve3;
|
||||
private curveLen: number;
|
||||
private starGeo = new THREE.BufferGeometry();
|
||||
private starMat: THREE.ShaderMaterial;
|
||||
private sizeUniforms: { value: number }[] = [];
|
||||
private nebulae = new Map<string, THREE.ShaderMaterial>();
|
||||
private motifLines = new Map<string, { line: LineSegments2; mat: LineMaterial; motif: Motif }>();
|
||||
private labels: SceneLabel[] = [];
|
||||
private ac = new AbortController();
|
||||
private ro: ResizeObserver;
|
||||
private raf = 0;
|
||||
private clock = new THREE.Clock();
|
||||
private reduced: boolean;
|
||||
private dirty = true;
|
||||
|
||||
// camera rig state
|
||||
private p = 0;
|
||||
private pT = 0;
|
||||
private yaw = 0; private yawT = 0;
|
||||
private pitch = 0; private pitchT = 0;
|
||||
private focusW = 0; private focusT = 0;
|
||||
private focusPos = new THREE.Vector3();
|
||||
private shift = new THREE.Vector2(); private shiftT = new THREE.Vector2();
|
||||
private size = new THREE.Vector2(1, 1);
|
||||
private lastNearest = -1;
|
||||
|
||||
// interaction state
|
||||
private hover: number | null = null;
|
||||
private open: number | null = null;
|
||||
private motif: string | null = null;
|
||||
private pointer = { down: false, x: 0, y: 0, sx: 0, sy: 0, moved: false };
|
||||
|
||||
constructor(
|
||||
private host: HTMLElement,
|
||||
private labelLayer: HTMLElement,
|
||||
stars: StarSpec[],
|
||||
private cb: SceneCallbacks,
|
||||
opts: { reducedMotion: boolean; intro: boolean },
|
||||
) {
|
||||
this.stars = stars;
|
||||
this.reduced = opts.reducedMotion;
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: "high-performance" });
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
|
||||
this.renderer.setClearColor(0x050816, 1);
|
||||
this.renderer.domElement.className = "sky__canvas";
|
||||
this.renderer.domElement.setAttribute("aria-hidden", "true");
|
||||
host.prepend(this.renderer.domElement);
|
||||
this.scene.fog = new THREE.FogExp2(0x050816, 0.0016);
|
||||
|
||||
this.curve = this.buildCurve(pathLength(stars));
|
||||
this.curveLen = this.curve.getLength();
|
||||
stars.forEach((s) => this.starPos.push(this.starWorld(s)));
|
||||
|
||||
this.starMat = new THREE.ShaderMaterial({
|
||||
vertexShader: starVert,
|
||||
fragmentShader: starFrag,
|
||||
uniforms: { uScale: { value: 1 }, uTime: { value: 0 }, uPixelRatio: { value: this.renderer.getPixelRatio() } },
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this.sizeUniforms.push(this.starMat.uniforms.uScale!);
|
||||
|
||||
this.buildBackdrop();
|
||||
this.buildPath();
|
||||
this.buildNebulae();
|
||||
this.buildConstellations();
|
||||
this.buildStars();
|
||||
this.buildLabels();
|
||||
|
||||
this.ro = new ResizeObserver(() => this.resize());
|
||||
this.ro.observe(host);
|
||||
this.resize();
|
||||
this.bindInput();
|
||||
|
||||
const first = stars[0]?.dist ?? 0;
|
||||
this.pT = first - 4;
|
||||
if (opts.intro && !this.reduced) {
|
||||
this.p = this.pT - 34;
|
||||
this.pitch = 0.42;
|
||||
this.yaw = -0.5;
|
||||
} else {
|
||||
this.p = this.pT;
|
||||
}
|
||||
document.addEventListener("visibilitychange", () => (document.hidden ? this.stop() : this.start()), { signal: this.ac.signal });
|
||||
this.start();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- construction
|
||||
|
||||
private buildCurve(len: number) {
|
||||
const pts: THREE.Vector3[] = [];
|
||||
for (let d = -PRE; d <= len + 70; d += 22) {
|
||||
pts.push(new THREE.Vector3(
|
||||
Math.sin(d * 0.012) * 16 + Math.sin(d * 0.005 + 1.2) * 12,
|
||||
Math.sin(d * 0.011 + 0.7) * 8,
|
||||
-d,
|
||||
));
|
||||
}
|
||||
return new THREE.CatmullRomCurve3(pts, false, "centripetal");
|
||||
}
|
||||
|
||||
private u(d: number) {
|
||||
return THREE.MathUtils.clamp((d + PRE) / this.curveLen, 0, 1);
|
||||
}
|
||||
pathPoint(d: number, out = new THREE.Vector3()) {
|
||||
return this.curve.getPointAt(this.u(d), out);
|
||||
}
|
||||
private frame(d: number) {
|
||||
const t = this.curve.getTangentAt(this.u(d)).normalize();
|
||||
const n = new THREE.Vector3().crossVectors(t, new THREE.Vector3(0, 1, 0)).normalize();
|
||||
const b = new THREE.Vector3().crossVectors(n, t).normalize();
|
||||
return { t, n, b };
|
||||
}
|
||||
private starWorld(s: StarSpec) {
|
||||
const { n, b } = this.frame(s.dist);
|
||||
return this.pathPoint(s.dist).addScaledVector(n, s.lateral[0]).addScaledVector(b, s.lateral[1]);
|
||||
}
|
||||
|
||||
private dust(count: number, fill: (i: number, pos: Float32Array, col: Float32Array, size: Float32Array, alpha: Float32Array) => void, attenuate: boolean) {
|
||||
const pos = new Float32Array(count * 3);
|
||||
const col = new Float32Array(count * 3);
|
||||
const size = new Float32Array(count);
|
||||
const alpha = new Float32Array(count);
|
||||
for (let i = 0; i < count; i++) fill(i, pos, col, size, alpha);
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
|
||||
geo.setAttribute("aColor", new THREE.BufferAttribute(col, 3));
|
||||
geo.setAttribute("aSize", new THREE.BufferAttribute(size, 1));
|
||||
geo.setAttribute("aAlpha", new THREE.BufferAttribute(alpha, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: dustVert,
|
||||
fragmentShader: dustFrag,
|
||||
uniforms: {
|
||||
uScale: { value: 1 }, uAttenuate: { value: attenuate ? 1 : 0 },
|
||||
uPixelRatio: { value: this.renderer.getPixelRatio() }, uBoost: { value: 1 },
|
||||
},
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this.sizeUniforms.push(mat.uniforms.uScale!);
|
||||
const pts = new THREE.Points(geo, mat);
|
||||
pts.frustumCulled = false;
|
||||
this.scene.add(pts);
|
||||
return mat;
|
||||
}
|
||||
|
||||
private buildBackdrop() {
|
||||
const r = rng(7);
|
||||
const tints = [new THREE.Color("#cfd8ff"), new THREE.Color("#fff1d6"), new THREE.Color("#ffffff"), new THREE.Color("#ffd2b8")];
|
||||
const mid = this.pathPoint(pathLength(this.stars) / 2);
|
||||
this.dust(3600, (i, pos, col, size, alpha) => {
|
||||
const u = r() * 2 - 1, th = r() * Math.PI * 2, rad = 420 + r() * 700;
|
||||
const s = Math.sqrt(1 - u * u);
|
||||
pos.set([mid.x + rad * s * Math.cos(th), mid.y + rad * u * 0.75, mid.z + rad * s * Math.sin(th)], i * 3);
|
||||
const c = tints[i % tints.length]!;
|
||||
col.set([c.r, c.g, c.b], i * 3);
|
||||
const big = r() > 0.97;
|
||||
size[i] = big ? 4.5 + r() * 3 : 1.6 + r() * 2.2;
|
||||
alpha[i] = big ? 0.9 : 0.25 + r() * 0.55;
|
||||
}, false);
|
||||
// near dust that drifts past the camera, for parallax while flying
|
||||
const len = pathLength(this.stars);
|
||||
this.dust(900, (i, pos, col, size, alpha) => {
|
||||
const p = this.pathPoint(r() * (len + 40) - 20);
|
||||
pos.set([p.x + (r() - 0.5) * 120, p.y + (r() - 0.5) * 70, p.z + (r() - 0.5) * 60], i * 3);
|
||||
col.set([0.75, 0.8, 1], i * 3);
|
||||
size[i] = 0.35 + r() * 0.5;
|
||||
alpha[i] = 0.35 + r() * 0.4;
|
||||
}, true);
|
||||
}
|
||||
|
||||
private buildPath() {
|
||||
// The voyage track: an engraved dotted line, plus year meridians and gap captions.
|
||||
const len = pathLength(this.stars);
|
||||
const pts: number[] = [];
|
||||
for (let d = 2; d < len; d += 1.1) {
|
||||
const p = this.pathPoint(d);
|
||||
pts.push(p.x, p.y, p.z);
|
||||
}
|
||||
const chart = new THREE.Color("#8d97c8");
|
||||
this.dust(pts.length / 3, (i, pos, col, size, alpha) => {
|
||||
pos.set([pts[i * 3]!, pts[i * 3 + 1]!, pts[i * 3 + 2]!], i * 3);
|
||||
col.set([chart.r, chart.g, chart.b], i * 3);
|
||||
size[i] = 0.42;
|
||||
alpha[i] = 0.9;
|
||||
}, true);
|
||||
|
||||
for (const mark of yearMarks(this.stars)) {
|
||||
const { t, n, b } = this.frame(mark.dist - 5);
|
||||
const c = this.pathPoint(mark.dist - 5);
|
||||
const ring: number[] = [];
|
||||
for (let i = 0; i <= 96; i++) {
|
||||
const a = (i / 96) * Math.PI * 2;
|
||||
const v = c.clone().addScaledVector(n, Math.cos(a) * 7).addScaledVector(b, Math.sin(a) * 7);
|
||||
ring.push(v.x, v.y, v.z);
|
||||
}
|
||||
const rg = new THREE.BufferGeometry();
|
||||
rg.setAttribute("position", new THREE.Float32BufferAttribute(ring, 3));
|
||||
const line = new THREE.Line(rg, new THREE.LineDashedMaterial({ color: 0x8d97c8, dashSize: 0.6, gapSize: 0.9, transparent: true, opacity: 0.3 }));
|
||||
line.computeLineDistances();
|
||||
this.scene.add(line);
|
||||
void t;
|
||||
const el = document.createElement("div");
|
||||
el.className = "sky-label sky-label--year";
|
||||
el.textContent = mark.year === 0 ? "The fall of Troy" : `Year ${mark.year} after Troy`;
|
||||
this.addLabel(el, c.clone().addScaledVector(b, 7.8), 120, 4);
|
||||
}
|
||||
for (const gap of longGaps(this.stars)) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "sky-label sky-label--gap";
|
||||
const words = ["", "a year", "two years", "three years", "four years"];
|
||||
el.textContent = gap.years === 1 ? "a year passes" : `${words[gap.years] ?? `${gap.years} years`} pass`;
|
||||
const d = (gap.from + gap.to) / 2;
|
||||
const { b } = this.frame(d);
|
||||
this.addLabel(el, this.pathPoint(d).addScaledVector(b, -2.2), 150, 2);
|
||||
}
|
||||
}
|
||||
|
||||
private buildNebulae() {
|
||||
for (const range of sagaRanges(this.stars)) {
|
||||
const [a, b] = SAGA_HUES[range.saga.id] ?? FALLBACK_HUES;
|
||||
const ca = new THREE.Color(a), cb = new THREE.Color(b);
|
||||
const r = rng(range.saga.order * 101);
|
||||
const from = range.from - 12, to = range.to + 12;
|
||||
const tmp = new THREE.Color();
|
||||
const mat = this.dust(700, (i, pos, col, size, alpha) => {
|
||||
const d = from + r() * (to - from);
|
||||
const { n, b: bn } = this.frame(d);
|
||||
const g = () => (r() + r() + r() - 1.5) / 1.5; // ~gaussian
|
||||
const rad = 15 + r() * 6;
|
||||
const p = this.pathPoint(d).addScaledVector(n, g() * rad * 1.3).addScaledVector(bn, g() * rad * 0.8);
|
||||
p.z += g() * 8;
|
||||
pos.set([p.x, p.y, p.z], i * 3);
|
||||
tmp.copy(ca).lerp(cb, r() * r());
|
||||
col.set([tmp.r, tmp.g, tmp.b], i * 3);
|
||||
const cloud = i % 3 === 0;
|
||||
size[i] = cloud ? 40 + r() * 55 : 0.4 + r() * 0.6;
|
||||
alpha[i] = cloud ? 0.028 + r() * 0.04 : 0.5 + r() * 0.45;
|
||||
}, true);
|
||||
this.nebulae.set(range.saga.id, mat);
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "sky-label sky-label--saga";
|
||||
el.style.setProperty("--hue", b);
|
||||
el.textContent = range.saga.name;
|
||||
const mid = (range.from + range.to) / 2;
|
||||
const { b: up } = this.frame(mid);
|
||||
this.addLabel(el, this.pathPoint(mid).addScaledVector(up, 18), 170, 3);
|
||||
}
|
||||
}
|
||||
|
||||
private buildConstellations() {
|
||||
motifs.forEach((m, mi) => {
|
||||
const lit = this.stars.filter((s) => s.song.parts.some((p) => p.motifIds.includes(m.id)));
|
||||
if (lit.length < 2) return;
|
||||
const theta = (mi / motifs.length) * Math.PI * 2;
|
||||
const seg: number[] = [];
|
||||
for (let i = 1; i < lit.length; i++) {
|
||||
const A = this.starPos[lit[i - 1]!.index]!, B = this.starPos[lit[i]!.index]!;
|
||||
const mid = A.clone().add(B).multiplyScalar(0.5);
|
||||
const { n, b } = this.frame((lit[i - 1]!.dist + lit[i]!.dist) / 2);
|
||||
const bow = 1.6 + A.distanceTo(B) * 0.07;
|
||||
const ctrl = mid.addScaledVector(n, Math.cos(theta) * bow).addScaledVector(b, Math.sin(theta) * bow);
|
||||
const curve = new THREE.QuadraticBezierCurve3(A, ctrl, B);
|
||||
const pts = curve.getPoints(24);
|
||||
// stop short of the stars so the arcs read as chart lines, not skewers
|
||||
for (let k = 1; k < pts.length - 2; k++) seg.push(...pts[k]!.toArray(), ...pts[k + 1]!.toArray());
|
||||
}
|
||||
const geo = new LineSegmentsGeometry();
|
||||
geo.setPositions(seg);
|
||||
const mat = new LineMaterial({
|
||||
color: new THREE.Color(m.color).getHex(),
|
||||
linewidth: 1.2,
|
||||
transparent: true,
|
||||
opacity: 0.22,
|
||||
depthWrite: false,
|
||||
dashed: m.kind === "character",
|
||||
dashSize: 0.9,
|
||||
gapSize: 0.7,
|
||||
worldUnits: false,
|
||||
});
|
||||
const line = new LineSegments2(geo, mat);
|
||||
if (m.kind === "character") line.computeLineDistances();
|
||||
line.frustumCulled = false;
|
||||
this.scene.add(line);
|
||||
this.motifLines.set(m.id, { line, mat, motif: m });
|
||||
});
|
||||
}
|
||||
|
||||
private buildStars() {
|
||||
const n = this.stars.length;
|
||||
const pos = new Float32Array(n * 3), col = new Float32Array(n * 3), size = new Float32Array(n);
|
||||
const state = new Float32Array(n).fill(1), ring = new Float32Array(n * 4);
|
||||
const warm = new THREE.Color("#fff3d8");
|
||||
this.stars.forEach((s, i) => {
|
||||
pos.set(this.starPos[i]!.toArray(), i * 3);
|
||||
const hue = new THREE.Color((SAGA_HUES[s.song.sagaId] ?? FALLBACK_HUES)[1]);
|
||||
const c = warm.clone().lerp(hue, 0.45);
|
||||
col.set([c.r, c.g, c.b], i * 3);
|
||||
size[i] = 3.4 + s.magnitude * 1.05;
|
||||
});
|
||||
this.starGeo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
|
||||
this.starGeo.setAttribute("aColor", new THREE.BufferAttribute(col, 3));
|
||||
this.starGeo.setAttribute("aSize", new THREE.BufferAttribute(size, 1));
|
||||
this.starGeo.setAttribute("aState", new THREE.BufferAttribute(state, 1));
|
||||
this.starGeo.setAttribute("aRing", new THREE.BufferAttribute(ring, 4));
|
||||
const pts = new THREE.Points(this.starGeo, this.starMat);
|
||||
pts.frustumCulled = false;
|
||||
pts.renderOrder = 2;
|
||||
this.scene.add(pts);
|
||||
}
|
||||
|
||||
private buildLabels() {
|
||||
this.stars.forEach((s, i) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "sky-label sky-label--star";
|
||||
el.dataset.song = s.song.id;
|
||||
el.innerHTML = `<span class="sky-label__num">${s.song.track}</span><span class="sky-label__name"></span><span class="sky-label__parts"></span>`;
|
||||
el.querySelector(".sky-label__name")!.textContent = s.song.title;
|
||||
this.starLabels.push(el);
|
||||
this.addLabel(el, this.starPos[i]!, 150, 1, true);
|
||||
});
|
||||
}
|
||||
|
||||
private addLabel(el: HTMLElement, pos: THREE.Vector3, fade: number, rank: number, star = false) {
|
||||
this.labelLayer.append(el);
|
||||
this.labels.push({ el, pos, fade, rank, star });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- public API
|
||||
|
||||
get progressRange(): [number, number] {
|
||||
return [(this.stars[0]?.dist ?? 0) - 10, (this.stars[this.stars.length - 1]?.dist ?? 0) + 4];
|
||||
}
|
||||
|
||||
setProgress(p: number, instant = false) {
|
||||
const [lo, hi] = this.progressRange;
|
||||
this.pT = THREE.MathUtils.clamp(p, lo, hi);
|
||||
if (this.open === null) this.focusT = 0;
|
||||
if (instant || this.reduced) this.p = this.pT;
|
||||
this.dirty = true;
|
||||
}
|
||||
nudge(delta: number) {
|
||||
this.setProgress(this.pT + delta);
|
||||
}
|
||||
get progress() { return this.pT; }
|
||||
|
||||
/** Fly to a star; `focus` pins the look-at on it (used while the song is open). */
|
||||
flyTo(index: number, focus: boolean, instant = false) {
|
||||
const s = this.stars[index];
|
||||
if (!s) return;
|
||||
this.focusPos.copy(this.starPos[index]!);
|
||||
this.focusT = focus ? 1 : 0;
|
||||
this.yawT = 0; this.pitchT = 0;
|
||||
this.setProgress(s.dist - (focus ? 3 : 4), instant);
|
||||
if (instant || this.reduced) { this.focusW = this.focusT; this.yaw = 0; this.pitch = 0; }
|
||||
}
|
||||
|
||||
/** Shift the projection centre (px) so the focused star clears an overlay panel. */
|
||||
setViewShift(x: number, y: number, instant = false) {
|
||||
this.shiftT.set(x, y);
|
||||
if (instant || this.reduced) this.shift.copy(this.shiftT);
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
setOpen(index: number | null) {
|
||||
this.open = index;
|
||||
if (index === null) this.focusT = 0;
|
||||
this.applyStates();
|
||||
}
|
||||
setHover(index: number | null) {
|
||||
if (this.hover === index) return;
|
||||
this.hover = index;
|
||||
this.applyStates();
|
||||
this.cb.onHover(index);
|
||||
}
|
||||
setMotif(id: string | null) {
|
||||
this.motif = id;
|
||||
for (const [mid, { mat }] of this.motifLines) {
|
||||
const on = id === mid;
|
||||
mat.opacity = id === null ? 0.22 : on ? 1 : 0.05;
|
||||
mat.linewidth = on ? 2.6 : 1.2;
|
||||
}
|
||||
const neb = id === null ? 1 : 0.55;
|
||||
for (const mat of this.nebulae.values()) mat.uniforms.uBoost!.value = neb;
|
||||
this.applyStates();
|
||||
}
|
||||
|
||||
projectStar(index: number): { x: number; y: number; visible: boolean } {
|
||||
const v = this.starPos[index]!.clone().project(this.camera);
|
||||
return { x: (v.x * 0.5 + 0.5) * this.size.x, y: (-v.y * 0.5 + 0.5) * this.size.y, visible: v.z < 1 };
|
||||
}
|
||||
|
||||
private applyStates() {
|
||||
const state = this.starGeo.getAttribute("aState") as THREE.BufferAttribute;
|
||||
const ring = this.starGeo.getAttribute("aRing") as THREE.BufferAttribute;
|
||||
const m = this.motif ? motifs.find((x) => x.id === this.motif) : undefined;
|
||||
const mc = m ? new THREE.Color(m.color) : null;
|
||||
this.stars.forEach((s, i) => {
|
||||
const has = m ? s.song.parts.some((p) => p.motifIds.includes(m.id)) : false;
|
||||
let st = m ? (has ? 1.6 : 0) : 1;
|
||||
if (i === this.hover || i === this.open) st = 2;
|
||||
state.setX(i, st);
|
||||
if (mc && has) ring.setXYZW(i, mc.r, mc.g, mc.b, 0.7);
|
||||
else if (i === this.open) ring.setXYZW(i, 0.95, 0.92, 0.85, 0.55);
|
||||
else ring.setXYZW(i, 0, 0, 0, 0);
|
||||
this.starLabels[i]!.classList.toggle("is-hover", i === this.hover);
|
||||
this.starLabels[i]!.classList.toggle("is-open", i === this.open);
|
||||
this.starLabels[i]!.classList.toggle("is-dim", !!m && !has);
|
||||
this.starLabels[i]!.classList.toggle("is-lit", !!m && has);
|
||||
});
|
||||
state.needsUpdate = true;
|
||||
ring.needsUpdate = true;
|
||||
for (const l of this.labels) l.box = undefined;
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- input
|
||||
|
||||
private pick(x: number, y: number): number | null {
|
||||
let best: number | null = null;
|
||||
let bestD = 26;
|
||||
this.stars.forEach((_, i) => {
|
||||
const p = this.projectStar(i);
|
||||
if (!p.visible) return;
|
||||
const d = Math.hypot(p.x - x, p.y - y);
|
||||
if (d < bestD) { bestD = d; best = i; }
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
private bindInput() {
|
||||
const el = this.renderer.domElement;
|
||||
const sig = { signal: this.ac.signal };
|
||||
el.addEventListener("pointerdown", (e) => {
|
||||
this.pointer = { down: true, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, moved: false };
|
||||
el.setPointerCapture(e.pointerId);
|
||||
}, sig);
|
||||
el.addEventListener("pointermove", (e) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (this.pointer.down) {
|
||||
const dx = e.clientX - this.pointer.x, dy = e.clientY - this.pointer.y;
|
||||
this.pointer.x = e.clientX; this.pointer.y = e.clientY;
|
||||
if (Math.hypot(e.clientX - this.pointer.sx, e.clientY - this.pointer.sy) > 5) this.pointer.moved = true;
|
||||
if (this.pointer.moved) {
|
||||
// horizontal drag travels along the voyage, vertical drag tilts the view
|
||||
this.nudge(-dx * 0.09);
|
||||
this.yawT = THREE.MathUtils.clamp(this.yawT - dx * 0.0016, -0.6, 0.6);
|
||||
this.pitchT = THREE.MathUtils.clamp(this.pitchT + dy * 0.004, -0.35, 0.7);
|
||||
if (this.reduced) { this.yaw = this.yawT; this.pitch = this.pitchT; }
|
||||
el.classList.add("is-dragging");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const hit = this.pick(e.clientX - rect.left, e.clientY - rect.top);
|
||||
this.setHover(hit);
|
||||
el.style.cursor = hit === null ? "grab" : "pointer";
|
||||
}, sig);
|
||||
const end = (e: PointerEvent) => {
|
||||
if (!this.pointer.down) return;
|
||||
this.pointer.down = false;
|
||||
el.classList.remove("is-dragging");
|
||||
if (!this.pointer.moved) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const hit = this.pick(e.clientX - rect.left, e.clientY - rect.top);
|
||||
if (hit !== null) this.cb.onPick(hit);
|
||||
}
|
||||
};
|
||||
el.addEventListener("pointerup", end, sig);
|
||||
el.addEventListener("pointercancel", end, sig);
|
||||
el.addEventListener("pointerleave", () => { if (!this.pointer.down) this.setHover(null); }, sig);
|
||||
el.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
const d = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
|
||||
this.nudge(d * (e.deltaMode === 1 ? 1.2 : 0.045));
|
||||
}, { ...sig, passive: false });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- frame loop
|
||||
|
||||
private resize() {
|
||||
const w = this.host.clientWidth || 1, h = this.host.clientHeight || 1;
|
||||
this.size.set(w, h);
|
||||
this.renderer.setSize(w, h, false);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
const scale = (h * this.renderer.getPixelRatio()) / (2 * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)));
|
||||
for (const u of this.sizeUniforms) u.value = scale;
|
||||
for (const { mat } of this.motifLines.values()) mat.resolution.set(w, h);
|
||||
this.dirty = true;
|
||||
this.renderFrame(0);
|
||||
}
|
||||
|
||||
private start() {
|
||||
if (this.raf || document.hidden) return;
|
||||
this.clock.getDelta();
|
||||
const loop = () => {
|
||||
this.raf = requestAnimationFrame(loop);
|
||||
this.renderFrame(Math.min(this.clock.getDelta(), 0.1));
|
||||
};
|
||||
this.raf = requestAnimationFrame(loop);
|
||||
}
|
||||
private stop() {
|
||||
cancelAnimationFrame(this.raf);
|
||||
this.raf = 0;
|
||||
}
|
||||
|
||||
private tmpA = new THREE.Vector3();
|
||||
private tmpB = new THREE.Vector3();
|
||||
|
||||
private renderFrame(dt: number) {
|
||||
const k = this.reduced ? 1 : 1 - Math.exp(-dt * 3.4);
|
||||
const moving =
|
||||
Math.abs(this.pT - this.p) > 0.002 || Math.abs(this.focusT - this.focusW) > 0.001 ||
|
||||
Math.abs(this.yawT - this.yaw) > 0.0005 || Math.abs(this.pitchT - this.pitch) > 0.0005 ||
|
||||
this.shift.distanceTo(this.shiftT) > 0.3;
|
||||
if (!moving && !this.dirty && this.reduced) return; // reduced motion: render only on change
|
||||
|
||||
this.p += (this.pT - this.p) * k;
|
||||
this.focusW += (this.focusT - this.focusW) * k;
|
||||
this.yaw += (this.yawT - this.yaw) * k;
|
||||
this.pitch += (this.pitchT - this.pitch) * k;
|
||||
this.shift.lerp(this.shiftT, k);
|
||||
if (!this.pointer.down && this.open === null && !this.reduced) {
|
||||
// drift back to the chart's natural heading after a drag
|
||||
this.yawT *= 1 - dt * 0.6;
|
||||
this.pitchT *= 1 - dt * 0.6;
|
||||
}
|
||||
if (!this.reduced) this.starMat.uniforms.uTime!.value += dt;
|
||||
|
||||
const { n, b } = this.frame(this.p - BACK);
|
||||
const base = this.pathPoint(this.p - BACK, this.tmpA).addScaledVector(b, HEIGHT).addScaledVector(n, SIDE);
|
||||
const look = this.pathPoint(this.p + AHEAD, this.tmpB).lerp(this.focusPos, this.focusW);
|
||||
const off = base.clone().sub(look);
|
||||
if (this.focusW > 0) off.setLength(THREE.MathUtils.lerp(off.length(), 30, this.focusW));
|
||||
off.applyAxisAngle(new THREE.Vector3(0, 1, 0), this.yaw);
|
||||
const right = new THREE.Vector3().crossVectors(off, new THREE.Vector3(0, 1, 0)).normalize();
|
||||
off.applyAxisAngle(right, -this.pitch);
|
||||
this.camera.position.copy(look).add(off);
|
||||
this.camera.lookAt(look);
|
||||
|
||||
const { x: w, y: h } = this.size;
|
||||
if (Math.abs(this.shift.x) > 0.5 || Math.abs(this.shift.y) > 0.5) this.camera.setViewOffset(w, h, this.shift.x, this.shift.y, w, h);
|
||||
else this.camera.clearViewOffset();
|
||||
this.camera.updateMatrixWorld();
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.placeLabels();
|
||||
this.dirty = false;
|
||||
|
||||
let nearest = 0, nd = Infinity;
|
||||
this.stars.forEach((s, i) => { const d = Math.abs(s.dist - this.p - 4); if (d < nd) { nd = d; nearest = i; } });
|
||||
if (moving || nearest !== this.lastNearest) this.cb.onProgress(this.p, nearest);
|
||||
this.lastNearest = nearest;
|
||||
}
|
||||
|
||||
private v = new THREE.Vector3();
|
||||
private placeLabels() {
|
||||
const { x: w, y: h } = this.size;
|
||||
const cam = this.camera.position;
|
||||
const shown: { l: SceneLabel; x: number; y: number; dist: number; rank: number }[] = [];
|
||||
for (const l of this.labels) {
|
||||
this.v.copy(l.pos).project(this.camera);
|
||||
const dist = cam.distanceTo(l.pos);
|
||||
const behind = this.v.z > 1 || this.v.z < -1;
|
||||
const x = (this.v.x * 0.5 + 0.5) * w, y = (-this.v.y * 0.5 + 0.5) * h;
|
||||
const off = x < -200 || x > w + 200 || y < -100 || y > h + 100;
|
||||
if (behind || off || dist > l.fade) { l.el.style.visibility = "hidden"; continue; }
|
||||
const c = l.el.classList;
|
||||
const rank = l.star && (c.contains("is-hover") || c.contains("is-open") || c.contains("is-lit")) ? 0 : l.rank;
|
||||
shown.push({ l, x, y, dist, rank });
|
||||
}
|
||||
// Greedy de-cluttering: nearest, most important labels claim their screen box first.
|
||||
shown.sort((a, b) => a.rank - b.rank || a.dist - b.dist);
|
||||
const taken: [number, number, number, number][] = [];
|
||||
for (const { l, x, y, dist, rank } of shown) {
|
||||
if (!l.box) l.box = { ox: l.el.offsetLeft, oy: l.el.offsetTop, w: l.el.offsetWidth, h: l.el.offsetHeight };
|
||||
const b = l.box;
|
||||
const r: [number, number, number, number] = [x + b.ox, y + b.oy, x + b.ox + b.w, y + b.oy + b.h];
|
||||
const hit = rank > 0 && taken.some((t) => r[0] < t[2] && r[2] > t[0] && r[1] < t[3] && r[3] > t[1]);
|
||||
if (hit) { l.el.style.visibility = "hidden"; continue; }
|
||||
taken.push(r);
|
||||
const near = l.star ? THREE.MathUtils.clamp((dist - 3) / 6, 0, 1) : 1;
|
||||
l.el.style.visibility = "visible";
|
||||
l.el.style.opacity = String(Math.min(1, (1 - dist / l.fade) * 2.2) * near);
|
||||
l.el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`;
|
||||
l.el.style.zIndex = String(1000 - Math.round(dist));
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.stop();
|
||||
this.ac.abort();
|
||||
this.ro.disconnect();
|
||||
this.scene.traverse((o) => {
|
||||
const any = o as THREE.Mesh;
|
||||
any.geometry?.dispose();
|
||||
const m = any.material as THREE.Material | THREE.Material[] | undefined;
|
||||
if (Array.isArray(m)) m.forEach((x) => x.dispose());
|
||||
else m?.dispose();
|
||||
});
|
||||
this.renderer.dispose();
|
||||
this.renderer.forceContextLoss();
|
||||
this.renderer.domElement.remove();
|
||||
for (const l of this.labels) l.el.remove();
|
||||
this.labels = [];
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,397 @@
|
||||
main { padding: 2rem; font-family: system-ui, sans-serif; }
|
||||
h1::before { content: "06 · "; }
|
||||
/* 06 · Constellation — an engraved star atlas of the voyage, flown through in deep space. */
|
||||
@import "@fontsource/cormorant-garamond/latin-400.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-400-italic.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-600-italic.css";
|
||||
@import "@fontsource/inter/latin-400.css";
|
||||
@import "@fontsource/inter/latin-500.css";
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--void: #050816;
|
||||
--void-2: #0a1030;
|
||||
--glass: rgba(8, 12, 34, 0.84);
|
||||
--starlight: #f3ead2;
|
||||
--ink: #e4e2ef;
|
||||
--muted: #9aa1c4;
|
||||
--chart: #8d97c8;
|
||||
--rule: rgba(141, 151, 200, 0.3);
|
||||
--rule-strong: rgba(141, 151, 200, 0.55);
|
||||
--serif: "Cormorant Garamond", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||
--sans: "Inter", system-ui, sans-serif;
|
||||
--credits-h: 3rem;
|
||||
--gutter: clamp(1rem, 2vw, 1.75rem);
|
||||
--panel-w: min(27rem, 34vw);
|
||||
--ease: cubic-bezier(0.2, 0.7, 0.1, 1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(120% 90% at 70% 20%, var(--void-2), var(--void) 60%);
|
||||
color: var(--ink);
|
||||
font: 400 0.875rem/1.5 var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button { font: inherit; color: inherit; }
|
||||
:focus-visible { outline: 2px solid var(--starlight); outline-offset: 3px; }
|
||||
|
||||
.atlas { position: fixed; inset: 0; padding: 0; }
|
||||
|
||||
/* ---------------------------------------------------------------- sky */
|
||||
.sky { position: absolute; inset: 0; z-index: 0; isolation: isolate; }
|
||||
.sky__canvas { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
|
||||
.sky__canvas.is-dragging { cursor: grabbing; }
|
||||
.sky-labels { position: absolute; inset: 0 0 var(--credits-h); pointer-events: none; overflow: hidden; }
|
||||
|
||||
.sky-label { position: absolute; left: 0; top: 0; will-change: transform; white-space: nowrap; visibility: hidden; }
|
||||
|
||||
.sky-label--star {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
column-gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
margin: -0.85rem 0 0 1.1rem;
|
||||
font-family: var(--serif);
|
||||
color: var(--starlight);
|
||||
text-shadow: 0 0 6px var(--void), 0 0 2px var(--void);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.sky-label__num { font: 400 0.68rem var(--sans); color: var(--muted); font-variant-numeric: oldstyle-nums; }
|
||||
.sky-label__name { font-style: italic; font-weight: 500; font-size: 1.12rem; letter-spacing: 0.01em; }
|
||||
.sky-label__parts {
|
||||
grid-column: 2;
|
||||
font: 500 0.7rem var(--sans);
|
||||
color: var(--c, var(--muted));
|
||||
}
|
||||
.sky-label__parts:empty { display: none; }
|
||||
.sky-label__meta {
|
||||
grid-column: 2;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
margin-top: 0.3rem;
|
||||
padding: 0.45rem 0.6rem 0.5rem;
|
||||
border-left: 1px solid var(--starlight);
|
||||
background: linear-gradient(90deg, rgba(8, 12, 34, 0.9), rgba(8, 12, 34, 0));
|
||||
font: 400 0.75rem/1.45 var(--sans);
|
||||
color: var(--ink);
|
||||
}
|
||||
.sky-label__cta { color: var(--starlight); margin-top: 0.2rem; font-weight: 500; }
|
||||
.sky-label--star.is-hover .sky-label__name { font-size: 1.5rem; }
|
||||
.sky-label--star.is-hover .sky-label__meta { display: flex; }
|
||||
.sky-label--star.is-open .sky-label__name { text-decoration: underline 1px; text-underline-offset: 0.25em; }
|
||||
.sky-label--star.is-dim { color: rgba(243, 234, 210, 0.35); }
|
||||
.sky-label--star.is-dim .sky-label__num { opacity: 0.4; }
|
||||
.sky-label--star.is-lit .sky-label__name { color: var(--c); }
|
||||
|
||||
.sky-label--saga {
|
||||
transform-origin: 0 0;
|
||||
margin: 0 0 0 -8rem;
|
||||
width: 16rem;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
font: italic 400 clamp(1.6rem, 2.6vw, 2.4rem)/1 var(--serif);
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--hue);
|
||||
opacity: 0.8;
|
||||
text-shadow: 0 0 18px var(--void);
|
||||
}
|
||||
.sky-label--year {
|
||||
margin: -1.6rem 0 0 -6rem;
|
||||
width: 12rem;
|
||||
text-align: center;
|
||||
font: 400 0.72rem var(--sans);
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--chart);
|
||||
}
|
||||
.sky-label--gap {
|
||||
margin: 0.4rem 0 0 -5rem;
|
||||
width: 10rem;
|
||||
text-align: center;
|
||||
font: italic 400 1rem var(--serif);
|
||||
color: var(--muted);
|
||||
}
|
||||
.sky-label--gap::before, .sky-label--gap::after { content: "~"; margin: 0 0.4em; opacity: 0.6; }
|
||||
|
||||
/* ---------------------------------------------------------------- masthead */
|
||||
.masthead {
|
||||
position: absolute;
|
||||
top: var(--gutter);
|
||||
left: var(--gutter);
|
||||
max-width: 21rem;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 0 12px var(--void);
|
||||
}
|
||||
.masthead__title {
|
||||
margin: 0;
|
||||
font: italic 500 clamp(2.2rem, 3.4vw, 3.1rem)/0.95 var(--serif);
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--starlight);
|
||||
}
|
||||
.masthead__lede { margin: 0.6rem 0 0; color: var(--muted); max-width: 19rem; }
|
||||
|
||||
/* ---------------------------------------------------------------- legend */
|
||||
.legend {
|
||||
position: absolute;
|
||||
left: var(--gutter);
|
||||
top: calc(var(--gutter) + 8.6rem);
|
||||
width: 16rem;
|
||||
max-height: calc(100% - var(--credits-h) - 8.6rem - 7.5rem - var(--gutter));
|
||||
overflow-y: auto;
|
||||
padding: 0.9rem 0.9rem 1rem;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--rule);
|
||||
outline: 1px solid var(--rule);
|
||||
outline-offset: -5px;
|
||||
backdrop-filter: blur(6px);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.legend__title { margin: 0 0 0.2rem; font: italic 500 1.35rem var(--serif); color: var(--starlight); }
|
||||
.legend__group { margin: 0.7rem 0 0.25rem; font: 500 0.72rem var(--sans); color: var(--muted); }
|
||||
.legend__list { list-style: none; margin: 0; padding: 0; }
|
||||
.legend__motif {
|
||||
display: grid;
|
||||
grid-template-columns: 2.25rem 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.28rem 0.35rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.legend__motif:hover { background: rgba(141, 151, 200, 0.12); }
|
||||
.legend__motif[aria-pressed="true"] { background: color-mix(in srgb, var(--c) 18%, transparent); box-shadow: inset 2px 0 var(--c); }
|
||||
.legend__name { font-size: 0.85rem; }
|
||||
.legend__count { font-size: 0.72rem; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.glyph { width: 2.25rem; height: 0.75rem; display: block; }
|
||||
.legend__key { margin: 0.8rem 0 0; color: var(--muted); font-size: 0.78rem; }
|
||||
.legend__detail { margin-top: 0.8rem; padding-top: 0.7rem; border-top: 1px solid var(--rule); font-size: 0.8rem; }
|
||||
.legend__detail p { margin: 0 0 0.5rem; }
|
||||
.legend__appears { color: var(--muted); }
|
||||
.legend__songs { margin: 0 0 0.6rem; padding: 0; list-style: none; }
|
||||
.legend__songs button {
|
||||
display: flex; justify-content: space-between; gap: 0.5rem; width: 100%;
|
||||
padding: 0.2rem 0.3rem; background: none; border: 0; text-align: left; cursor: pointer;
|
||||
}
|
||||
.legend__songs button:hover { background: rgba(141, 151, 200, 0.12); }
|
||||
.legend__song { font: italic 500 1rem var(--serif); color: var(--c); }
|
||||
.legend__parts { font-size: 0.72rem; color: var(--muted); white-space: nowrap; }
|
||||
.legend__clear { background: none; border: 0; padding: 0; color: var(--starlight); text-decoration: underline 1px; text-underline-offset: 3px; cursor: pointer; font-size: 0.78rem; }
|
||||
|
||||
/* ---------------------------------------------------------------- ruler */
|
||||
.ruler {
|
||||
position: absolute;
|
||||
left: calc(var(--gutter) + 1rem);
|
||||
right: calc(var(--gutter) + 1rem);
|
||||
bottom: calc(var(--credits-h) + 0.6rem);
|
||||
padding-top: 1.6rem;
|
||||
transition: right 0.35s var(--ease);
|
||||
}
|
||||
body.has-panel .ruler { right: calc(var(--panel-w) + var(--gutter) * 2); }
|
||||
.ruler__track { position: relative; height: 1.4rem; border-bottom: 1px solid var(--rule-strong); }
|
||||
.ruler__year {
|
||||
position: absolute; bottom: -0.35rem; height: 0.7rem; border-left: 1px solid var(--chart);
|
||||
padding-left: 0.35rem; transform: translateY(-1.45rem);
|
||||
font-size: 0.7rem; color: var(--chart); white-space: nowrap; line-height: 0.7rem;
|
||||
}
|
||||
.ruler__gap {
|
||||
position: absolute; bottom: -1.35rem; text-align: center;
|
||||
font: italic 400 0.85rem var(--serif); color: var(--muted);
|
||||
border-top: 1px dotted var(--muted); padding-top: 0.05rem; line-height: 1.1;
|
||||
}
|
||||
.ruler__star {
|
||||
position: absolute; bottom: -0.65rem; width: 1.3rem; height: 1.3rem; margin-left: -0.65rem;
|
||||
padding: 0; background: none; border: 0; cursor: pointer; display: grid; place-items: center;
|
||||
}
|
||||
.ruler__dot { width: 0.42rem; height: 0.42rem; border-radius: 50%; background: var(--starlight); box-shadow: 0 0 6px var(--starlight); transition: transform 0.2s; }
|
||||
.ruler__star:hover .ruler__dot, .ruler__star:focus-visible .ruler__dot { transform: scale(1.6); }
|
||||
.ruler__star.is-lit .ruler__dot { background: var(--c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--c) 35%, transparent), 0 0 8px var(--c); }
|
||||
.ruler__star.is-dim .ruler__dot { opacity: 0.3; }
|
||||
.ruler__star.is-open .ruler__dot { box-shadow: 0 0 0 2px var(--void), 0 0 0 3px var(--starlight); }
|
||||
.ruler__cursor {
|
||||
position: absolute; bottom: -0.2rem; width: 0; height: 0; margin-left: -0.35rem;
|
||||
border: 0.35rem solid transparent; border-bottom-color: var(--starlight); transform: translateY(100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.ruler__hint { display: flex; flex-wrap: wrap; gap: 0.3rem 1.5rem; margin: 1.6rem 0 0; font-size: 0.72rem; color: var(--muted); }
|
||||
|
||||
/* ---------------------------------------------------------------- panel */
|
||||
.panel {
|
||||
position: absolute;
|
||||
top: var(--gutter);
|
||||
right: var(--gutter);
|
||||
bottom: calc(var(--credits-h) + 0.6rem);
|
||||
width: var(--panel-w);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--rule-strong);
|
||||
outline: 1px solid var(--rule);
|
||||
outline-offset: -6px;
|
||||
backdrop-filter: blur(10px);
|
||||
animation: panel-in 0.45s var(--ease);
|
||||
z-index: 5;
|
||||
}
|
||||
.panel[hidden] { display: none; }
|
||||
@keyframes panel-in { from { opacity: 0; transform: translateX(1.5rem); } }
|
||||
.panel__scroll { flex: 1; overflow-y: auto; padding: 1.4rem 1.5rem 1rem; scrollbar-width: thin; }
|
||||
.panel__head { position: relative; padding-right: 2rem; }
|
||||
.panel__where { margin: 0; color: var(--muted); font-size: 0.78rem; }
|
||||
.panel__title { margin: 0.2rem 0 0; font: italic 500 2.4rem/1.02 var(--serif); color: var(--starlight); }
|
||||
.panel__close {
|
||||
position: absolute; top: -0.4rem; right: -0.5rem; width: 2rem; height: 2rem;
|
||||
background: none; border: 1px solid var(--rule); border-radius: 50%; cursor: pointer; font-size: 1.2rem; line-height: 1;
|
||||
}
|
||||
.panel__close:hover { border-color: var(--starlight); }
|
||||
.panel__meta { margin: 1.1rem 0 0; display: grid; gap: 0.55rem; }
|
||||
.panel__meta div { display: grid; grid-template-columns: 6.5rem 1fr; gap: 0.75rem; align-items: baseline; }
|
||||
.panel__meta dt { color: var(--muted); font-size: 0.75rem; }
|
||||
.panel__meta dd { margin: 0; }
|
||||
.panel__meta small { display: block; color: var(--muted); font-size: 0.72rem; }
|
||||
.panel__meta a { color: var(--starlight); text-decoration: underline 1px; text-underline-offset: 3px; }
|
||||
.panel__muted { color: var(--muted); font-style: italic; }
|
||||
.panel__themes { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem; }
|
||||
.panel__themes small { display: inline; margin-left: 0.3rem; }
|
||||
.dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--c); box-shadow: 0 0 6px var(--c); }
|
||||
|
||||
.panel__sec { margin-top: 1.4rem; }
|
||||
.panel__sec h3 {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
margin: 0 0 0.6rem; font: italic 500 1.2rem var(--serif); color: var(--starlight);
|
||||
}
|
||||
.panel__sec h3::after { content: ""; flex: 1; height: 1px; background: var(--rule); }
|
||||
|
||||
.parts { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.7rem; }
|
||||
.part {
|
||||
padding: 0.6rem 0.7rem 0.7rem;
|
||||
border-left: 2px solid var(--rule);
|
||||
background: rgba(141, 151, 200, 0.05);
|
||||
transition: opacity 0.25s, border-color 0.25s, background 0.25s;
|
||||
}
|
||||
.part.is-lit { border-left-color: var(--c); background: color-mix(in srgb, var(--c) 12%, transparent); }
|
||||
.part.is-dim { opacity: 0.45; }
|
||||
.part__head { display: flex; justify-content: space-between; gap: 1rem; align-items: baseline; }
|
||||
.part__name { font-weight: 500; }
|
||||
.part__singers { color: var(--muted); font-size: 0.78rem; text-align: right; }
|
||||
.part__lines { margin: 0.35rem 0 0.5rem; font: italic 400 1.02rem/1.35 var(--serif); color: rgba(228, 226, 239, 0.8); }
|
||||
.part__lines p { margin: 0; }
|
||||
.part__chips { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.12rem 0.55rem 0.15rem 0.45rem;
|
||||
border: 1px solid color-mix(in srgb, var(--c) 60%, transparent);
|
||||
border-radius: 999px; background: none; cursor: pointer; font-size: 0.74rem;
|
||||
}
|
||||
.chip::before { content: ""; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--c); }
|
||||
.chip--character { border-style: dashed; }
|
||||
.chip:hover { background: color-mix(in srgb, var(--c) 16%, transparent); }
|
||||
.chip[aria-pressed="true"] { background: var(--c); color: var(--void); border-color: var(--c); }
|
||||
.chip[aria-pressed="true"]::before { background: var(--void); }
|
||||
|
||||
.panel__fact p { margin: 0; font: italic 400 1.15rem/1.4 var(--serif); }
|
||||
|
||||
.panel .audio-slot { border: 1px dashed var(--rule-strong); border-radius: 2px; color: var(--muted); font-size: 0.8rem; opacity: 1; }
|
||||
.panel .video-cards { display: grid; gap: 0.4rem; }
|
||||
.panel .video-card {
|
||||
position: relative; padding: 0.55rem 0.75rem 0.55rem 2.2rem; border: 1px solid var(--rule-strong); border-radius: 2px;
|
||||
color: var(--ink); font-size: 0.82rem;
|
||||
}
|
||||
.panel .video-card::before {
|
||||
content: ""; position: absolute; left: 0.8rem; top: 50%; translate: 0 -50%;
|
||||
border: 0.32rem solid transparent; border-left: 0.5rem solid var(--starlight); border-right: 0;
|
||||
}
|
||||
.panel .video-card:hover { border-color: var(--starlight); opacity: 1; }
|
||||
|
||||
.panel__nav { display: grid; grid-template-columns: 1fr 1fr; border-top: 1px solid var(--rule); }
|
||||
.panel__nav button {
|
||||
padding: 0.6rem 1rem 0.7rem; background: none; border: 0; text-align: left; cursor: pointer;
|
||||
font: italic 500 1rem var(--serif); color: var(--starlight);
|
||||
}
|
||||
.panel__nav button + button { text-align: right; border-left: 1px solid var(--rule); }
|
||||
.panel__nav button:disabled { color: var(--muted); cursor: default; }
|
||||
.panel__nav button:not(:disabled):hover { background: rgba(141, 151, 200, 0.1); }
|
||||
.panel__nav small { display: block; font: 400 0.7rem var(--sans); color: var(--muted); }
|
||||
|
||||
/* ---------------------------------------------------------------- credits (always visible) */
|
||||
.fan-credits {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||
padding: 0.7rem var(--gutter);
|
||||
background: rgba(5, 8, 22, 0.92);
|
||||
border-top: 1px solid var(--rule);
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- WebGL fallback */
|
||||
.no-webgl .sky { background:
|
||||
radial-gradient(1px 1px at 20% 30%, var(--starlight), transparent),
|
||||
radial-gradient(1px 1px at 70% 60%, var(--starlight), transparent),
|
||||
radial-gradient(1.5px 1.5px at 45% 80%, var(--starlight), transparent),
|
||||
radial-gradient(1px 1px at 85% 20%, var(--chart), transparent),
|
||||
radial-gradient(60% 50% at 30% 40%, rgba(196, 137, 63, 0.12), transparent),
|
||||
radial-gradient(50% 50% at 75% 60%, rgba(37, 105, 138, 0.16), transparent); }
|
||||
.no-webgl .ruler__hint { display: none; }
|
||||
.no-webgl .ruler { padding-bottom: 1.5rem; }
|
||||
.no-webgl .ruler__cursor { display: none; }
|
||||
.fallback {
|
||||
position: absolute;
|
||||
left: calc(var(--gutter) + 18rem);
|
||||
right: var(--gutter);
|
||||
top: var(--gutter);
|
||||
bottom: calc(var(--credits-h) + 6rem);
|
||||
overflow-y: auto;
|
||||
max-width: 44rem;
|
||||
}
|
||||
body.has-panel .fallback { right: calc(var(--panel-w) + var(--gutter) * 2); }
|
||||
.fallback__note {
|
||||
padding: 1.1rem 1.3rem; margin-bottom: 1.2rem;
|
||||
background: var(--glass); border: 1px solid var(--rule-strong); outline: 1px solid var(--rule); outline-offset: -5px;
|
||||
}
|
||||
.fallback__note h2 { margin: 0 0 0.4rem; font: italic 500 1.7rem var(--serif); color: var(--starlight); }
|
||||
.fallback__note p { margin: 0.3rem 0 0; max-width: 60ch; }
|
||||
.catalogue, .catalogue ol { list-style: none; margin: 0; padding: 0; }
|
||||
.catalogue__year h3 { margin: 1rem 0 0.3rem; font: 500 0.75rem var(--sans); color: var(--chart); border-bottom: 1px solid var(--rule); padding-bottom: 0.3rem; }
|
||||
.catalogue__star {
|
||||
display: grid; grid-template-columns: 1.4rem 1.6rem 1fr auto; gap: 0.6rem; align-items: center;
|
||||
width: 100%; padding: 0.35rem 0.4rem; background: none; border: 0; text-align: left; cursor: pointer;
|
||||
}
|
||||
.catalogue__star:hover, .catalogue__star[aria-current="true"] { background: rgba(141, 151, 200, 0.12); }
|
||||
.catalogue__mag { width: calc(0.25rem + var(--m) * 0.14rem); height: calc(0.25rem + var(--m) * 0.14rem); border-radius: 50%; background: var(--starlight); box-shadow: 0 0 8px var(--starlight); justify-self: center; }
|
||||
.catalogue__num { color: var(--muted); font-size: 0.72rem; }
|
||||
.catalogue__name { font: italic 500 1.2rem var(--serif); color: var(--starlight); }
|
||||
.catalogue__saga { color: var(--muted); font-size: 0.75rem; }
|
||||
.catalogue__star.is-lit .catalogue__name { color: var(--c); }
|
||||
.catalogue__star.is-lit .catalogue__mag { background: var(--c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--c) 35%, transparent); }
|
||||
.catalogue__star.is-dim { opacity: 0.4; }
|
||||
|
||||
/* ---------------------------------------------------------------- narrow screens */
|
||||
@media (max-width: 760px) {
|
||||
:root { --panel-w: 100%; }
|
||||
.masthead { max-width: 60vw; }
|
||||
.masthead__lede { display: none; }
|
||||
.legend {
|
||||
top: auto; left: var(--gutter); right: var(--gutter); width: auto;
|
||||
bottom: calc(var(--credits-h) + 5.6rem);
|
||||
max-height: 30vh; padding: 0.6rem 0.7rem;
|
||||
}
|
||||
.legend__group, .legend__key { display: none; }
|
||||
.legend__list { display: flex; flex-wrap: wrap; gap: 0.2rem; }
|
||||
.legend__motif { grid-template-columns: 1.6rem auto auto; width: auto; }
|
||||
.glyph { width: 1.6rem; }
|
||||
.ruler__hint { display: none; }
|
||||
.ruler { padding-bottom: 1.5rem; }
|
||||
body.has-panel .ruler, body.has-panel .legend { display: none; }
|
||||
.panel { left: 0; right: 0; top: auto; width: auto; height: 62vh; bottom: var(--credits-h); }
|
||||
.fallback { left: var(--gutter); bottom: calc(var(--credits-h) + 16rem); top: 5rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.panel { animation: none; }
|
||||
.ruler, .part, .ruler__dot, .sky-label--star { transition: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user