Files
Epic-The-Musical-Timeline/src/mockups/10-chart-scroll/chart.ts
T

120 lines
4.6 KiB
TypeScript

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;
}