415 lines
17 KiB
JavaScript
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 };
|
|
}
|