2026-09-17 10:32:19 +09:00
|
|
|
|
import type { RosterItem, RoutePoint } from "./data";
|
2026-09-17 09:40:53 +09:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-17 10:32:19 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-17 09:40:53 +09:00
|
|
|
|
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("");
|