map/mapPostAdminTransport.js
2026-05-28 02:47:33 +09:00

413 lines
18 KiB
JavaScript

import { MAP_W, MAP_H, SIZE, indexOf, inside } from "./mapUtils.js";
import { pathLengthCells } from "./mapTransport.js";
function cellKey(x, y) { return `${Math.round(x)},${Math.round(y)}`; }
function pathTouchesCell(path, x, y, radius = 0.65) {
if (!path || path.length < 1) return false;
for (const [px, py] of path) if (Math.hypot(px - x, py - y) <= radius) return true;
return false;
}
function anyPathTouches(paths, p, radius = 0.65) {
return (paths || []).some((path) => pathTouchesCell(path, p.x, p.y, radius));
}
function pathTerrainRuns(path, terrain = null) {
const sea = terrain?.sea;
const elevation = terrain?.elevation;
const ridgeField = terrain?.ridgeField;
const naturalBarrierScore = terrain?.naturalBarrierScore;
let seaRun = 0, maxSeaRun = 0, tunnelRun = 0, maxTunnelRun = 0;
for (const [x, y] of path || []) {
if (!inside(x, y)) continue;
const i = indexOf(x, y);
const isSea = Boolean(sea?.[i]);
const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.74 && (ridgeField?.[i] || 0) >= 0.46) || (naturalBarrierScore?.[i] || 0) >= 0.82);
if (isSea) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); } else seaRun = 0;
if (isTunnel) { tunnelRun++; maxTunnelRun = Math.max(maxTunnelRun, tunnelRun); } else tunnelRun = 0;
}
return { maxSeaRun, maxTunnelRun };
}
function directPath(a, b, options = {}) {
if (!a || !b) return [];
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
const out = [];
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)) return [];
if (!out.length || out[out.length - 1][0] !== x || out[out.length - 1][1] !== y) out.push([x, y]);
}
if (options.maxLength && pathLengthCells(out) > options.maxLength) return [];
if (options.terrain) {
const runs = pathTerrainRuns(out, options.terrain);
if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) return [];
if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) return [];
}
return out;
}
function nearestPointOnPaths(paths, p, maxDistance = Infinity) {
let best = null;
for (const path of paths || []) {
for (const [x, y] of path || []) {
const d = Math.hypot(p.x - x, p.y - y);
if (d <= maxDistance && (!best || d < best.d)) best = { x, y, d };
}
}
return best;
}
function nearestEntity(entities, p, maxDistance = Infinity) {
let best = null;
for (const q of entities || []) {
if (!q || !Number.isFinite(q.x) || !Number.isFinite(q.y) || (q.x === p.x && q.y === p.y)) continue;
const d = Math.hypot(q.x - p.x, q.y - p.y);
if (d <= maxDistance && (!best || d < best.d)) best = { ...q, d };
}
return best;
}
function dedupePaths(paths, sampleStep = 2) {
const seen = new Set();
const kept = [];
for (const path of paths || []) {
if (!path || path.length < 2) continue;
const cleaned = [];
for (const pt of path) {
const x = Math.round(pt[0]);
const y = Math.round(pt[1]);
if (!inside(x, y)) continue;
if (!cleaned.length || cleaned[cleaned.length - 1][0] !== x || cleaned[cleaned.length - 1][1] !== y) cleaned.push([x, y]);
}
if (cleaned.length < 2) continue;
const sigFor = (arr) => arr.map((p, i) => (i % sampleStep === 0 || i === arr.length - 1) ? `${p[0]},${p[1]}` : "").filter(Boolean).join("|");
const f = sigFor(cleaned);
const r = sigFor([...cleaned].reverse());
const sig = f < r ? f : r;
if (seen.has(sig)) continue;
seen.add(sig);
kept.push(cleaned);
}
return kept;
}
function addInterchange(interchanges, x, y, source = "post-admin-expressway-endpoint") {
x = Math.round(x); y = Math.round(y);
if (!inside(x, y)) return false;
if ((interchanges || []).some((p) => Math.hypot(p.x - x, p.y - y) <= 2.5)) return false;
interchanges.push({ x, y, kind: "Interchange", score: 1, source });
return true;
}
function smoothPath(path, passes = 1) {
let cur = (path || []).map(([x, y]) => [Math.round(x), Math.round(y)]);
for (let pass = 0; pass < passes; pass++) {
if (cur.length < 3) break;
const next = [cur[0]];
for (let i = 1; i < cur.length - 1; i++) {
const [ax, ay] = cur[i - 1];
const [bx, by] = cur[i];
const [cx, cy] = cur[i + 1];
const x = Math.round((ax + bx * 2 + cx) / 4);
const y = Math.round((ay + by * 2 + cy) / 4);
if (!next.length || next[next.length - 1][0] !== x || next[next.length - 1][1] !== y) next.push([x, y]);
}
next.push(cur[cur.length - 1]);
cur = next;
}
return cur;
}
function rebuildInfluence(paths, radius = 5) {
const field = new Float32Array(SIZE);
const r = Math.ceil(radius);
for (const path of paths || []) {
for (const [px, py] of path || []) {
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
const x = px + dx, y = py + dy;
if (!inside(x, y)) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const i = indexOf(x, y);
field[i] = Math.max(field[i], Math.max(0, 1 - d / Math.max(0.001, radius)));
}
}
}
return field;
}
export function finalizeAdminAwareTransport({ seed, terrain, features, admin, geography = null }) {
if (!features || !admin) return features;
const minorRoads = features.minorRoads || [];
const nationalRoads = features.nationalRoads || [];
const externalRoads = features.externalRoads || [];
const expressways = features.expressways || [];
const externalExpressways = features.externalExpressways || [];
const interchanges = features.interchanges || [];
const adminCenters = admin.adminCentersRaw || features.adminCenters || [];
const townsForNational = [
...(features.modernCities || []).filter((p) => (p.population || 0) >= 5000),
...(features.markets || []).filter((p) => (p.population || 0) >= 5000),
...(features.villages || []).filter((p) => (p.population || 0) >= 5000),
...(features.ports || []).filter((p) => (p.population || 0) >= 5000 || p.portClass === "major" || p.portClass === "regional" || p.portClass === "fishing"),
];
const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0, expresswayMajorCityLinksAdded: 0, railCityChainsAdded: 0, railCityChainCitiesCovered: 0 };
// Local roads after admin: every municipal office cell should lie on a road.
const settlementTargets = [...(features.modernCities || []), ...(features.markets || []), ...(features.villages || []), ...(features.ports || [])];
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y)) continue;
debug.adminCentersChecked++;
const roadSet = [...minorRoads, ...nationalRoads, ...externalRoads];
if (anyPathTouches(roadSet, center, 0.65)) continue;
const nearRoad = nearestPointOnPaths(roadSet, center, 22);
const nearSettlement = nearestEntity(settlementTargets, center, 18);
const target = nearRoad || nearSettlement;
let path = target ? directPath(center, target, { maxLength: 34 }) : [];
if (!path.length) {
const x = center.x, y = center.y;
const a = { x: Math.max(0, x - 2), y };
const b = { x: Math.min(MAP_W - 1, x + 2), y };
path = directPath(a, b, { maxLength: 8 });
}
if (path.length >= 2 && pathTouchesCell(path, center.x, center.y, 0.65)) {
minorRoads.push(path);
debug.adminLocalRoadsAdded++;
}
}
// National roads after admin/settlements: try to cover red-dot towns by chain routes instead of one spur per town.
function concatPaths(parts) {
const out = [];
for (const part of parts || []) {
if (!part || part.length < 2) continue;
for (const pt of part) {
if (!out.length || out[out.length - 1][0] !== pt[0] || out[out.length - 1][1] !== pt[1]) out.push(pt);
}
}
return out;
}
function townWeight(p) {
return (p.population || 0) + (p.isPrefecturalCapital ? 800000 : 0) + (p.isRegionalCapital ? 350000 : 0) + (p.portClass === "major" ? 220000 : p.portClass === "regional" ? 120000 : p.portClass === "fishing" ? 45000 : 0);
}
function nearestTrunkOrHub(p, maxDistance = 85) {
const trunk = nearestPointOnPaths([...nationalRoads, ...externalRoads], p, maxDistance);
if (trunk) return trunk;
return nearestEntity([...(features.modernCities || []), ...(features.ports || []), ...(features.markets || []), ...(features.externalGateways || [])], p, maxDistance);
}
function buildTownChain(start, pool, maxHops = 7) {
const chain = [start];
let cur = start;
for (let hop = 1; hop < maxHops; hop++) {
let best = null;
for (const town of pool) {
if (chain.includes(town)) continue;
const d = Math.hypot(cur.x - town.x, cur.y - town.y);
if (d > 42) continue;
const score = d - Math.min(18, Math.sqrt(Math.max(0, townWeight(town))) / 70);
if (!best || score < best.score) best = { town, d, score };
}
if (!best) break;
chain.push(best.town);
cur = best.town;
}
return chain;
}
function addNationalTownChains() {
let uncovered = townsForNational
.filter((town) => town && inside(town.x, town.y) && !anyPathTouches([...nationalRoads, ...externalRoads], town, 0.65))
.sort((a, b) => townWeight(b) - townWeight(a));
let chainsAdded = 0;
let townsCovered = 0;
while (uncovered.length) {
const start = uncovered.shift();
const chain = buildTownChain(start, uncovered, 7);
uncovered = uncovered.filter((town) => !chain.includes(town));
const parts = [];
const before = nearestTrunkOrHub(chain[0], 80);
if (before) {
const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
if (p.length) parts.push(p);
}
for (let i = 1; i < chain.length; i++) {
const d = Math.hypot(chain[i - 1].x - chain[i].x, chain[i - 1].y - chain[i].y);
const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 10 });
if (p.length) parts.push(p);
}
const after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null;
if (after) {
const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
if (p.length) parts.push(p);
}
let path = concatPaths(parts);
if (path.length < 2) {
const target = nearestTrunkOrHub(chain[0], 90);
path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 10 }) : [];
}
if (path.length >= 2 && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) {
nationalRoads.push(path);
chainsAdded++;
townsCovered += chain.filter((town) => pathTouchesCell(path, town.x, town.y, 0.65)).length;
}
}
return { chainsAdded, townsCovered };
}
const chainDebug = addNationalTownChains();
debug.nationalTownChainsAdded = chainDebug.chainsAdded;
debug.nationalTownChainTownsCovered = chainDebug.townsCovered;
debug.nationalTownSpursAdded = chainDebug.chainsAdded;
function majorCityKey(city) { return city?.name || `${Math.round(city.x)},${Math.round(city.y)}`; }
function expresswayCityComponents(cities, radius = 8.0) {
const parent = new Map();
function find(k) {
const p = parent.get(k);
if (p === k) return k;
const r = find(p);
parent.set(k, r);
return r;
}
function unite(a, b) {
const ra = find(a), rb = find(b);
if (ra !== rb) parent.set(ra, rb);
}
for (const city of cities) parent.set(majorCityKey(city), majorCityKey(city));
for (const path of [...expressways, ...externalExpressways]) {
const near = cities.filter((city) => pathTouchesCell(path, city.x, city.y, radius));
if (near.length >= 2) {
const first = majorCityKey(near[0]);
for (const city of near.slice(1)) unite(first, majorCityKey(city));
}
}
return new Map(cities.map((city) => [majorCityKey(city), find(majorCityKey(city))]));
}
function ensureMajorCityExpresswayLinks(minPopulation = 100000) {
const cities = (features.modernCities || [])
.filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y))
.sort((a, b) => (b.population || 0) - (a.population || 0));
const result = { minPopulation, checked: cities.length, added: 0 };
if (cities.length < 2) return result;
for (let iter = 0; iter < cities.length * 2; iter++) {
const comps = expresswayCityComponents(cities);
if (new Set(comps.values()).size <= 1) break;
let best = null;
for (const a of cities) {
for (const b of cities) {
if (a === b || comps.get(majorCityKey(a)) === comps.get(majorCityKey(b))) continue;
const d = Math.hypot(a.x - b.x, a.y - b.y);
const score = d / Math.max(1, Math.log2(Math.sqrt((a.population || 1) * (b.population || 1))));
if (!best || score < best.score) best = { a, b, d, score };
}
}
if (!best) break;
let path = directPath(best.a, best.b, { maxLength: best.d * 1.18 + 12, terrain, maxSeaRun: 20 });
if (!path.length) path = directPath(best.a, best.b, { maxLength: best.d * 1.38 + 28, terrain });
if (!path.length) break;
path = smoothPath(path, 2);
expressways.push(path);
result.added++;
}
return result;
}
function cityRailChain(minPopulation = 50000) {
const cities = (features.modernCities || [])
.filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y))
.sort((a, b) => a.x - b.x || a.y - b.y);
const result = { minPopulation, checked: cities.length, chainsAdded: 0, citiesCovered: 0 };
if (cities.length < 2) return result;
const existingRail = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])];
const uncovered = cities.filter((city) => !anyPathTouches(existingRail, city, 1.2));
if (!uncovered.length) return result;
const remaining = uncovered.slice();
let chain = [remaining.shift()];
while (remaining.length) {
const cur = chain[chain.length - 1];
let bestIndex = 0;
let bestD = Infinity;
for (let i = 0; i < remaining.length; i++) {
const d = Math.hypot(cur.x - remaining[i].x, cur.y - remaining[i].y);
if (d < bestD) { bestD = d; bestIndex = i; }
}
chain.push(remaining.splice(bestIndex, 1)[0]);
}
const parts = [];
for (let i = 1; i < chain.length; i++) {
const a = chain[i - 1], b = chain[i];
const d = Math.hypot(a.x - b.x, a.y - b.y);
let p = directPath(a, b, { maxLength: d * 1.55 + 20, terrain, maxSeaRun: 10, maxTunnelRun: 10 });
if (!p.length) p = directPath(a, b, { maxLength: d * 1.85 + 40, terrain });
if (p.length) parts.push(p);
}
const path = concatPaths(parts);
if (path.length >= 2) {
features.railways = features.railways || [];
features.railways.push(smoothPath(path, 1));
result.chainsAdded = 1;
result.citiesCovered = chain.filter((city) => pathTouchesCell(path, city.x, city.y, 1.2)).length;
}
return result;
}
const railDebug = cityRailChain(50000);
debug.railCityChainsAdded = railDebug.chainsAdded;
debug.railCityChainCitiesCovered = railDebug.citiesCovered;
const expressDebug = ensureMajorCityExpresswayLinks(100000);
debug.expresswayMajorCityLinksAdded = expressDebug.added;
// Expressway finalization after administration: smooth and ensure both endpoints are ICs.
for (let i = 0; i < expressways.length; i++) {
const smoothed = smoothPath(expressways[i], 2);
if (smoothed.length >= 2) {
expressways[i] = smoothed;
debug.expresswaysSmoothed++;
}
}
for (const path of [...expressways, ...externalExpressways]) {
if (!path || path.length < 2) continue;
const a = path[0];
const b = path[path.length - 1];
if (addInterchange(interchanges, a[0], a[1])) debug.expresswayEndpointInterchangesAdded++;
if (addInterchange(interchanges, b[0], b[1])) debug.expresswayEndpointInterchangesAdded++;
}
features.minorRoads = dedupePaths(minorRoads, 2);
features.nationalRoads = dedupePaths(nationalRoads, 1);
features.externalRoads = dedupePaths(externalRoads, 1);
features.expressways = dedupePaths(expressways, 2);
features.externalExpressways = dedupePaths(externalExpressways, 2);
features.railways = dedupePaths(features.railways || [], 2);
features.interchanges = interchanges;
// Final invariant: after all dedupe passes, every municipal office cell has at least a local road cell on it.
let finalAdminStubsAdded = 0;
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y)) continue;
if (anyPathTouches([...features.minorRoads, ...features.nationalRoads, ...features.externalRoads], center, 0.65)) continue;
const x = Math.round(center.x), y = Math.round(center.y);
const candidates = [
[{ x: Math.max(0, x - 1), y }, { x, y }, { x: Math.min(MAP_W - 1, x + 1), y }],
[{ x, y: Math.max(0, y - 1) }, { x, y }, { x, y: Math.min(MAP_H - 1, y + 1) }],
];
const stub = candidates
.map((cand) => cand.map((p) => [p.x, p.y]).filter(([px, py], idx, arr) => idx === 0 || px !== arr[idx - 1][0] || py !== arr[idx - 1][1]))
.find((p) => p.length >= 2) || [[x, y], [Math.min(MAP_W - 1, x + 1), y]];
features.minorRoads.push(stub);
finalAdminStubsAdded++;
}
debug.finalAdminStubsAdded = finalAdminStubsAdded;
features.roadInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 5.0);
features.roadDensityInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 9.0);
features.transportDebug = {
...(features.transportDebug || {}),
generationOrder: debug.order,
postAdminTransportFinalization: debug,
};
return features;
}