map/mapTransportOD.js
2026-05-28 00:30:09 +09:00

415 lines
17 KiB
JavaScript

import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside, pickEntities } from "./mapUtils.js";
import { makeUnionFind, pathAverageField, pathLengthCells } from "./mapTransportUtils.js";
// Phase 3: unified OD rail model
// --------------------------------
// Rail is no longer generated from isolated potential-field strokes. It is
// derived from a single transport-node model: major cities, prefectural seats,
// ports, large market towns, and external gateways create OD demand; MST-style
// connectivity gives the skeleton; high-demand pairs add loops; lower-tier
// settlements receive short branch connections only when the trunk is nearby.
export function buildUnifiedRailODNetwork(ctx) {
const {
seed,
sea,
elevation,
slope,
ridgeField,
valleyField,
basinField,
coastalLowland,
plain,
agriculture,
naturalBarrierScore,
passSuitability,
transportFields,
settlementDemand,
preliminaryUrbanInfluence,
preliminaryTownInfluence,
preliminaryVillageInfluence,
modernCities,
markets,
ports,
commercialPorts,
externalGateways,
geographicUrbanAnchors = [],
regionIdAt,
routeBetweenTrafficCandidates,
addCorridorInfluencePenalty,
transportRouteAcceptable,
pruneParallelSameMode,
cachedInfluenceFromPaths,
} = ctx;
const railways = [];
const branchRailways = [];
const debug = {
version: "phase3-unified-rail-od-v1",
strategy: "OD nodes -> MST trunk -> demand loops -> short branches",
nodeCounts: {},
trunkPairsConsidered: 0,
trunkPairsRouted: 0,
loopPairsRouted: 0,
branchPairsRouted: 0,
rejected: {},
nodes: [],
trunkCorridors: [],
loopCorridors: [],
branchCorridors: [],
parallelPruning: null,
};
const reject = (reason) => { debug.rejected[reason] = (debug.rejected[reason] || 0) + 1; };
function fieldValue(field, i, fallback = 0) {
const v = field?.[i];
return Number.isFinite(v) ? v : fallback;
}
function railCostAt(x, y) {
if (!inside(x, y)) return INF;
const i = indexOf(x, y);
return sea[i] ? INF : transportFields.rail[i];
}
function populationProxy(p, fallback = 12000) {
if (!p) return fallback;
if (Number.isFinite(p.population) && p.population > 0) return p.population;
if (p.portClass === "major") return 85000;
if (p.portClass === "regional") return 42000;
const i = inside(p.x, p.y) ? indexOf(p.x, p.y) : -1;
if (i < 0) return fallback;
return Math.max(fallback, Math.round(
fieldValue(preliminaryUrbanInfluence, i) * 160000 +
fieldValue(preliminaryTownInfluence, i) * 65000 +
fieldValue(preliminaryVillageInfluence, i) * 15000 +
fieldValue(settlementDemand, i) * 45000
));
}
function nearbyRailAnchor(point, role = "rail-node", options = {}) {
if (!point || !inside(point.x, point.y)) return null;
const inner = options.inner ?? 0;
const outer = options.outer ?? (role.includes("city") || role.includes("capital") ? 7 : role.includes("port") ? 8 : 5);
let best = null;
for (let dy = -outer; dy <= outer; dy++) {
for (let dx = -outer; dx <= outer; dx++) {
const x = Math.round(point.x + dx);
const y = Math.round(point.y + dy);
if (!inside(x, y)) continue;
const d = Math.hypot(dx, dy);
if (d < inner || d > outer) continue;
const i = indexOf(x, y);
if (sea[i] || transportFields.rail[i] >= INF) continue;
const density = fieldValue(settlementDemand, i);
const terrain =
fieldValue(transportFields.railPotential, i) * 1.35 +
fieldValue(preliminaryUrbanInfluence, i) * 0.42 +
fieldValue(preliminaryTownInfluence, i) * 0.46 +
valleyField[i] * 0.28 +
basinField[i] * 0.20 +
coastalLowland[i] * 0.20 +
plain[i] * 0.16 -
slope[i] * 1.10 -
ridgeField[i] * 0.72 -
Math.max(0, elevation[i] - 0.58) * 1.45 -
Math.max(0, density - 0.78) * 0.32;
const centerPenalty = d * (role.includes("city") || role.includes("capital") ? 0.025 : 0.060);
const score = terrain - centerPenalty + hash2(x, y, seed + 23101 + point.x * 3 + point.y * 7) * 0.035;
if (!best || score > best.score) best = {
x,
y,
score,
role,
source: point,
regionId: regionIdAt(x, y),
population: populationProxy(point),
name: point.name,
kind: point.kind,
portClass: point.portClass,
};
}
}
return best;
}
function dedupeNodes(nodes, minDistance = 5.5) {
const sorted = nodes
.filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && railCostAt(p.x, p.y) < INF)
.sort((a, b) => (b.score || 0) - (a.score || 0));
const out = [];
for (const p of sorted) {
if (out.every((q) => Math.hypot(q.x - p.x, q.y - p.y) >= minDistance)) out.push(p);
}
return out;
}
function lineStats(a, b, costField = transportFields.rail) {
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
let cost = 0;
let barrier = 0;
let high = 0;
let seaHits = 0;
let n = 0;
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!inside(x, y)) { seaHits++; n++; continue; }
const i = indexOf(x, y);
if (sea[i] || costField[i] >= INF) {
seaHits++;
cost += 8;
barrier += 1;
n++;
continue;
}
cost += costField[i];
barrier += clamp(
fieldValue(naturalBarrierScore, i) * 0.80 +
ridgeField[i] * 0.36 +
slope[i] * 0.42 +
Math.max(0, elevation[i] - 0.58) * 0.65 -
fieldValue(passSuitability, i) * 0.42 -
valleyField[i] * 0.12
);
if (elevation[i] > 0.68 || slope[i] > 0.48) high++;
n++;
}
return {
avgCost: n ? cost / n : INF,
barrier: n ? barrier / n : 1,
highShare: n ? high / n : 1,
seaShare: n ? seaHits / n : 1,
};
}
function odDemand(a, b, mode = "trunk") {
const d = Math.hypot(a.x - b.x, a.y - b.y);
const popDemand = Math.sqrt(Math.max(5000, a.population || 0) * Math.max(5000, b.population || 0));
const roleBonus =
(a.role?.includes("capital") || b.role?.includes("capital") ? 0.24 : 0) +
(a.role?.includes("regional") || b.role?.includes("regional") ? 0.18 : 0) +
(a.role?.includes("port") || b.role?.includes("port") ? 0.14 : 0) +
(a.role?.includes("external") || b.role?.includes("external") ? 0.20 : 0);
const distanceBand = mode === "branch"
? clamp(1 - Math.abs(d - 24) / 34)
: clamp(1 - Math.abs(d - 58) / 74);
return popDemand / (mode === "branch" ? 95000 : 145000) + roleBonus + distanceBand * (mode === "branch" ? 0.18 : 0.28);
}
function pairScore(a, b, mode = "trunk") {
const d = Math.hypot(a.x - b.x, a.y - b.y);
const stats = lineStats(a, b);
if (stats.seaShare > 0.06) return null;
if (stats.highShare > (mode === "branch" ? 0.22 : 0.16)) return null;
const demand = odDemand(a, b, mode);
const crossRegion = a.regionId !== b.regionId ? 0.10 : 0;
const barrierPenalty = 1 + stats.barrier * (mode === "branch" ? 1.15 : 1.45) + stats.avgCost * 0.30 + stats.seaShare * 4.0;
const score = d * barrierPenalty / Math.max(0.18, demand + crossRegion);
return { a, b, d, demand, stats, score };
}
function routeRailPair(pair, penalty, branch = false) {
const path = routeBetweenTrafficCandidates(pair.a, pair.b, "rail", transportFields.rail, penalty, {
curvePenalty: branch ? 0.135 : 0.150,
penaltyStrength: branch ? 0.92 : 1.28,
terrainFlowBias: branch ? 0.16 : 0.13,
surfaceGrain: 0.010,
relaxRadius: 1,
relaxLineWeight: branch ? 0.44 : 0.50,
snapRadius: branch ? 2.4 : 2.8,
searchPad: Math.ceil(Math.max(22, Math.min(68, pair.d * 0.48))),
maxPathLength: pair.d * (branch ? 2.28 : 2.48) + (branch ? 18 : 36),
maxSeaRun: 1,
maxSeaShare: 0.006,
});
const len = pathLengthCells(path);
if (len < (branch ? 5 : 14)) { reject(branch ? "branchTooShort" : "trunkTooShort"); return []; }
if (len > pair.d * (branch ? 2.36 : 2.58) + (branch ? 24 : 42)) { reject(branch ? "branchTooLong" : "trunkTooLong"); return []; }
if (!transportRouteAcceptable(path, "rail", transportFields.railPotential, penalty, {
minLength: branch ? 4 : 12,
maxLength: pair.d * (branch ? 2.40 : 2.66) + (branch ? 26 : 46),
maxCompactness: branch ? 2.85 : 2.65,
maxSteepShare: branch ? 0.24 : 0.20,
maxHighElevationShare: branch ? 0.04 : 0.02,
minAvgPotential: branch ? 0.06 : 0.10,
})) { reject(branch ? "branchQuality" : "trunkQuality"); return []; }
if (pathAverageField(path, transportFields.railPotential) < (branch ? 0.08 : 0.13) && pair.d > 24) { reject(branch ? "branchLowPotential" : "trunkLowPotential"); return []; }
return path;
}
function keyOf(p) { return `${p.x},${p.y}`; }
const regionalCityNodes = modernCities
.filter((c) => c.isRegionalCapital || c.isPrefecturalCapital || (c.population || 0) >= 90000)
.map((c) => nearbyRailAnchor(c, c.isRegionalCapital ? "regional-capital-rail" : c.isPrefecturalCapital ? "prefectural-capital-rail" : "major-city-rail", { outer: 8 }))
.filter(Boolean);
const secondaryCityNodes = modernCities
.filter((c) => !regionalCityNodes.some((n) => n.source === c) && (c.population || 0) >= 38000)
.map((c) => nearbyRailAnchor(c, "secondary-city-rail", { outer: 7 }))
.filter(Boolean);
const portNodes = [...commercialPorts, ...ports]
.filter((p, idx, arr) => arr.findIndex((q) => q.x === p.x && q.y === p.y) === idx)
.filter((p) => p.portClass === "major" || p.portClass === "regional" || (p.population || 0) >= 16000)
.map((p) => nearbyRailAnchor(p, "port-rail", { outer: 8 }))
.filter(Boolean);
const externalNodes = externalGateways
.map((g) => nearbyRailAnchor({ ...g, population: 60000 }, "external-rail-gateway", { outer: 5 }))
.filter(Boolean);
const anchorNodes = geographicUrbanAnchors
.filter((a) => (a.score || 0) > 0.76)
.slice(0, 8)
.map((a) => nearbyRailAnchor({ ...a, population: a.population || 52000 }, "geo-anchor-rail", { outer: 6 }))
.filter(Boolean);
let trunkNodes = dedupeNodes([...regionalCityNodes, ...secondaryCityNodes, ...portNodes, ...externalNodes, ...anchorNodes], 7.5)
.sort((a, b) => (b.population || 0) - (a.population || 0))
.slice(0, 34);
if (trunkNodes.length < 2) {
trunkNodes = dedupeNodes([
...modernCities.map((c) => nearbyRailAnchor(c, "fallback-city-rail", { outer: 8 })),
...markets.filter((m) => (m.population || 0) >= 14000).map((m) => nearbyRailAnchor(m, "fallback-market-rail", { outer: 5 })),
], 7).slice(0, 18);
}
debug.nodeCounts = {
regionalCityNodes: regionalCityNodes.length,
secondaryCityNodes: secondaryCityNodes.length,
portNodes: portNodes.length,
externalNodes: externalNodes.length,
geographicAnchorNodes: anchorNodes.length,
trunkNodes: trunkNodes.length,
};
debug.nodes = trunkNodes.map((n) => ({ x: n.x, y: n.y, role: n.role, population: n.population, regionId: n.regionId }));
const trunkPairs = [];
for (let a = 0; a < trunkNodes.length; a++) {
for (let b = a + 1; b < trunkNodes.length; b++) {
const A = trunkNodes[a];
const B = trunkNodes[b];
const d = Math.hypot(A.x - B.x, A.y - B.y);
if (d < 16 || d > 150) { reject("trunkDistanceEnvelope"); continue; }
const pair = pairScore(A, B, "trunk");
if (!pair) { reject("trunkLineStats"); continue; }
trunkPairs.push(pair);
}
}
trunkPairs.sort((a, b) => a.score - b.score);
debug.trunkPairsConsidered = trunkPairs.length;
const uf = makeUnionFind(trunkNodes, keyOf);
const penalty = new Float32Array(SIZE);
const maxTrunk = Math.min(18, Math.max(4, trunkNodes.length - 1));
let connectedEdges = 0;
for (const pair of trunkPairs) {
if (connectedEdges >= maxTrunk) break;
const ak = keyOf(pair.a);
const bk = keyOf(pair.b);
if (uf.find(ak) === uf.find(bk)) continue;
const path = routeRailPair(pair, penalty, false);
if (!path.length) continue;
railways.push(path);
addCorridorInfluencePenalty(penalty, path, 9, 0.74);
uf.unite(ak, bk);
connectedEdges++;
debug.trunkPairsRouted++;
debug.trunkCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), demand: Math.round(pair.demand * 100) / 100, length: Math.round(pathLengthCells(path)), path });
}
// Add a few loops / redundant high-demand links after MST. These are the
// Shinkansen/main-line analogues around dense corridors and port approaches.
let loopAdded = 0;
const trunkInfluenceBeforeLoops = cachedInfluenceFromPaths(railways, 5, "rail-od:trunk-before-loops");
for (const pair of trunkPairs) {
if (loopAdded >= Math.min(7, Math.max(2, Math.ceil(trunkNodes.length / 5)))) break;
const ai = indexOf(pair.a.x, pair.a.y);
const bi = indexOf(pair.b.x, pair.b.y);
if ((trunkInfluenceBeforeLoops[ai] || 0) > 0.62 && (trunkInfluenceBeforeLoops[bi] || 0) > 0.62 && pair.demand < 0.88) continue;
if (pair.score > 92 && pair.demand < 0.78) continue;
const path = routeRailPair(pair, penalty, false);
if (!path.length) continue;
railways.push(path);
addCorridorInfluencePenalty(penalty, path, 11, 0.66);
loopAdded++;
debug.loopPairsRouted++;
debug.loopCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), demand: Math.round(pair.demand * 100) / 100, length: Math.round(pathLengthCells(path)), path });
}
const railInfluence = cachedInfluenceFromPaths(railways, 8, "rail-od:trunk-for-branches");
const branchCandidates = dedupeNodes([
...modernCities
.filter((c) => (c.population || 0) >= 22000 && (c.population || 0) < 90000)
.map((c) => nearbyRailAnchor(c, "branch-city-rail", { outer: 6 })),
...markets
.filter((m) => (m.population || 0) >= 16000)
.map((m) => nearbyRailAnchor(m, "branch-market-rail", { outer: 5 })),
...ports
.filter((p) => p.portClass === "regional" || (p.population || 0) >= 10000)
.map((p) => nearbyRailAnchor(p, "branch-port-rail", { outer: 7 })),
], 6).sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 36);
const trunkTargets = [];
for (const path of railways) {
const stride = Math.max(5, Math.floor(path.length / 18));
for (let k = 0; k < path.length; k += stride) {
const [x, y] = path[k];
if (inside(x, y) && !sea[indexOf(x, y)]) trunkTargets.push({ x, y, role: "rail-trunk-cell", population: 70000, score: 0.8, regionId: regionIdAt(x, y) });
}
}
trunkTargets.push(...trunkNodes);
let branchAdded = 0;
for (const node of branchCandidates) {
if (branchAdded >= 14) break;
const ni = indexOf(node.x, node.y);
if ((railInfluence[ni] || 0) > 0.34) continue;
const options = trunkTargets
.map((q) => {
const d = Math.hypot(q.x - node.x, q.y - node.y);
if (d < 8 || d > 54) return null;
const pair = pairScore(node, q, "branch");
if (!pair) return null;
return pair;
})
.filter(Boolean)
.sort((a, b) => a.score - b.score);
for (const pair of options.slice(0, 5)) {
if (odDemand(pair.a, pair.b, "branch") < 0.34 && pair.d > 32) { reject("branchWeakDemand"); continue; }
const path = routeRailPair(pair, penalty, true);
if (!path.length) continue;
branchRailways.push(path);
addCorridorInfluencePenalty(penalty, path, 6, 0.42);
branchAdded++;
debug.branchPairsRouted++;
debug.branchCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), demand: Math.round(pair.demand * 100) / 100, length: Math.round(pathLengthCells(path)), path });
break;
}
}
debug.parallelPruning = pruneParallelSameMode([...railways, ...branchRailways], "rail", transportFields.railPotential, {
minKeep: Math.min(3, railways.length),
radius: 2,
threshold: 0.62,
shortLength: 24,
});
// pruneParallelSameMode mutates only the temporary array above, so repeat a
// conservative in-place pass per layer to preserve trunk/branch classification.
debug.trunkParallelPruning = pruneParallelSameMode(railways, "rail", transportFields.railPotential, {
minKeep: 2,
radius: 2,
threshold: 0.66,
shortLength: 30,
});
debug.branchParallelPruning = pruneParallelSameMode(branchRailways, "rail", transportFields.railPotential, {
minKeep: 0,
radius: 2,
threshold: 0.70,
shortLength: 18,
});
debug.finalRailwayCount = railways.length;
debug.finalBranchRailwayCount = branchRailways.length;
return { railways, branchRailways, debug };
}