Files
ncmr-songs-videos/src/song-274/layout.ts
T
mikkeli 561c930620 fix(274): separate nodes that share exact coordinates
Three pairs of sung nodes have identical roster coordinates, so one circle fully
eclipsed the other: 草木橋 / 滝の上こ線橋, ポン久留喜橋 / 久留喜橋, and
モトツ橋 / モトツシェルター (a bridge roofed by a shelter). Editing the lyrics
index's lat/lon could not fix it: the video takes positions from the roster
(274-lookup.csv), joined on object.

- layout.ts: nodePositions() groups nodes by exact coordinate and spreads each
  group COINCIDENT_SPREAD (1.5 map units, about 35 m) apart along the road's
  direction there, in road order, centred on the shared point. All such pairs
  are on-road structures, so they stay on the road (within 0.06 units) and
  read as a doubled dot; partial overlap is intended, full eclipse is not.
  Along rather than across the road, and small: at 6 units モトツシェルター
  landed on 福山トンネル, 90 m further on.
- data.ts: RosterItem carries the roster's km, for road order and direction.
- Transect.tsx: draws nodes at those positions.

Verified: eslint + tsc clean on this commit's tree; close-ups at モトツ, resting
and lit, show both nodes on the road in order after モトツトンネル.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-17 10:32:19 +09:00

135 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, NodePosition> => {
const groups = new Map<string, RosterItem[]>();
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<string, NodePosition>();
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("");