// The voyage's geometry, pure (no three.js): nine places along a winding route and one stop per song. // The route runs roughly towards -z. Each saga has a waypoint the route passes; its place (island, cliffs, // whirlpool …) lies to one side of it, and that saga's songs are stops spread along the route past it. // Within a saga the stops are a few ship-lengths apart; between sagas the route crosses open sea. import { songs } from "../../data/album"; import type { PlaceId } from "../../data/album"; export interface Place { id: PlaceId; x: number; z: number; radius: number } export interface Stop { songId: string; placeId: PlaceId; x: number; z: number; heading: number; s: number /* 0..1 along route */ } export interface RoutePoint { x: number; z: number; heading: number } /** The route's waypoint per saga, which side of it the place lies on (+1 starboard, -1 port), and its radius. */ const WAYPOINTS: { id: PlaceId; x: number; z: number; side: 1 | -1; radius: number }[] = [ { id: "troy", x: 0, z: 0, side: -1, radius: 64 }, { id: "cyclops", x: -150, z: -290, side: -1, radius: 58 }, { id: "ocean", x: 110, z: -560, side: 1, radius: 60 }, { id: "circe", x: -90, z: -850, side: -1, radius: 60 }, { id: "underworld", x: 160, z: -1130, side: 1, radius: 56 }, { id: "thunder", x: -70, z: -1420, side: -1, radius: 62 }, { id: "wisdom", x: 170, z: -1700, side: 1, radius: 66 }, { id: "vengeance", x: -50, z: -1990, side: -1, radius: 60 }, { id: "ithaca", x: 120, z: -2280, side: 1, radius: 64 }, ]; /** How far off the route the place's centre lies, and how much of the route its stops spread over (× radius). */ const OFFSET = 0.9; const SPAN = 1.1; function unit(x: number, z: number): [number, number] { const l = Math.hypot(x, z) || 1; return [x / l, z / l]; } /** The place's centre and the route direction at its waypoint. */ function layout(i: number) { const w = WAYPOINTS[i]!; const a = WAYPOINTS[Math.max(0, i - 1)]!, b = WAYPOINTS[Math.min(WAYPOINTS.length - 1, i + 1)]!; const [dx, dz] = unit(b.x - a.x, b.z - a.z); const [nx, nz] = [-dz, dx]; // starboard when sailing along (dx, dz) return { w, dx, dz, cx: w.x + w.side * nx * OFFSET * w.radius, cz: w.z + w.side * nz * OFFSET * w.radius }; } export const PLACES: Place[] = WAYPOINTS.map((w, i) => { const l = layout(i); return { id: w.id, x: l.cx, z: l.cz, radius: w.radius }; }); /** Which side of the route each place lies on (+1 starboard, -1 port): the camera keeps to the other side. */ export const PLACE_SIDE: Record = Object.fromEntries(WAYPOINTS.map((w) => [w.id, w.side])) as Record; /* ---------- stop positions and the spline through them ---------- */ const POS: { x: number; z: number; songId: string; placeId: PlaceId }[] = []; WAYPOINTS.forEach((w, i) => { const l = layout(i); const own = songs.filter((s) => s.sagaId === w.id); own.forEach((song, k) => { const t = own.length > 1 ? k / (own.length - 1) : 0.5; const along = (t - 0.5) * SPAN * w.radius; POS.push({ x: w.x + l.dx * along, z: w.z + l.dz * along, songId: song.id, placeId: w.id }); }); }); const CUM: number[] = [0]; for (let i = 1; i < POS.length; i++) CUM.push(CUM[i - 1]! + Math.hypot(POS[i]!.x - POS[i - 1]!.x, POS[i]!.z - POS[i - 1]!.z)); /** The route's length in world units (straight lines between stops); s × ROUTE_LENGTH is a distance. */ export const ROUTE_LENGTH = CUM.at(-1)!; /** Route position s of each stop: cumulative straight-line distance, normalised to 0..1. */ const S: number[] = CUM.map((v) => v / ROUTE_LENGTH); /** Control points for the spline, with the ends extended so the first and last segments have neighbours. */ const P: [number, number][] = (() => { const pts = POS.map((p) => [p.x, p.z] as [number, number]); const n = pts.length; return [ [2 * pts[0]![0] - pts[1]![0], 2 * pts[0]![1] - pts[1]![1]], ...pts, [2 * pts[n - 1]![0] - pts[n - 2]![0], 2 * pts[n - 1]![1] - pts[n - 2]![1]], ]; })(); /** Centripetal Catmull–Rom (Barry–Goldman) on segment i (between stops i and i+1), u in 0..1. No loops or cusps * where short in-saga hops meet long crossings. */ function spline(i: number, u: number): [number, number] { const p0 = P[i]!, p1 = P[i + 1]!, p2 = P[i + 2]!, p3 = P[i + 3]!; const knot = (a: [number, number], b: [number, number]) => Math.max(1e-6, Math.sqrt(Math.hypot(b[0] - a[0], b[1] - a[1]))); const t0 = 0, t1 = t0 + knot(p0, p1), t2 = t1 + knot(p1, p2), t3 = t2 + knot(p2, p3); const t = t1 + (t2 - t1) * u; const mix = (a: [number, number], b: [number, number], ta: number, tb: number): [number, number] => { const k = (t - ta) / (tb - ta); return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k]; }; const a1 = mix(p0, p1, t0, t1), a2 = mix(p1, p2, t1, t2), a3 = mix(p2, p3, t2, t3); const b1 = mix(a1, a2, t0, t2), b2 = mix(a2, a3, t1, t3); return mix(b1, b2, t1, t2); } const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); /** The segment index and local parameter for a route position. */ function locate(s: number): { i: number; u: number } { const v = clamp01(s); let i = 0; while (i < S.length - 2 && v >= S[i + 1]!) i++; return { i, u: clamp01((v - S[i]!) / (S[i + 1]! - S[i]!)) }; } /** A point on the route and the heading there (radians; atan2(dx, dz), as three.js' rotation.y for a ship facing +z). */ export function pointAt(s: number): RoutePoint { const { i, u } = locate(s); const [x, z] = spline(i, u); const e = 1e-3; const [ax, az] = spline(i, Math.max(0, u - e)); const [bx, bz] = spline(i, Math.min(1, u + e)); return { x, z, heading: Math.atan2(bx - ax, bz - az) }; } /** Route position → fractional song index (0..39), linear between stops. */ export function sIndex(s: number): number { const { i, u } = locate(s); return i + u; } /** Fractional song index → route position. */ export function sAt(index: number): number { const i = Math.min(S.length - 2, Math.max(0, Math.floor(index))); const u = clamp01(index - i); return S[i]! + (S[i + 1]! - S[i]!) * u; } const STOPS: Stop[] = POS.map((p, i) => ({ ...p, s: S[i]!, heading: pointAt(S[i]!).heading })); /** One stop per song, in album order. */ export function stops(): Stop[] { return STOPS.map((s) => ({ ...s })); } export function stopOf(songId: string): Stop | undefined { return STOPS.find((s) => s.songId === songId); }