import { songs } from "../../data/sample"; import { gapYears } from "../../data/query"; /* Chart geometry in chart units (1 unit = 1px at scale 1). Mockup 01's reading model: every song is one STEP after the last, and every in-story year adds YEAR units, so elapsed time reads as distance sailed. */ export const STEP = 220; export const YEAR = 600; export const START = 300; export const W = 4600; export const H = 626; export const SEA_TOP = 44; export const SEA_BOTTOM = 412; export const SCALE_Y = 396; export type Pt = readonly [number, number]; export const xs: number[] = []; songs.forEach((s, i) => { xs.push(i === 0 ? START : xs[i - 1]! + STEP + Math.max(0, gapYears(songs[i - 1]!, s)) * YEAR); }); /* Port heights: gentle, deterministic undulation; the Cyclops ports sit a little lower so the island has room above them. */ const PORT_Y = [262, 282, 266, 288, 280, 296, 304, 292, 300, 276]; export const ports: Pt[] = xs.map((x, i) => [x, PORT_Y[i] ?? 280] as const); /* Extra control points keep the long open-water gaps from being straight. */ const EXTRA: Pt[] = [ [130, 246], [1370, 322], [3040, 250], [3460, 318], [3800, 258], [4470, 262], ]; const CONTROL: Pt[] = [...EXTRA, ...ports].sort((a, b) => a[0] - b[0]); /* The route is y = f(x): a cubic Hermite spline over x, so it can never loop back on itself and stays left-to-right. */ const tangents = CONTROL.map((p, i) => { const a = CONTROL[Math.max(0, i - 1)]!; const b = CONTROL[Math.min(CONTROL.length - 1, i + 1)]!; return (b[1] - a[1]) / (b[0] - a[0]); }); export function routeY(x: number): number { const c = CONTROL; if (x <= c[0]![0]) return c[0]![1]; if (x >= c[c.length - 1]![0]) return c[c.length - 1]![1]; let i = 0; while (c[i + 1]![0] < x) i++; const [x0, y0] = c[i]!; const [x1, y1] = c[i + 1]!; const h = x1 - x0; const t = (x - x0) / h; const t2 = t * t, t3 = t2 * t; return (2 * t3 - 3 * t2 + 1) * y0 + (t3 - 2 * t2 + t) * h * tangents[i]! + (-2 * t3 + 3 * t2) * y1 + (t3 - t2) * h * tangents[i + 1]!; } export const routeAngle = (x: number) => (Math.atan2(routeY(x + 4) - routeY(x - 4), 8) * 180) / Math.PI; export const ROUTE_START = CONTROL[0]![0]; export const ROUTE_END = CONTROL[CONTROL.length - 1]![0]; /** Path along the route between two x positions, optionally offset vertically. */ export function routePath(a: number, b: number, dy = 0): string { let d = ""; for (let x = a; x < b; x += 6) d += `${d ? "L" : "M"}${x.toFixed(1)},${(routeY(x) + dy).toFixed(1)}`; return d + `L${b.toFixed(1)},${(routeY(b) + dy).toFixed(1)}`; } /* ---------------------------------------------------------------- coastlines */ function rand(seed: number) { let s = seed >>> 0; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); } function seg(p0: Pt, p1: Pt, p2: Pt, p3: Pt): string { const t = 1 / 6; return ` C${(p1[0] + (p2[0] - p0[0]) * t).toFixed(1)},${(p1[1] + (p2[1] - p0[1]) * t).toFixed(1)} ${(p2[0] - (p3[0] - p1[0]) * t).toFixed(1)},${(p2[1] - (p3[1] - p1[1]) * t).toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`; } export function closedSmooth(pts: Pt[]): string { const n = pts.length; let d = `M${pts[0]![0].toFixed(1)},${pts[0]![1].toFixed(1)}`; for (let i = 0; i < n; i++) d += seg(pts[(i - 1 + n) % n]!, pts[i]!, pts[(i + 1) % n]!, pts[(i + 2) % n]!); return d + "Z"; } export function openSmooth(pts: Pt[]): string { let d = `M${pts[0]![0].toFixed(1)},${pts[0]![1].toFixed(1)}`; for (let i = 0; i < pts.length - 1; i++) d += seg(pts[Math.max(0, i - 1)]!, pts[i]!, pts[i + 1]!, pts[Math.min(pts.length - 1, i + 2)]!); return d; } /** A seeded, irregular island outline; `grow` inflates it for offset water lines. */ export function island(cx: number, cy: number, rx: number, ry: number, seed: number, grow = 0, n = 30): string { const r = rand(seed); const jitter = Array.from({ length: n }, () => r()); const pts: Pt[] = []; for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2; const k = 1 + 0.13 * Math.sin(3 * a + seed) + 0.08 * Math.sin(7 * a + seed * 2) + (jitter[i]! - 0.5) * 0.12; pts.push([cx + Math.cos(a) * (rx * k + grow), cy + Math.sin(a) * (ry * k + grow)]); } return closedSmooth(pts); } /** Troy's mainland: a coast hanging from the top edge of the sea. */ export function troyCoast(dy = 0): Pt[] { const r = rand(7); const pts: Pt[] = []; for (let x = -20; x <= 1180; x += 40) { const fall = x > 900 ? ((x - 900) / 280) ** 2 * 120 : 0; pts.push([x, 112 + 24 * Math.sin(x / 150) + 15 * Math.sin(x / 61 + 1) + 8 * Math.sin(x / 23 + 2) + (r() - 0.5) * 18 - fall + dy]); } pts.push([1240, SEA_TOP - 6 + dy]); return pts; }