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
This commit is contained in:
2026-09-17 10:32:19 +09:00
parent 04963c929a
commit 561c930620
3 changed files with 86 additions and 5 deletions
+67 -1
View File
@@ -1,4 +1,4 @@
import type { RoutePoint } from "./data";
import type { RosterItem, RoutePoint } from "./data";
/**
* The "Ground — true shape" projection of `274-transect.html`, in the page's
@@ -38,6 +38,72 @@ export const projectGround = (route: RoutePoint[]): Projection => {
};
};
/**
* 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. */