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:
@@ -18,7 +18,14 @@ import {
|
|||||||
type Frame,
|
type Frame,
|
||||||
} from "./camera";
|
} from "./camera";
|
||||||
import type { Cue, RosterItem, TransectData } from "./data";
|
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 { useWindowedAudioData } from "@remotion/media-utils";
|
||||||
import { isAwake, mixLooks, nodeLookAt, spectrumLook } from "./nodeAnimation";
|
import { isAwake, mixLooks, nodeLookAt, spectrumLook } from "./nodeAnimation";
|
||||||
import {
|
import {
|
||||||
@@ -100,6 +107,11 @@ export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
|
|||||||
);
|
);
|
||||||
const parts = useMemo(() => narrationParts(data ? data.lines : []), [data]);
|
const parts = useMemo(() => narrationParts(data ? data.lines : []), [data]);
|
||||||
const cueMap = useMemo(() => cuesByObject(data ? data.cues : []), [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.
|
// Each node gets one log-spaced band, dealt out in a seeded shuffle.
|
||||||
const frequencies = useMemo(
|
const frequencies = useMemo(
|
||||||
() => shuffledBandFrequencies(data ? data.items.length : 0),
|
() => shuffledBandFrequencies(data ? data.items.length : 0),
|
||||||
@@ -112,7 +124,7 @@ export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
|
|||||||
windowInSeconds: AUDIO_WINDOW_S,
|
windowInSeconds: AUDIO_WINDOW_S,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data || !projection || !kmAt) {
|
if (!data || !projection || !kmAt || !positions) {
|
||||||
return <AbsoluteFill style={{ backgroundColor: palette.background }} />;
|
return <AbsoluteFill style={{ backgroundColor: palette.background }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,8 +177,8 @@ export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
|
|||||||
const draw = ({ item, look }: (typeof nodes)[number]) => (
|
const draw = ({ item, look }: (typeof nodes)[number]) => (
|
||||||
<circle
|
<circle
|
||||||
key={item.object}
|
key={item.object}
|
||||||
cx={projection.x(item.lon)}
|
cx={positions.get(item.object)?.x ?? projection.x(item.lon)}
|
||||||
cy={projection.y(item.lat)}
|
cy={positions.get(item.object)?.y ?? projection.y(item.lat)}
|
||||||
r={look.radius}
|
r={look.radius}
|
||||||
fill={palette[item.family]}
|
fill={palette[item.family]}
|
||||||
opacity={look.opacity}
|
opacity={look.opacity}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export type RosterItem = {
|
|||||||
object: string;
|
object: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
family: FeatureFamily;
|
family: FeatureFamily;
|
||||||
|
/** Distance along the road; the nearest road point for off-road objects. */
|
||||||
|
km: number;
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
};
|
};
|
||||||
@@ -163,6 +165,7 @@ export const loadTransectData = async (): Promise<TransectData> => {
|
|||||||
object: r.object,
|
object: r.object,
|
||||||
kind: r.kind,
|
kind: r.kind,
|
||||||
family: familyOf(r.kind, r.object),
|
family: familyOf(r.kind, r.object),
|
||||||
|
km: Number(r.km),
|
||||||
lat: Number(r.lat),
|
lat: Number(r.lat),
|
||||||
lon: Number(r.lon),
|
lon: Number(r.lon),
|
||||||
});
|
});
|
||||||
|
|||||||
+67
-1
@@ -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
|
* 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 };
|
export type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
|
||||||
|
|
||||||
/** Bounds of the projected route from its start up to each point, inclusive. */
|
/** Bounds of the projected route from its start up to each point, inclusive. */
|
||||||
|
|||||||
Reference in New Issue
Block a user