120 lines
4.6 KiB
TypeScript
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;
|
|
}
|