From 561c93062085ac8d0b730fbb293ac41eb6c2d113 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Thu, 17 Sep 2026 10:32:19 +0900 Subject: [PATCH] fix(274): separate nodes that share exact coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u --- src/song-274/Transect.tsx | 20 +++++++++--- src/song-274/data.ts | 3 ++ src/song-274/layout.ts | 68 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src/song-274/Transect.tsx b/src/song-274/Transect.tsx index 66d4d32..a1f1e14 100644 --- a/src/song-274/Transect.tsx +++ b/src/song-274/Transect.tsx @@ -18,7 +18,14 @@ import { type Frame, } from "./camera"; import type { Cue, RosterItem, TransectData } from "./data"; -import { extendBounds, GROUND, prefixBounds, projectGround, routePath } from "./layout"; +import { + extendBounds, + GROUND, + nodePositions, + prefixBounds, + projectGround, + routePath, +} from "./layout"; import { useWindowedAudioData } from "@remotion/media-utils"; import { isAwake, mixLooks, nodeLookAt, spectrumLook } from "./nodeAnimation"; import { @@ -100,6 +107,11 @@ export const Transect: React.FC = ({ palette, audio, data }) => { ); const parts = useMemo(() => narrationParts(data ? data.lines : []), [data]); const cueMap = useMemo(() => cuesByObject(data ? data.cues : []), [data]); + // Nodes at identical coordinates are spread slightly apart along the road. + const positions = useMemo( + () => (data && projection ? nodePositions(data.items, data.route, projection) : null), + [data, projection], + ); // Each node gets one log-spaced band, dealt out in a seeded shuffle. const frequencies = useMemo( () => shuffledBandFrequencies(data ? data.items.length : 0), @@ -112,7 +124,7 @@ export const Transect: React.FC = ({ palette, audio, data }) => { windowInSeconds: AUDIO_WINDOW_S, }); - if (!data || !projection || !kmAt) { + if (!data || !projection || !kmAt || !positions) { return ; } @@ -165,8 +177,8 @@ export const Transect: React.FC = ({ palette, audio, data }) => { const draw = ({ item, look }: (typeof nodes)[number]) => ( => { object: r.object, kind: r.kind, family: familyOf(r.kind, r.object), + km: Number(r.km), lat: Number(r.lat), lon: Number(r.lon), }); diff --git a/src/song-274/layout.ts b/src/song-274/layout.ts index dee8ee8..0187dcc 100644 --- a/src/song-274/layout.ts +++ b/src/song-274/layout.ts @@ -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 => { + 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. */