import type { RosterItem, RoutePoint } from "./data"; /** * The "Ground — true shape" projection of `274-transect.html`, in the page's * own units: an equirectangular plot with longitude shrunk by cos(lat), so * distances read true east–west and north–south. camera.ts decides how these * units land on screen. */ export const GROUND = { width: 1200, pad: 34, } as const; export type Projection = { height: number; x: (lon: number) => number; y: (lat: number) => number; }; export const projectGround = (route: RoutePoint[]): Projection => { const lats = route.map((p) => p[1]); const lons = route.map((p) => p[2]); const la0 = Math.min(...lats); const la1 = Math.max(...lats); const lo0 = Math.min(...lons); const lo1 = Math.max(...lons); const kx = Math.cos((((la0 + la1) / 2) * Math.PI) / 180); const sx = (lo1 - lo0) * kx; const sy = la1 - la0; const iw = GROUND.width - 2 * GROUND.pad; const ih = (iw * sy) / sx; return { height: ih + 2 * GROUND.pad, x: (lon) => GROUND.pad + (((lon - lo0) * kx) / sx) * iw, y: (lat) => GROUND.pad + ih - ((lat - la0) / sy) * ih, }; }; /** * Distance, in map units, between nodes that share exactly the same roster * coordinates (草木橋 / 滝の上こ線橋, ポン久留喜橋 / 久留喜橋, モトツ橋 / * モトツシェルター): small, so they read as a doubled dot on the road. Grows with * zoom like the nodes themselves. (1 map unit ≈ 44 m on the ground here.) */ export const COINCIDENT_SPREAD = 1.5; export type NodePosition = { x: number; y: number }; /** Direction of the road at `km`, as a unit vector in map units. */ const roadDirection = (route: RoutePoint[], km: number, proj: Projection) => { const at = (k: number) => { let i = 1; while (i < route.length - 1 && route[i][0] < k) { i++; } const [k0, lat0, lon0] = route[i - 1]; const [k1, lat1, lon1] = route[i]; const p = k1 === k0 ? 0 : Math.min(1, Math.max(0, (k - k0) / (k1 - k0))); return { x: proj.x(lon0 + (lon1 - lon0) * p), y: proj.y(lat0 + (lat1 - lat0) * p) }; }; const a = at(km - 0.1); const b = at(km + 0.1); const length = Math.hypot(b.x - a.x, b.y - a.y); return length > 0 ? { x: (b.x - a.x) / length, y: (b.y - a.y) / length } : { x: 1, y: 0 }; }; /** * Where each node is drawn, by object name. Usually its roster coordinate; nodes * sharing exactly the same coordinate are spread COINCIDENT_SPREAD apart along * the road there, in road order, centred on the shared point. All such pairs are * on-road structures, so they stay on the road. Keep the spread small: at 6, * モトツシェルター landed on 福山トンネル, only 90 m further on. */ export const nodePositions = ( items: RosterItem[], route: RoutePoint[], proj: Projection, ): Map => { const groups = new Map(); for (const item of items) { const key = `${item.lat},${item.lon}`; const group = groups.get(key) ?? []; group.push(item); groups.set(key, group); } const positions = new Map(); groups.forEach((group) => { const x = proj.x(group[0].lon); const y = proj.y(group[0].lat); if (group.length === 1) { positions.set(group[0].object, { x, y }); return; } const ordered = group.slice().sort((a, b) => a.km - b.km); const dir = roadDirection(route, ordered[0].km, proj); ordered.forEach((item, i) => { const offset = (i - (ordered.length - 1) / 2) * COINCIDENT_SPREAD; positions.set(item.object, { x: x + dir.x * offset, y: y + dir.y * offset }); }); }); return positions; }; export type Bounds = { minX: number; minY: number; maxX: number; maxY: number }; /** Bounds of the projected route from its start up to each point, inclusive. */ export const prefixBounds = (route: RoutePoint[], proj: Projection): Bounds[] => { const out: Bounds[] = []; for (const p of route) { const x = proj.x(p[2]); const y = proj.y(p[1]); const prev = out[out.length - 1]; out.push(prev ? extendBounds(prev, x, y) : { minX: x, minY: y, maxX: x, maxY: y }); } return out; }; export const extendBounds = (b: Bounds, x: number, y: number): Bounds => ({ minX: Math.min(b.minX, x), minY: Math.min(b.minY, y), maxX: Math.max(b.maxX, x), maxY: Math.max(b.maxY, y), }); export const routePath = (points: RoutePoint[], proj: Projection): string => points .map( (p, i) => `${i ? "L" : "M"}${proj.x(p[2]).toFixed(2)} ${proj.y(p[1]).toFixed(2)}`, ) .join("");