2557 lines
119 KiB
JavaScript
2557 lines
119 KiB
JavaScript
import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside, pickEntities, xyOf } from "./mapUtils.js";
|
|
import { distanceToNearest, influenceFromPaths, samplePath } from "./mapGeneratorHelpers.js";
|
|
import {
|
|
assessExpresswayRoute,
|
|
assessMountainRoute,
|
|
countReason,
|
|
fieldBackbonePolicy,
|
|
makeSpatialIndex,
|
|
makeUnionFind,
|
|
packDebugField,
|
|
pathAverageField,
|
|
pathLengthCells,
|
|
squaredDistance,
|
|
TRANSPORT_ROUTE_POLICIES,
|
|
} from "./mapTransportUtils.js";
|
|
export {
|
|
createPathInfluenceCache,
|
|
packDebugField,
|
|
pathAverageField,
|
|
pathLengthCells,
|
|
pathSetSignature,
|
|
routeQualityAcceptable,
|
|
routeQualityStats,
|
|
TRANSPORT_ROUTE_POLICIES,
|
|
} from "./mapTransportUtils.js";
|
|
|
|
export function buildDensityFlowRoadTransportSystem(ctx) {
|
|
const {
|
|
seed,
|
|
sea, elevation, slope, ridgeField, valleyField, coastalLowland, naturalBarrierScore,
|
|
agriculture, basinField, plain, passSuitability, crossingSuitability,
|
|
settlementDemand, preliminaryVillageInfluence, preliminaryTownInfluence,
|
|
logisticsPreSuitability, urbanEdge,
|
|
transportFields, cachedInfluenceFromPaths,
|
|
nationalRoads, minorRoads, railways, externalRoads, externalRailways,
|
|
expressways, externalExpressways, icAccessRoads, interchanges, externalGateways,
|
|
modernCities, markets, villages, ports, commercialPorts, passes, regionStats,
|
|
regionIdAt, inFocusedPrefecture, importantNodesForRegion, dedupePointCandidates,
|
|
routeBetweenTrafficCandidates, traceCorridorByCost, addCorridorInfluencePenalty,
|
|
relaxRouteToTerrain, transportRouteAcceptable, repairTransportConnectivity,
|
|
repairDanglingTransportEndpoints, pruneDanglingTerminalSegments, pruneParallelSameMode,
|
|
} = ctx;
|
|
|
|
// --- OD-corridor road generation ---------------------------------------
|
|
// Roads are generated as corridors first; hierarchy-specific labels are
|
|
// assigned by corridor purpose. This replaces the older field-corridor
|
|
// national/expressway lines and avoids repeated endpoint correction passes.
|
|
|
|
const majorCitiesForExpressway = modernCities.filter((c) => (c.population || 0) >= 45000);
|
|
const villageCentersForExpressway = villages.filter((v) => (v.population || 0) >= 400);
|
|
const cityCoreProtectionIndex = makeSpatialIndex(
|
|
majorCitiesForExpressway.map((c) => ({ ...c, protectedRadius: Math.max(5.5, (c.coreRadius || 4) + 3.2) })),
|
|
18
|
|
);
|
|
const urbanCoreProtectionIndex = makeSpatialIndex(
|
|
modernCities.map((c) => ({ ...c, protectedRadius: Math.max(6.2, (c.coreRadius || 4) + 4.2) })),
|
|
18
|
|
);
|
|
const villageCoreIndex = makeSpatialIndex(villageCentersForExpressway, 8);
|
|
const lineCostCache = new Map();
|
|
|
|
function approximateLineCost(a, b, costField) {
|
|
const fieldLabel =
|
|
costField === expresswayCorridorCost ? "expressway" :
|
|
costField === transportFields.national ? "national" :
|
|
costField === transportFields.local ? "local" :
|
|
null;
|
|
const orderedEndpoints = a.x < b.x || (a.x === b.x && a.y <= b.y)
|
|
? `${a.x},${a.y}:${b.x},${b.y}`
|
|
: `${b.x},${b.y}:${a.x},${a.y}`;
|
|
const cacheKey = fieldLabel ? `${fieldLabel}:${orderedEndpoints}` : null;
|
|
if (cacheKey && lineCostCache.has(cacheKey)) return lineCostCache.get(cacheKey);
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
|
|
let sum = 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)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || costField[i] >= INF) {
|
|
if (cacheKey) lineCostCache.set(cacheKey, INF);
|
|
return INF;
|
|
}
|
|
sum += costField[i];
|
|
n++;
|
|
}
|
|
const result = n ? sum / n : INF;
|
|
if (cacheKey) lineCostCache.set(cacheKey, result);
|
|
return result;
|
|
}
|
|
|
|
function pathTerrainRisk(path) {
|
|
if (!path?.length) return 1;
|
|
let mountain = 0;
|
|
let boundary = 0;
|
|
let dense = 0;
|
|
let villageCore = 0;
|
|
let cityCore = 0;
|
|
let highAltitude = 0;
|
|
let n = 0;
|
|
for (const [x, y] of path) {
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
mountain += clamp((elevation[i] - 0.56) * 2.7 + slope[i] * 0.95 + ridgeField[i] * 0.85);
|
|
if (elevation[i] >= 0.70) highAltitude++;
|
|
const nb = naturalBarrierScore?.[i] || 0;
|
|
boundary += nb * nb;
|
|
dense += settlementDemand[i] > 0.70 ? 1 : 0;
|
|
villageCore += preliminaryVillageInfluence[i] > 0.46 ? 1 : 0;
|
|
cityCore += preliminaryTownInfluence[i] > 0.52 || settlementDemand[i] > 0.68 ? 1 : 0;
|
|
n++;
|
|
}
|
|
return n ? {
|
|
mountain: mountain / n,
|
|
boundary: boundary / n,
|
|
denseShare: dense / n,
|
|
villageCoreShare: villageCore / n,
|
|
cityCoreShare: cityCore / n,
|
|
highAltitudeShare: highAltitude / n,
|
|
} : { mountain: 1, boundary: 1, denseShare: 1, villageCoreShare: 1, cityCoreShare: 1, highAltitudeShare: 1 };
|
|
}
|
|
|
|
function highAltitudeRoadClosed(i) {
|
|
return elevation[i] >= 0.70;
|
|
}
|
|
|
|
const expresswayCorridorCost = new Float32Array(SIZE);
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (sea[i] || highAltitudeRoadClosed(i) || transportFields.expressway[i] >= INF) {
|
|
expresswayCorridorCost[i] = INF;
|
|
} else {
|
|
const urbanCore = clamp(settlementDemand[i] * 0.80 + preliminaryTownInfluence[i] * 0.92 + preliminaryVillageInfluence[i] * 0.58);
|
|
const ruralSettlementCore = clamp(preliminaryVillageInfluence[i] * 1.15 + agriculture[i] * 0.20 - plain[i] * 0.16);
|
|
const boundary = naturalBarrierScore?.[i] || 0;
|
|
// Highways are through-corridors here, not urban expressways. Penalize
|
|
// CBD proxies and village cores strongly, while still allowing suburban
|
|
// edge cells selected by majorCitySuburbanAnchor().
|
|
expresswayCorridorCost[i] = transportFields.expressway[i]
|
|
+ urbanCore * 8.2
|
|
+ ruralSettlementCore * 6.3
|
|
+ boundary * boundary * 2.6
|
|
+ Math.max(0, elevation[i] - 0.58) * 5.4
|
|
+ ridgeField[i] * 2.1;
|
|
}
|
|
}
|
|
|
|
const routePolicies = TRANSPORT_ROUTE_POLICIES;
|
|
|
|
function mountainRouteAssessment(path, mode = "road") {
|
|
return assessMountainRoute(path, mode, pathTerrainRisk, routePolicies);
|
|
}
|
|
|
|
function routeTooStraightAcrossMountains(path, mode = "road") {
|
|
return !mountainRouteAssessment(path, mode).ok;
|
|
}
|
|
|
|
|
|
function routeTooStraightMountainOnly(path) {
|
|
return !mountainRouteAssessment(path, "expresswayMountainOnly").ok;
|
|
}
|
|
|
|
function expresswayProximityRisk(path) {
|
|
const result = { cityCoreHits: 0, villageHits: 0, minMajorCityDistance: Infinity, minVillageDistance: Infinity };
|
|
if (!path?.length) return { ...result, cityCoreHits: 999, villageHits: 999, minMajorCityDistance: 0, minVillageDistance: 0 };
|
|
for (const [x, y] of path) {
|
|
for (const c of cityCoreProtectionIndex.near(x, y, 42)) {
|
|
const d2 = squaredDistance(x, y, c.x, c.y);
|
|
const d = Math.sqrt(d2);
|
|
result.minMajorCityDistance = Math.min(result.minMajorCityDistance, d);
|
|
if (d2 < c.protectedRadius * c.protectedRadius) result.cityCoreHits++;
|
|
}
|
|
for (const v of villageCoreIndex.near(x, y, 4)) {
|
|
const d2 = squaredDistance(x, y, v.x, v.y);
|
|
const d = Math.sqrt(d2);
|
|
result.minVillageDistance = Math.min(result.minVillageDistance, d);
|
|
if (d2 < 1.65 * 1.65) result.villageHits++;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function expresswayRouteAssessment(path, options = {}) {
|
|
return assessExpresswayRoute(path, options, pathTerrainRisk, expresswayProximityRisk, routePolicies);
|
|
}
|
|
|
|
function expresswayRouteAcceptable(path, options = {}) {
|
|
return expresswayRouteAssessment(path, options).ok;
|
|
}
|
|
|
|
function routeAcceptableForMode(path, mode, potentialField, penalty, limits = {}, expresswayOptions = {}) {
|
|
if (mode === "expressway") {
|
|
return expresswayRouteAssessment(path, expresswayOptions);
|
|
}
|
|
const ok = transportRouteAcceptable(path, mode, potentialField, penalty, limits);
|
|
return { ok, reason: ok ? "ok" : "transportQuality" };
|
|
}
|
|
|
|
function majorCitySuburbanAnchor(city) {
|
|
if (!city || !inside(city.x, city.y)) return null;
|
|
let best = null;
|
|
const inner = Math.max(10, Math.round((city.coreRadius || 4) + 7));
|
|
const outer = Math.round(Math.max(inner + 7, Math.min(32, (city.urbanRadius || 12) * 1.85)));
|
|
for (let dy = -outer; dy <= outer; dy++) {
|
|
for (let dx = -outer; dx <= outer; dx++) {
|
|
const x = city.x + dx;
|
|
const y = city.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.expressway[i] >= INF) continue;
|
|
let protectedUrbanCore = false;
|
|
for (const other of urbanCoreProtectionIndex.near(x, y, 18)) {
|
|
if (squaredDistance(x, y, other.x, other.y) < other.protectedRadius * other.protectedRadius) {
|
|
protectedUrbanCore = true;
|
|
break;
|
|
}
|
|
}
|
|
if (protectedUrbanCore) continue;
|
|
let onVillageCore = false;
|
|
for (const v of villageCoreIndex.near(x, y, 4)) {
|
|
if (squaredDistance(x, y, v.x, v.y) < 2.3 * 2.3) {
|
|
onVillageCore = true;
|
|
break;
|
|
}
|
|
}
|
|
if (onVillageCore) continue;
|
|
const nb = naturalBarrierScore?.[i] || 0;
|
|
const suburbanBand = clamp(1 - Math.abs(d - (inner + outer) * 0.50) / Math.max(3, (outer - inner) * 0.52));
|
|
const score =
|
|
transportFields.expresswayPotential[i] * 1.18 +
|
|
logisticsPreSuitability[i] * 0.68 +
|
|
urbanEdge[i] * 0.52 +
|
|
plain[i] * 0.22 +
|
|
basinField[i] * 0.14 +
|
|
suburbanBand * 0.38 -
|
|
settlementDemand[i] * 1.10 -
|
|
preliminaryTownInfluence[i] * 0.72 -
|
|
preliminaryVillageInfluence[i] * 0.96 -
|
|
slope[i] * 0.86 -
|
|
ridgeField[i] * 0.72 -
|
|
nb * nb * 1.05 +
|
|
hash2(x, y, seed + 18103 + city.x * 7 + city.y * 13) * 0.06;
|
|
if (!best || score > best.score) {
|
|
best = { x, y, score, city, regionId: regionIdAt(x, y), role: "major-city-suburb", population: city.population || 0 };
|
|
}
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function dedupeAnchors(points, minDistance = 5) {
|
|
return dedupePointCandidates(points.filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]), minDistance);
|
|
}
|
|
|
|
function buildExpresswayODCorridors(debug) {
|
|
const majorSuburbs = dedupeAnchors(
|
|
modernCities
|
|
.filter((c) => (c.population || 0) >= 100000)
|
|
.map(majorCitySuburbanAnchor),
|
|
10
|
|
);
|
|
debug.majorCitySuburbanAnchors = majorSuburbs.map((p) => ({ x: p.x, y: p.y, city: p.city?.name, population: p.population }));
|
|
|
|
const majorCityRefs = majorSuburbs.filter(Boolean);
|
|
const externalRefs = externalGateways.map((g) => ({ ...g, score: 0.92, role: "external-gateway", population: 90000 }));
|
|
const portRefs = commercialPorts
|
|
.filter((p) => p.portClass === "major" || (p.population || 0) >= 18000)
|
|
.map((p) => ({ ...p, score: 0.78 + (p.portClass === "major" ? 0.30 : 0), role: "port-logistics", population: p.population || 45000 }));
|
|
const remoteRefs = modernCities
|
|
.filter((c) => (c.population || 0) >= 65000 && !majorSuburbs.some((m) => m.city === c))
|
|
.map((c) => {
|
|
const nearestMajor = majorSuburbs.reduce((best, m) => {
|
|
const d = Math.hypot(m.x - c.x, m.y - c.y);
|
|
return !best || d < best.d ? { m, d } : best;
|
|
}, null);
|
|
const anchor = majorCitySuburbanAnchor(c) || { x: c.x, y: c.y, score: 0.3, city: c, population: c.population };
|
|
return { ...anchor, role: "remote-city", score: (anchor.score || 0.3) + Math.min(1.0, (nearestMajor?.d || 0) / 95) * 0.55, remoteDistance: nearestMajor?.d || 0, population: c.population || 0 };
|
|
})
|
|
.filter((p) => p.remoteDistance >= 52)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 8);
|
|
|
|
const nodes = dedupeAnchors([...majorCityRefs, ...portRefs, ...externalRefs, ...remoteRefs], 9);
|
|
const pairs = [];
|
|
for (let a = 0; a < nodes.length; a++) {
|
|
for (let b = a + 1; b < nodes.length; b++) {
|
|
const A = nodes[a];
|
|
const B = nodes[b];
|
|
const d = Math.hypot(A.x - B.x, A.y - B.y);
|
|
if (d < 48 || d > 176) continue;
|
|
const lineCost = approximateLineCost(A, B, expresswayCorridorCost);
|
|
if (!Number.isFinite(lineCost) || lineCost >= INF) continue;
|
|
const demand = Math.sqrt(Math.max(25000, A.population || 50000) * Math.max(25000, B.population || 50000)) / 100000;
|
|
const longDistanceNeed = clamp((d - 48) / 70);
|
|
const externalNeed = A.role === "external-gateway" || B.role === "external-gateway" ? 0.55 : 0;
|
|
const logisticsNeed = A.role === "port-logistics" || B.role === "port-logistics" ? 0.38 : 0;
|
|
const remoteNeed = A.role === "remote-city" || B.role === "remote-city" ? 0.42 : 0;
|
|
const score = (demand * 0.70 + longDistanceNeed * 0.90 + externalNeed + logisticsNeed + remoteNeed) / Math.max(0.9, lineCost) + hash2(A.x + B.x, A.y + B.y, seed + 18131) * 0.025;
|
|
pairs.push({ a: A, b: B, d, score });
|
|
}
|
|
}
|
|
pairs.sort((x, y) => y.score - x.score);
|
|
|
|
const penalty = new Float32Array(SIZE);
|
|
const degree = new Map();
|
|
const maxCorridors = Math.min(6, Math.max(3, Math.ceil(majorSuburbs.length / 2.4)));
|
|
for (const pair of pairs) {
|
|
if (expressways.length >= maxCorridors) break;
|
|
const aid = `${pair.a.x},${pair.a.y}`;
|
|
const bid = `${pair.b.x},${pair.b.y}`;
|
|
if ((degree.get(aid) || 0) >= 2 || (degree.get(bid) || 0) >= 2) continue;
|
|
const path = routeBetweenTrafficCandidates(pair.a, pair.b, "expressway", expresswayCorridorCost, penalty, {
|
|
curvePenalty: 0.11,
|
|
penaltyStrength: 2.2,
|
|
terrainFlowBias: 0.10,
|
|
surfaceGrain: 0.006,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.24,
|
|
maxPathLength: pair.d * 2.25 + 42,
|
|
});
|
|
const len = pathLengthCells(path);
|
|
if (len < 34 || len > pair.d * 2.25 + 48) continue;
|
|
if (routeTooStraightMountainOnly(path)) continue;
|
|
if (!expresswayRouteAcceptable(path)) continue;
|
|
expressways.push(path);
|
|
addCorridorInfluencePenalty(penalty, path, 24, 1.80);
|
|
degree.set(aid, (degree.get(aid) || 0) + 1);
|
|
degree.set(bid, (degree.get(bid) || 0) + 1);
|
|
debug.expresswayCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), length: Math.round(len), score: Math.round(pair.score * 100) / 100, path });
|
|
}
|
|
|
|
// Guarantee at least one expressway approach for every very large city. The
|
|
// path still uses suburban anchors and expresswayCorridorCost, so it should
|
|
// bypass the CBD and village cores instead of cutting through them.
|
|
const expressInfluence = cachedInfluenceFromPaths(expressways, 18, "expressway:major-city-coverage");
|
|
const allCandidateTargets = dedupeAnchors([...majorCityRefs, ...portRefs, ...externalRefs, ...remoteRefs], 8);
|
|
for (const anchor of majorCityRefs.sort((a, b) => (b.population || 0) - (a.population || 0))) {
|
|
if ((anchor.population || 0) < 100000) continue;
|
|
const ai = indexOf(anchor.x, anchor.y);
|
|
if ((expressInfluence[ai] || 0) > 0.20) continue;
|
|
const options = allCandidateTargets
|
|
.filter((q) => q !== anchor)
|
|
.map((q) => ({ q, d: Math.hypot(q.x - anchor.x, q.y - anchor.y), c: approximateLineCost(anchor, q, expresswayCorridorCost) }))
|
|
.filter((e) => e.d >= 38 && e.d <= 170 && Number.isFinite(e.c) && e.c < INF)
|
|
.sort((a, b) => (a.d * a.c) - (b.d * b.c));
|
|
for (const opt of options.slice(0, 8)) {
|
|
const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", expresswayCorridorCost, penalty, {
|
|
curvePenalty: 0.11,
|
|
penaltyStrength: 2.6,
|
|
terrainFlowBias: 0.10,
|
|
surfaceGrain: 0.006,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.22,
|
|
maxPathLength: opt.d * 2.75 + 78,
|
|
});
|
|
const len = pathLengthCells(path);
|
|
if (len >= 24 && len <= opt.d * 2.95 + 96 && !routeTooStraightMountainOnly(path) && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) {
|
|
expressways.push(path);
|
|
addCorridorInfluencePenalty(penalty, path, 58, 8.80);
|
|
debug.expresswayCorridors.push({ from: "major-city-guarantee", city: anchor.city?.name, to: opt.q.role, distance: Math.round(opt.d), length: Math.round(len), score: 0, path });
|
|
break;
|
|
}
|
|
}
|
|
const refreshed = cachedInfluenceFromPaths(expressways, 9, `expressway:coverage:${anchor.x},${anchor.y}`);
|
|
if ((refreshed[ai] || 0) <= 0.20) {
|
|
// Last resort: create a short suburban approach to the nearest low-cost
|
|
// through corridor cell, still outside the urban core. This avoids the
|
|
// pathological case where a large isolated city receives no motorway at all.
|
|
const fallbackTargets = [];
|
|
const searchR = 56;
|
|
for (let dy = -searchR; dy <= searchR; dy += 3) {
|
|
for (let dx = -searchR; dx <= searchR; dx += 3) {
|
|
const x = anchor.x + dx;
|
|
const y = anchor.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
const d = Math.hypot(dx, dy);
|
|
if (d < 20 || d > searchR || sea[i] || expresswayCorridorCost[i] >= INF) continue;
|
|
if (settlementDemand[i] > 0.44 || preliminaryTownInfluence[i] > 0.34 || preliminaryVillageInfluence[i] > 0.30) continue;
|
|
fallbackTargets.push({ x, y, d, role: "suburban-fallback", population: anchor.population, score: expresswayCorridorCost[i] + d * 0.018 });
|
|
}
|
|
}
|
|
fallbackTargets.sort((a, b) => a.score - b.score);
|
|
const target = fallbackTargets[0];
|
|
if (target) {
|
|
const path = routeBetweenTrafficCandidates(anchor, target, "expressway", expresswayCorridorCost, penalty, {
|
|
curvePenalty: 0.11,
|
|
penaltyStrength: 2.2,
|
|
terrainFlowBias: 0.10,
|
|
surfaceGrain: 0.006,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.16,
|
|
maxPathLength: target.d * 2.8 + 34,
|
|
});
|
|
if (pathLengthCells(path) >= 12 && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) {
|
|
expressways.push(path);
|
|
addCorridorInfluencePenalty(penalty, path, 10, 0.74);
|
|
debug.expresswayCorridors.push({ from: "major-city-fallback-approach", city: anchor.city?.name, to: target.role, distance: Math.round(target.d), length: Math.round(pathLengthCells(path)), score: 0, path });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Final city-level coverage pass. The anchor-level influence check can miss
|
|
// paired urban centers whose suburban anchors were deduplicated into the
|
|
// neighboring city. Check distance from each major city center to the
|
|
// motorway layer, then connect its own suburban anchor to the nearest
|
|
// existing motorway cell or create a short outward suburban approach.
|
|
function expresswayCells() {
|
|
const cells = [];
|
|
for (const path of expressways) {
|
|
for (const [x, y] of path) if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, role: "existing-expressway", population: 0 });
|
|
}
|
|
return cells;
|
|
}
|
|
function minDistanceToExpressways(x, y) {
|
|
let best = Infinity;
|
|
for (const c of expresswayCells()) best = Math.min(best, Math.hypot(c.x - x, c.y - y));
|
|
return best;
|
|
}
|
|
for (const city of modernCities.filter((c) => (c.population || 0) >= 100000).sort((a, b) => (b.population || 0) - (a.population || 0))) {
|
|
const coverLimit = Math.max(25, (city.urbanRadius || 13) * 1.75);
|
|
if (minDistanceToExpressways(city.x, city.y) <= coverLimit) continue;
|
|
const anchor = majorCitySuburbanAnchor(city);
|
|
if (!anchor) continue;
|
|
let addedForCity = false;
|
|
const cells = expresswayCells()
|
|
.map((q) => ({ q, d: Math.hypot(q.x - anchor.x, q.y - anchor.y), c: approximateLineCost(anchor, q, expresswayCorridorCost) }))
|
|
.filter((e) => e.d >= 8 && e.d <= 105 && Number.isFinite(e.c) && e.c < INF)
|
|
.sort((a, b) => (a.d * a.c) - (b.d * b.c));
|
|
for (const opt of cells.slice(0, 8)) {
|
|
const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", expresswayCorridorCost, penalty, {
|
|
curvePenalty: 0.11,
|
|
penaltyStrength: 2.6,
|
|
terrainFlowBias: 0.10,
|
|
surfaceGrain: 0.006,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.18,
|
|
maxPathLength: opt.d * 2.85 + 42,
|
|
});
|
|
const len = pathLengthCells(path);
|
|
if (len >= 8 && len <= opt.d * 3.0 + 58 && !routeTooStraightMountainOnly(path) && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) {
|
|
expressways.push(path);
|
|
addCorridorInfluencePenalty(penalty, path, 10, 0.72);
|
|
debug.expresswayCorridors.push({ from: "major-city-center-coverage", city: city.name, to: opt.q.role, distance: Math.round(opt.d), length: Math.round(len), score: 0, path });
|
|
addedForCity = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!addedForCity) {
|
|
const searchR = 48;
|
|
const fallbackTargets = [];
|
|
for (let dy = -searchR; dy <= searchR; dy += 3) {
|
|
for (let dx = -searchR; dx <= searchR; dx += 3) {
|
|
const x = anchor.x + dx;
|
|
const y = anchor.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
const d = Math.hypot(dx, dy);
|
|
if (d < 16 || d > searchR || sea[i] || expresswayCorridorCost[i] >= INF) continue;
|
|
if (settlementDemand[i] > 0.46 || preliminaryTownInfluence[i] > 0.36 || preliminaryVillageInfluence[i] > 0.31) continue;
|
|
fallbackTargets.push({ x, y, d, role: "city-coverage-fallback", population: city.population, score: expresswayCorridorCost[i] + d * 0.016 });
|
|
}
|
|
}
|
|
fallbackTargets.sort((a, b) => a.score - b.score);
|
|
for (const target of fallbackTargets.slice(0, 4)) {
|
|
const path = routeBetweenTrafficCandidates(anchor, target, "expressway", expresswayCorridorCost, penalty, {
|
|
curvePenalty: 0.11,
|
|
penaltyStrength: 2.2,
|
|
terrainFlowBias: 0.10,
|
|
surfaceGrain: 0.006,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.16,
|
|
maxPathLength: target.d * 2.9 + 36,
|
|
});
|
|
if (pathLengthCells(path) >= 10 && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) {
|
|
expressways.push(path);
|
|
addCorridorInfluencePenalty(penalty, path, 10, 0.64);
|
|
debug.expresswayCorridors.push({ from: "major-city-center-fallback", city: city.name, to: target.role, distance: Math.round(target.d), length: Math.round(pathLengthCells(path)), score: 0, path });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inter-city backbone pass. The previous city-coverage fallback produced
|
|
// short suburban motorway approaches, but did not necessarily connect those
|
|
// approaches into a through network. Treat high-capacity roads as OD
|
|
// corridors: connect major-city suburb anchors, ports and external gates by
|
|
// a small Kruskal-style backbone over low-cost terrain.
|
|
const backboneAnchors = dedupeAnchors([...majorSuburbs, ...portRefs, ...externalRefs], 10)
|
|
.filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]);
|
|
const keyOf = (p) => `${p.x},${p.y}`;
|
|
const { find, unite } = makeUnionFind(backboneAnchors, keyOf);
|
|
const backbonePairs = [];
|
|
for (let a = 0; a < backboneAnchors.length; a++) {
|
|
for (let b = a + 1; b < backboneAnchors.length; b++) {
|
|
const A = backboneAnchors[a];
|
|
const B = backboneAnchors[b];
|
|
const d = Math.hypot(A.x - B.x, A.y - B.y);
|
|
if (d < 44 || d > 190) continue;
|
|
const c = approximateLineCost(A, B, expresswayCorridorCost);
|
|
if (!Number.isFinite(c) || c >= INF) continue;
|
|
const demand = Math.sqrt(Math.max(50000, A.population || 70000) * Math.max(50000, B.population || 70000)) / 120000;
|
|
const gatewayBonus = A.role === "external-gateway" || B.role === "external-gateway" ? 0.28 : 0;
|
|
const portBonus = A.role === "port-logistics" || B.role === "port-logistics" ? 0.18 : 0;
|
|
backbonePairs.push({ A, B, d, score: d * c / Math.max(0.55, demand + gatewayBonus + portBonus) });
|
|
}
|
|
}
|
|
backbonePairs.sort((a, b) => a.score - b.score);
|
|
let backboneAdded = 0;
|
|
for (const pair of backbonePairs) {
|
|
const ak = keyOf(pair.A);
|
|
const bk = keyOf(pair.B);
|
|
if (find(ak) === find(bk)) continue;
|
|
if (backboneAdded >= Math.min(9, Math.max(3, backboneAnchors.length - 1))) break;
|
|
const path = routeBetweenTrafficCandidates(pair.A, pair.B, "expressway", expresswayCorridorCost, penalty, {
|
|
curvePenalty: 0.105,
|
|
penaltyStrength: 2.10,
|
|
terrainFlowBias: 0.12,
|
|
surfaceGrain: 0.007,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.20,
|
|
maxPathLength: pair.d * 3.05 + 96,
|
|
snapRadius: 5,
|
|
});
|
|
const len = pathLengthCells(path);
|
|
if (len < 34 || len > pair.d * 3.15 + 116) continue;
|
|
if (routeTooStraightMountainOnly(path)) continue;
|
|
if (!expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) continue;
|
|
expressways.push(path);
|
|
addCorridorInfluencePenalty(penalty, path, 24, 1.70);
|
|
unite(ak, bk);
|
|
backboneAdded++;
|
|
debug.expresswayCorridors.push({ from: "expressway-backbone", to: `${pair.A.role}-${pair.B.role}`, distance: Math.round(pair.d), length: Math.round(len), score: Math.round(pair.score * 100) / 100, path });
|
|
}
|
|
}
|
|
|
|
|
|
function buildNationalCorridorNetwork(debug) {
|
|
const baseNodes = [
|
|
...modernCities.map((c) => ({ ...c, score: 1.2 + Math.sqrt(c.population || 50000) / 430 + ((c.population || 0) >= 100000 ? 0.55 : 0) + ((c.population || 0) >= 500000 ? 1.10 : 0), role: "city", population: c.population || 0 })),
|
|
...markets.filter((m) => (m.population || 0) >= 3000).map((m) => ({ ...m, score: 0.72 + (m.population || 6000) / 42000, role: "market", population: m.population || 0 })),
|
|
...ports.map((p) => ({ ...p, score: 0.76 + (p.portClass === "major" ? 0.45 : 0), role: "port", population: p.population || 12000 })),
|
|
...externalGateways.map((g) => ({ ...g, score: 0.92, role: "external-gateway", population: 42000 })),
|
|
...villages.filter((v) => (v.population || 0) >= 2200).map((v) => ({ ...v, score: 0.38 + (v.population || 0) / 18000, role: "large-village", population: v.population || 0 })),
|
|
];
|
|
const nodes = dedupeAnchors(baseNodes.sort((a, b) => b.score - a.score), 6).slice(0, 48);
|
|
if (nodes.length < 2) return;
|
|
const penalty = cachedInfluenceFromPaths([...expressways, ...externalRoads], 8, "national-corridor:base");
|
|
const connected = [nodes[0]];
|
|
const remaining = nodes.slice(1);
|
|
const maxMain = Math.min(24, Math.max(14, Math.ceil(nodes.length * 0.42)));
|
|
|
|
while (remaining.length && nationalRoads.length < maxMain) {
|
|
let best = null;
|
|
for (const node of remaining) {
|
|
const candidates = connected
|
|
.map((q) => {
|
|
const d = Math.hypot(node.x - q.x, node.y - q.y);
|
|
if (d < 10 || d > 112) return null;
|
|
const lineCost = approximateLineCost(node, q, transportFields.national);
|
|
if (!Number.isFinite(lineCost) || lineCost >= INF) return null;
|
|
const demand = Math.sqrt(Math.max(3000, node.population || 6000) * Math.max(3000, q.population || 6000)) / 65000;
|
|
const score = d * lineCost / Math.max(0.35, demand + node.score * 0.25 + q.score * 0.25);
|
|
return { q, d, score };
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => a.score - b.score);
|
|
if (!candidates.length) continue;
|
|
const cand = candidates[0];
|
|
if (!best || cand.score < best.score) best = { node, target: cand.q, d: cand.d, score: cand.score };
|
|
}
|
|
if (!best) break;
|
|
const path = routeBetweenTrafficCandidates(best.node, best.target, "national", transportFields.national, penalty, {
|
|
curvePenalty: 0.050,
|
|
penaltyStrength: 0.92,
|
|
terrainFlowBias: 0.26,
|
|
surfaceGrain: 0.034,
|
|
relaxRadius: 2,
|
|
relaxLineWeight: 0.32,
|
|
maxPathLength: best.d * 2.55 + 38,
|
|
});
|
|
const len = pathLengthCells(path);
|
|
if (len >= 6 && len <= best.d * 2.65 + 42 && !routeTooStraightAcrossMountains(path, "national") && transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 6, maxLength: best.d * 2.65 + 42, maxHighElevationShare: 0.34, maxSteepShare: 0.46 })) {
|
|
nationalRoads.push(path);
|
|
debug.nationalCorridors.push({ from: best.node.role, to: best.target.role, length: Math.round(len), path });
|
|
addCorridorInfluencePenalty(penalty, path, 6, 0.30);
|
|
}
|
|
connected.push(best.node);
|
|
remaining.splice(remaining.indexOf(best.node), 1);
|
|
}
|
|
|
|
const extraPairs = [];
|
|
for (let a = 0; a < Math.min(nodes.length, 32); a++) {
|
|
for (let b = a + 1; b < Math.min(nodes.length, 32); b++) {
|
|
const A = nodes[a];
|
|
const B = nodes[b];
|
|
const d = Math.hypot(A.x - B.x, A.y - B.y);
|
|
if (d < 22 || d > 86) continue;
|
|
const regional = A.regionId !== B.regionId ? 0.22 : 0;
|
|
const need = (A.score + B.score) * 0.5 + regional;
|
|
extraPairs.push({ A, B, d, score: d / Math.max(0.5, need) + hash2(A.x + B.x, A.y + B.y, seed + 18161) * 0.06 });
|
|
}
|
|
}
|
|
extraPairs.sort((a, b) => a.score - b.score);
|
|
let addedExtra = 0;
|
|
for (const pair of extraPairs) {
|
|
if (addedExtra >= 5 || nationalRoads.length >= 29) break;
|
|
const path = routeBetweenTrafficCandidates(pair.A, pair.B, "national", transportFields.national, penalty, {
|
|
curvePenalty: 0.050,
|
|
penaltyStrength: 1.05,
|
|
terrainFlowBias: 0.25,
|
|
surfaceGrain: 0.034,
|
|
relaxRadius: 2,
|
|
relaxLineWeight: 0.32,
|
|
maxPathLength: pair.d * 2.35 + 32,
|
|
});
|
|
const len = pathLengthCells(path);
|
|
if (len < 8 || len > pair.d * 2.35 + 32 || routeTooStraightAcrossMountains(path, "national")) continue;
|
|
if (pathAverageField(path, penalty) > 0.42 && pathAverageField(path, transportFields.nationalPotential) < 0.37) continue;
|
|
nationalRoads.push(path);
|
|
addedExtra++;
|
|
debug.nationalCorridors.push({ from: `${pair.A.role}-extra`, to: `${pair.B.role}-extra`, length: Math.round(len), path });
|
|
addCorridorInfluencePenalty(penalty, path, 6, 0.32);
|
|
}
|
|
|
|
const nationalInfluence = cachedInfluenceFromPaths([...nationalRoads, ...externalRoads], 7, "national:major-city-coverage");
|
|
const importantCities = modernCities
|
|
.filter((c) => (c.population || 0) >= 100000)
|
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
const nationalTargets = dedupeAnchors([...nodes, ...externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 }))], 5);
|
|
for (const city of importantCities) {
|
|
const ci = indexOf(city.x, city.y);
|
|
if ((nationalInfluence[ci] || 0) > 0.20) continue;
|
|
const target = nationalTargets
|
|
.filter((q) => Math.hypot(q.x - city.x, q.y - city.y) > 4)
|
|
.map((q) => ({ q, d: Math.hypot(q.x - city.x, q.y - city.y), c: approximateLineCost(city, q, transportFields.national) }))
|
|
.filter((e) => e.d <= 86 && Number.isFinite(e.c) && e.c < INF)
|
|
.sort((a, b) => (a.d * a.c) - (b.d * b.c))[0];
|
|
if (!target) continue;
|
|
const path = routeBetweenTrafficCandidates(city, target.q, "national", transportFields.national, penalty, {
|
|
curvePenalty: 0.052,
|
|
penaltyStrength: 0.86,
|
|
terrainFlowBias: 0.27,
|
|
surfaceGrain: 0.034,
|
|
relaxRadius: 2,
|
|
relaxLineWeight: 0.28,
|
|
maxPathLength: target.d * 2.5 + 34,
|
|
});
|
|
const len = pathLengthCells(path);
|
|
if (len >= 4 && len <= target.d * 2.55 + 38 && !routeTooStraightAcrossMountains(path, "national")) {
|
|
nationalRoads.push(path);
|
|
debug.nationalCorridors.push({ from: "major-city-guarantee", city: city.name, to: target.q.role, length: Math.round(len), path });
|
|
addCorridorInfluencePenalty(penalty, path, 6, 0.30);
|
|
}
|
|
}
|
|
|
|
// National roads should form regional corridors, not a set of short roads
|
|
// terminating around each town. Add a sparse backbone over cities, ports
|
|
// and external gates after the initial MST/extra pass, using the same
|
|
// terrain cost field but a larger distance envelope.
|
|
const backboneNodes = dedupeAnchors([
|
|
...modernCities.filter((c) => (c.population || 0) >= 65000).map((c) => ({ ...c, role: "city-backbone", score: 1.0 + Math.sqrt(c.population || 70000) / 420, population: c.population || 0 })),
|
|
...ports.filter((p) => p.portClass === "major" || (p.population || 0) >= 9000).map((p) => ({ ...p, role: "port-backbone", score: 1.05, population: p.population || 20000 })),
|
|
...externalGateways.map((g) => ({ ...g, role: "external-backbone", score: 1.0, population: 42000 })),
|
|
].sort((a, b) => b.score - a.score), 7).slice(0, 34);
|
|
const keyOf = (p) => `${p.x},${p.y}`;
|
|
const { find, unite } = makeUnionFind(backboneNodes, keyOf);
|
|
const pairs = [];
|
|
for (let a = 0; a < backboneNodes.length; a++) {
|
|
for (let b = a + 1; b < backboneNodes.length; b++) {
|
|
const A = backboneNodes[a];
|
|
const B = backboneNodes[b];
|
|
const d = Math.hypot(A.x - B.x, A.y - B.y);
|
|
if (d < 16 || d > 132) continue;
|
|
const c = approximateLineCost(A, B, transportFields.national);
|
|
if (!Number.isFinite(c) || c >= INF) continue;
|
|
const demand = Math.sqrt(Math.max(9000, A.population || 12000) * Math.max(9000, B.population || 12000)) / 82000;
|
|
const regional = A.regionId !== B.regionId ? 0.28 : 0;
|
|
pairs.push({ A, B, d, score: d * c / Math.max(0.42, demand + regional + (A.score + B.score) * 0.18) });
|
|
}
|
|
}
|
|
pairs.sort((a, b) => a.score - b.score);
|
|
let backboneAdded = 0;
|
|
for (const pair of pairs) {
|
|
if (backboneAdded >= Math.min(22, Math.max(8, backboneNodes.length - 1))) break;
|
|
const ak = keyOf(pair.A);
|
|
const bk = keyOf(pair.B);
|
|
if (find(ak) === find(bk)) continue;
|
|
const path = routeBetweenTrafficCandidates(pair.A, pair.B, "national", transportFields.national, penalty, {
|
|
curvePenalty: 0.052,
|
|
penaltyStrength: 0.78,
|
|
terrainFlowBias: 0.28,
|
|
surfaceGrain: 0.036,
|
|
relaxRadius: 2,
|
|
relaxLineWeight: 0.28,
|
|
maxPathLength: pair.d * 3.05 + 62,
|
|
snapRadius: 4,
|
|
});
|
|
const len = pathLengthCells(path);
|
|
if (len < 6 || len > pair.d * 3.15 + 78) continue;
|
|
if (routeTooStraightAcrossMountains(path, "national")) continue;
|
|
nationalRoads.push(path);
|
|
addCorridorInfluencePenalty(penalty, path, 6, 0.27);
|
|
unite(ak, bk);
|
|
backboneAdded++;
|
|
debug.nationalCorridors.push({ from: "national-backbone", to: `${pair.A.role}-${pair.B.role}`, length: Math.round(len), path });
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Density-flow transport system
|
|
// -------------------------------------------------------------------------
|
|
// The road hierarchy below deliberately avoids treating a city as one node.
|
|
// Cities contribute several edge portals and the field samplers add population
|
|
// density anchors, so trunks are drawn to preferred density bands instead of
|
|
// collapsing into CBD points.
|
|
|
|
function densityBand(i, target = 0.46, width = 0.26) {
|
|
const d = (settlementDemand[i] || 0) - target;
|
|
return Math.exp(-(d * d) / Math.max(0.0001, 2 * width * width));
|
|
}
|
|
|
|
function denseCorePenaltyAt(i) {
|
|
return clamp(
|
|
(settlementDemand[i] - 0.62) / 0.30 +
|
|
preliminaryTownInfluence[i] * 0.44 +
|
|
preliminaryVillageInfluence[i] * 0.30
|
|
);
|
|
}
|
|
|
|
function terrainCorridorBonusAt(i, mode = "national") {
|
|
return clamp(
|
|
valleyField[i] * (mode === "expressway" ? 0.26 : 0.46) +
|
|
coastalLowland[i] * (mode === "expressway" ? 0.24 : 0.34) +
|
|
plain[i] * 0.24 +
|
|
basinField[i] * 0.18 +
|
|
agriculture[i] * (mode === "expressway" ? 0.08 : 0.18) +
|
|
(passSuitability?.[i] || 0) * (mode === "expressway" ? 0.18 : 0.32) +
|
|
(crossingSuitability?.[i] || 0) * (mode === "expressway" ? 0.10 : 0.24) -
|
|
ridgeField[i] * (mode === "expressway" ? 0.42 : 0.24) -
|
|
slope[i] * (mode === "expressway" ? 0.36 : 0.22)
|
|
);
|
|
}
|
|
|
|
function markPathInfluence(field, path, radius = 5, strength = 1) {
|
|
for (const [px, py] of path || []) {
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
if (dx * dx + dy * dy > radius * radius) continue;
|
|
const x = px + dx;
|
|
const y = py + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
const v = strength * Math.pow(1 - d / Math.max(1, radius), 1.35);
|
|
if (v > field[i]) field[i] = v;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function fieldAdjustedCost(baseCost, mode, flowField = null) {
|
|
const out = new Float32Array(SIZE);
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (sea[i] || baseCost[i] >= INF) {
|
|
out[i] = INF;
|
|
continue;
|
|
}
|
|
const core = denseCorePenaltyAt(i);
|
|
const flow = flowField?.[i] || 0;
|
|
if (mode === "expressway") {
|
|
// Motorways prefer the urban fringe / logistics band and through-flow,
|
|
// but strongly avoid CBD and village cores.
|
|
const preferred = densityBand(i, 0.38, 0.22) * 0.42 + urbanEdge[i] * 0.42 + logisticsPreSuitability[i] * 0.34 + terrainCorridorBonusAt(i, mode) * 0.24;
|
|
out[i] = Math.max(0.12,
|
|
baseCost[i]
|
|
- preferred
|
|
- flow * 0.58
|
|
+ core * 2.55
|
|
+ preliminaryVillageInfluence[i] * 1.38
|
|
+ Math.max(0, elevation[i] - 0.60) * 2.9
|
|
+ ridgeField[i] * 0.92
|
|
+ slope[i] * 0.92
|
|
);
|
|
} else if (mode === "national") {
|
|
// National roads should follow town chains and valleys without diving
|
|
// into every exact population maximum.
|
|
const preferred = densityBand(i, 0.52, 0.32) * 0.38 + preliminaryTownInfluence[i] * 0.24 + preliminaryVillageInfluence[i] * 0.20 + terrainCorridorBonusAt(i, mode) * 0.34;
|
|
out[i] = Math.max(0.10,
|
|
baseCost[i]
|
|
- preferred
|
|
- flow * 0.74
|
|
+ Math.max(0, core - 0.46) * 0.52
|
|
+ Math.max(0, elevation[i] - 0.66) * 1.4
|
|
+ ridgeField[i] * 0.28
|
|
);
|
|
} else {
|
|
out[i] = baseCost[i];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function pointPopulationProxy(p, mode = "national") {
|
|
if (!p) return 1000;
|
|
if (p.population) 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;
|
|
const fieldPop = i >= 0 ? Math.round((settlementDemand[i] * 110000 + preliminaryTownInfluence[i] * 65000 + preliminaryVillageInfluence[i] * 18000)) : 0;
|
|
return Math.max(mode === "expressway" ? 42000 : 7000, fieldPop);
|
|
}
|
|
|
|
function portalSearchAroundPoint(point, mode = "national", role = "portal", options = {}) {
|
|
if (!point || !inside(point.x, point.y)) return null;
|
|
const inner = options.inner ?? (mode === "expressway" ? Math.max(9, Math.round((point.coreRadius || 3) + 6)) : Math.max(3, Math.round((point.coreRadius || 2) + 2)));
|
|
const outer = options.outer ?? (mode === "expressway" ? Math.max(inner + 7, Math.round((point.urbanRadius || 12) * 1.85)) : Math.max(inner + 5, Math.round((point.urbanRadius || 9) * 1.05)));
|
|
const potentialField = mode === "expressway" ? transportFields.expresswayPotential : transportFields.nationalPotential;
|
|
const targetDensity = mode === "expressway" ? 0.38 : 0.52;
|
|
const width = mode === "expressway" ? 0.22 : 0.32;
|
|
let best = null;
|
|
for (let dy = -outer; dy <= outer; dy++) {
|
|
for (let dx = -outer; dx <= outer; dx++) {
|
|
const x = point.x + dx;
|
|
const y = 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]) continue;
|
|
if ((mode === "expressway" ? expresswayCorridorCost[i] : transportFields.national[i]) >= INF) continue;
|
|
const radialBand = clamp(1 - Math.abs(d - (inner + outer) * 0.50) / Math.max(2, (outer - inner) * 0.55));
|
|
const core = denseCorePenaltyAt(i);
|
|
const score =
|
|
potentialField[i] * 1.15 +
|
|
densityBand(i, targetDensity, width) * 0.58 +
|
|
(mode === "expressway" ? urbanEdge[i] * 0.60 + logisticsPreSuitability[i] * 0.46 - core * 1.12 : preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.18 - Math.max(0, core - 0.70) * 0.28) +
|
|
terrainCorridorBonusAt(i, mode) * 0.38 +
|
|
radialBand * 0.26 -
|
|
slope[i] * (mode === "expressway" ? 0.90 : 0.42) -
|
|
ridgeField[i] * (mode === "expressway" ? 0.68 : 0.26) +
|
|
hash2(x, y, seed + 18610 + point.x * 7 + point.y * 13 + (mode === "expressway" ? 37 : 0)) * 0.055;
|
|
if (!best || score > best.score) best = { x, y, score, role, regionId: regionIdAt(x, y), population: pointPopulationProxy(point, mode), source: point };
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function cityPortalAnchors(city, mode = "national") {
|
|
if (!city || !inside(city.x, city.y)) return [];
|
|
const sectors = mode === "expressway" ? 8 : 10;
|
|
const inner = mode === "expressway" ? Math.max(10, Math.round((city.coreRadius || 4) + 7)) : Math.max(4, Math.round((city.coreRadius || 3) + 2));
|
|
const outer = mode === "expressway" ? Math.max(inner + 7, Math.round((city.urbanRadius || 12) * 1.9)) : Math.max(inner + 5, Math.round((city.urbanRadius || 11) * 1.15));
|
|
const bySector = Array.from({ length: sectors }, () => null);
|
|
const potentialField = mode === "expressway" ? transportFields.expresswayPotential : transportFields.nationalPotential;
|
|
for (let dy = -outer; dy <= outer; dy += 1) {
|
|
for (let dx = -outer; dx <= outer; dx += 1) {
|
|
const x = city.x + dx;
|
|
const y = city.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]) continue;
|
|
if ((mode === "expressway" ? expresswayCorridorCost[i] : transportFields.national[i]) >= INF) continue;
|
|
const sector = Math.floor((((Math.atan2(dy, dx) + Math.PI) / (Math.PI * 2)) * sectors)) % sectors;
|
|
const tangentNoise = hash2(x, y, seed + 18630 + city.x * 5 + city.y * 11) * 0.055;
|
|
const radialBand = clamp(1 - Math.abs(d - (inner + outer) * 0.50) / Math.max(2, (outer - inner) * 0.54));
|
|
const core = denseCorePenaltyAt(i);
|
|
const score =
|
|
potentialField[i] * 1.10 +
|
|
(mode === "expressway" ? densityBand(i, 0.38, 0.22) * 0.58 + urbanEdge[i] * 0.60 + logisticsPreSuitability[i] * 0.48 - core * 1.22 : densityBand(i, 0.52, 0.32) * 0.48 + preliminaryTownInfluence[i] * 0.26 + preliminaryVillageInfluence[i] * 0.16 - Math.max(0, core - 0.70) * 0.24) +
|
|
terrainCorridorBonusAt(i, mode) * 0.36 +
|
|
radialBand * 0.30 -
|
|
slope[i] * (mode === "expressway" ? 0.92 : 0.42) -
|
|
ridgeField[i] * (mode === "expressway" ? 0.74 : 0.30) +
|
|
tangentNoise;
|
|
const current = bySector[sector];
|
|
if (!current || score > current.score) bySector[sector] = { x, y, score, role: mode === "expressway" ? "urban-fringe-ic" : "urban-portal", regionId: regionIdAt(x, y), population: city.population || 0, city };
|
|
}
|
|
}
|
|
const count = mode === "expressway"
|
|
? ((city.population || 0) >= 420000 ? 2 : 1)
|
|
: ((city.population || 0) >= 420000 ? 4 : (city.population || 0) >= 140000 ? 3 : 2);
|
|
return dedupeAnchors(bySector.filter(Boolean).sort((a, b) => b.score - a.score), mode === "expressway" ? 9 : 5).slice(0, count);
|
|
}
|
|
|
|
function densityFieldAnchors(mode = "national", max = 48) {
|
|
const candidates = [];
|
|
const step = mode === "expressway" ? 5 : 4;
|
|
const potentialField = mode === "expressway" ? transportFields.expresswayPotential : transportFields.nationalPotential;
|
|
for (let y = 3; y < MAP_H - 3; y += step) {
|
|
for (let x = 3; x < MAP_W - 3; x += step) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || regionIdAt(x, y) < 0) continue;
|
|
if ((mode === "expressway" ? expresswayCorridorCost[i] : transportFields.national[i]) >= INF) continue;
|
|
const core = denseCorePenaltyAt(i);
|
|
const score = mode === "expressway"
|
|
? potentialField[i] * 1.18 + densityBand(i, 0.38, 0.22) * 0.60 + urbanEdge[i] * 0.48 + logisticsPreSuitability[i] * 0.52 + terrainCorridorBonusAt(i, mode) * 0.32 - core * 1.18 - preliminaryVillageInfluence[i] * 0.82 - slope[i] * 0.78 - ridgeField[i] * 0.62 + hash2(x, y, seed + 18670) * 0.06
|
|
: potentialField[i] * 1.26 + densityBand(i, 0.52, 0.32) * 0.46 + preliminaryTownInfluence[i] * 0.32 + preliminaryVillageInfluence[i] * 0.24 + terrainCorridorBonusAt(i, mode) * 0.38 - Math.max(0, core - 0.82) * 0.36 - slope[i] * 0.36 - ridgeField[i] * 0.22 + hash2(x, y, seed + 18671) * 0.06;
|
|
const threshold = mode === "expressway" ? 0.78 : 0.66;
|
|
if (score >= threshold) candidates.push({ x, y, score, role: mode === "expressway" ? "density-fringe" : "density-town-chain", regionId: regionIdAt(x, y), population: pointPopulationProxy({ x, y }, mode) });
|
|
}
|
|
}
|
|
return pickEntities(candidates, { max, minDistance: mode === "expressway" ? 18 : 9, threshold: 0, seed: seed + 18680 + (mode === "expressway" ? 41 : 0), jitter: 0.035 });
|
|
}
|
|
|
|
function roadAnchorsForMode(mode = "national") {
|
|
if (mode === "expressway") {
|
|
const urbanPortals = modernCities
|
|
// Expressways are intercity corridors. Do not give every medium city an
|
|
// urban-expressway-like fringe anchor; medium cities are handled by the
|
|
// national-road layer unless they are a capital.
|
|
.filter((c) => (c.population || 0) >= 150000 || c.isRegionalCapital || c.isPrefecturalCapital)
|
|
.flatMap((c) => cityPortalAnchors(c, "expressway"));
|
|
const portPortals = commercialPorts
|
|
.filter((p) => p.portClass === "major" || p.portClass === "regional")
|
|
.map((p) => portalSearchAroundPoint(p, "expressway", "port-fringe", { inner: 4, outer: 14 }) || { ...p, role: "port-fringe", population: p.population || 55000, score: 0.9 });
|
|
const gatewayPortals = externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 90000, score: 0.92 }));
|
|
const fieldPortals = densityFieldAnchors("expressway", 16);
|
|
return dedupeAnchors([...urbanPortals, ...portPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 11).slice(0, 38);
|
|
}
|
|
|
|
const cityPortals = modernCities.flatMap((c) => cityPortalAnchors(c, "national"));
|
|
const marketPortals = markets
|
|
.filter((m) => (m.population || 0) >= 6000)
|
|
.map((m) => portalSearchAroundPoint(m, "national", "market-portal", { inner: 2, outer: 8 }) || { ...m, role: "market-portal", population: m.population || 9000, score: 0.65 });
|
|
const villagePortals = villages
|
|
.filter((v) => (v.population || 0) >= 3500)
|
|
.map((v) => ({ ...v, role: "large-village", score: 0.34 + (v.population || 0) / 17000, population: v.population || 0 }));
|
|
const portPortals = ports.map((p) => ({ ...p, role: "port", score: 0.72 + (p.portClass === "major" ? 0.48 : p.portClass === "regional" ? 0.28 : 0), population: p.population || 18000 }));
|
|
const passPortals = passes.map((p) => ({ ...p, role: "pass", score: 0.46 + (passSuitability?.[indexOf(p.x, p.y)] || 0), population: 8000 }));
|
|
const gatewayPortals = externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 }));
|
|
const fieldPortals = densityFieldAnchors("national", 54);
|
|
return dedupeAnchors([...cityPortals, ...marketPortals, ...villagePortals, ...portPortals, ...passPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 5.5).slice(0, 88);
|
|
}
|
|
|
|
function buildTrafficFlowField(mode, anchors, baseCost, maxRoutes = 34) {
|
|
const flow = new Float32Array(SIZE);
|
|
const nodes = anchors.slice(0, mode === "expressway" ? 24 : 56);
|
|
const pairs = [];
|
|
for (let a = 0; a < nodes.length; a++) {
|
|
for (let b = a + 1; b < nodes.length; b++) {
|
|
const A = nodes[a];
|
|
const B = nodes[b];
|
|
const d = Math.hypot(A.x - B.x, A.y - B.y);
|
|
if (d < (mode === "expressway" ? 36 : 14) || d > (mode === "expressway" ? 178 : 118)) continue;
|
|
const c = approximateLineCost(A, B, baseCost);
|
|
if (!Number.isFinite(c) || c >= INF) continue;
|
|
const demand = Math.sqrt(pointPopulationProxy(A, mode) * pointPopulationProxy(B, mode)) / (mode === "expressway" ? 120000 : 52000);
|
|
const crossRegion = A.regionId !== B.regionId ? (mode === "expressway" ? 0.30 : 0.18) : 0;
|
|
const gateway = A.role === "external-gateway" || B.role === "external-gateway" ? (mode === "expressway" ? 0.38 : 0.18) : 0;
|
|
const score = d * c / Math.max(0.28, demand + crossRegion + gateway + (A.score + B.score) * 0.16);
|
|
pairs.push({ A, B, d, score, demand });
|
|
}
|
|
}
|
|
pairs.sort((a, b) => a.score - b.score);
|
|
let added = 0;
|
|
const virtualPenalty = new Float32Array(SIZE);
|
|
for (const pair of pairs) {
|
|
if (added >= maxRoutes) break;
|
|
const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, baseCost, virtualPenalty, {
|
|
curvePenalty: mode === "expressway" ? 0.12 : 0.065,
|
|
penaltyStrength: mode === "expressway" ? 1.05 : 0.72,
|
|
terrainFlowBias: mode === "expressway" ? 0.10 : 0.25,
|
|
surfaceGrain: mode === "expressway" ? 0.006 : 0.026,
|
|
relaxRadius: mode === "expressway" ? 1 : 2,
|
|
relaxLineWeight: mode === "expressway" ? 0.20 : 0.28,
|
|
maxPathLength: pair.d * (mode === "expressway" ? 2.65 : 2.45) + (mode === "expressway" ? 70 : 34),
|
|
snapRadius: mode === "expressway" ? 4 : 3,
|
|
heuristicWeight: mode === "expressway" ? 0.70 : 0.48,
|
|
});
|
|
if (path.length < 4) continue;
|
|
const len = pathLengthCells(path);
|
|
if (len > pair.d * (mode === "expressway" ? 2.75 : 2.55) + (mode === "expressway" ? 78 : 38)) continue;
|
|
markPathInfluence(flow, path, mode === "expressway" ? 8 : 5, Math.min(1.2, 0.42 + pair.demand * 0.36));
|
|
addCorridorInfluencePenalty(virtualPenalty, path, mode === "expressway" ? 42 : 6, mode === "expressway" ? 4.20 : 0.22);
|
|
added++;
|
|
}
|
|
return flow;
|
|
}
|
|
|
|
function maxSegmentLength(path) {
|
|
let max = 0;
|
|
for (let k = 1; k < (path?.length || 0); k++) max = Math.max(max, Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1]));
|
|
return max;
|
|
}
|
|
|
|
function existingParallelShare(path, influenceField, threshold = 0.16) {
|
|
if (!path?.length || !influenceField) return 0;
|
|
let hit = 0;
|
|
let n = 0;
|
|
for (const [x, y] of path) {
|
|
if (!inside(x, y)) continue;
|
|
n++;
|
|
if ((influenceField[indexOf(x, y)] || 0) > threshold) hit++;
|
|
}
|
|
return n ? hit / n : 0;
|
|
}
|
|
|
|
function localRouteTerrainStats(path) {
|
|
const len = pathLengthCells(path);
|
|
const first = path?.[0];
|
|
const last = path?.[path.length - 1];
|
|
const direct = first && last ? Math.hypot(first[0] - last[0], first[1] - last[1]) : 0;
|
|
let mountain = 0, ridge = 0, steep = 0, high = 0, valleyPass = 0, coast = 0, seaNear = 0, n = 0;
|
|
for (const [x, y] of path || []) {
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return { len, direct, straightness: 1, mountain: 1, ridge: 1, steepShare: 1, highShare: 1, valleyPass: 0, coast: 0, seaNear: 1 };
|
|
mountain += clamp((elevation[i] - 0.56) * 2.6 + slope[i] * 0.95 + ridgeField[i] * 0.85 - valleyField[i] * 0.26 - (passSuitability?.[i] || 0) * 0.32);
|
|
ridge += ridgeField[i];
|
|
if (slope[i] > 0.44) steep++;
|
|
if (elevation[i] >= 0.70) high++;
|
|
valleyPass += clamp(valleyField[i] * 0.62 + (passSuitability?.[i] || 0) * 0.58 + basinField[i] * 0.18 + plain[i] * 0.12);
|
|
coast += coastalLowland[i];
|
|
let near = 0;
|
|
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
|
if (!dx && !dy) continue;
|
|
const nx = x + dx, ny = y + dy;
|
|
if (inside(nx, ny) && sea[indexOf(nx, ny)]) near = 1;
|
|
}
|
|
seaNear += near;
|
|
n++;
|
|
}
|
|
return {
|
|
len,
|
|
direct,
|
|
straightness: direct / Math.max(1, len),
|
|
mountain: mountain / Math.max(1, n),
|
|
ridge: ridge / Math.max(1, n),
|
|
steepShare: steep / Math.max(1, n),
|
|
highShare: high / Math.max(1, n),
|
|
valleyPass: valleyPass / Math.max(1, n),
|
|
coast: coast / Math.max(1, n),
|
|
seaNear: seaNear / Math.max(1, n),
|
|
};
|
|
}
|
|
|
|
function localRouteAcceptableStrict(path, options = {}) {
|
|
if (!path?.length || path.length < 2) return false;
|
|
const st = localRouteTerrainStats(path);
|
|
const maxLength = options.maxLength ?? 74;
|
|
if (st.len > maxLength) return false;
|
|
if (st.highShare > 0 && !(options.allowPassCrossing && st.valleyPass > 0.62 && st.len <= 18)) return false;
|
|
// Coastal land roads are allowed; actual sea cells are already rejected by localRouteTerrainStats().
|
|
if (st.len > 26 && st.highShare > 0.34 && st.valleyPass < 0.34) return false;
|
|
if (st.len > 30 && st.steepShare > 0.42 && st.valleyPass < 0.36) return false;
|
|
if (st.len > 34 && st.mountain > 0.46 && st.valleyPass < 0.36) return false;
|
|
if (st.len > 26 && st.straightness > 0.82 && st.mountain > 0.38 && st.valleyPass < 0.40) return false;
|
|
if (st.len > 44 && st.ridge > 0.42 && st.valleyPass < 0.42) return false;
|
|
return true;
|
|
}
|
|
|
|
function sanitizeLocalRoads() {
|
|
const before = minorRoads.length;
|
|
const kept = [];
|
|
let pruned = 0;
|
|
for (const path of minorRoads) {
|
|
if (!path || path.length < 2) { pruned++; continue; }
|
|
const len = pathLengthCells(path);
|
|
const maxLength = len > 58 ? 68 : 78;
|
|
if (localRouteAcceptableStrict(path, { maxLength })) kept.push(path);
|
|
else pruned++;
|
|
}
|
|
minorRoads.length = 0;
|
|
minorRoads.push(...kept);
|
|
return { before, after: minorRoads.length, pruned };
|
|
}
|
|
|
|
|
|
function removePathSelfLoops(path) {
|
|
if (!path || path.length < 2) return path || [];
|
|
const out = [];
|
|
let pos = new Map();
|
|
function rebuildIndex() {
|
|
pos = new Map();
|
|
for (let k = 0; k < out.length; k++) pos.set(`${out[k][0]},${out[k][1]}`, k);
|
|
}
|
|
for (const p of path) {
|
|
if (!p || !inside(p[0], p[1]) || sea[indexOf(p[0], p[1])]) continue;
|
|
const key = `${p[0]},${p[1]}`;
|
|
if (out.length && out[out.length - 1][0] === p[0] && out[out.length - 1][1] === p[1]) continue;
|
|
if (pos.has(key)) {
|
|
const keep = pos.get(key) + 1;
|
|
if (out.length - keep > 2) {
|
|
out.length = keep;
|
|
rebuildIndex();
|
|
}
|
|
continue;
|
|
}
|
|
pos.set(key, out.length);
|
|
out.push([p[0], p[1]]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function expresswayUrbanTangleScore(path) {
|
|
if (!path?.length) return { share: 0, maxCity: null, coreHits: 0 };
|
|
let bestShare = 0;
|
|
let bestCity = null;
|
|
let bestCoreHits = 0;
|
|
for (const city of modernCities) {
|
|
if ((city.population || 0) < 28000) continue;
|
|
const envelope = Math.max(18, (city.urbanRadius || 10) * ((city.population || 0) >= 220000 ? 2.15 : 2.55));
|
|
const core = Math.max(5.0, (city.coreRadius || 4) + 4.2);
|
|
let inEnvelope = 0;
|
|
let coreHits = 0;
|
|
for (const [x, y] of path) {
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
if (d <= envelope) inEnvelope++;
|
|
if (d <= core) coreHits++;
|
|
}
|
|
const share = inEnvelope / Math.max(1, path.length);
|
|
if (share > bestShare) { bestShare = share; bestCity = city; bestCoreHits = coreHits; }
|
|
}
|
|
return { share: bestShare, maxCity: bestCity, coreHits: bestCoreHits };
|
|
}
|
|
|
|
function sanitizeExpresswayNetwork(debug = null) {
|
|
const before = expressways.length;
|
|
const cleaned = [];
|
|
let loopTrimmed = 0;
|
|
let urbanPruned = 0;
|
|
let shortPruned = 0;
|
|
const seen = new Set();
|
|
for (const path of expressways) {
|
|
const cleanedPath = removePathSelfLoops(path);
|
|
if (cleanedPath.length < (path?.length || 0)) loopTrimmed++;
|
|
const len = pathLengthCells(cleanedPath);
|
|
if (len < 12 || cleanedPath.length < 2) { shortPruned++; continue; }
|
|
const sig = cleanedPath.map((p, k) => k % 3 === 0 ? `${p[0]},${p[1]}` : '').filter(Boolean).join('|');
|
|
if (seen.has(sig)) continue;
|
|
seen.add(sig);
|
|
const tangle = expresswayUrbanTangleScore(cleanedPath);
|
|
// A through expressway may graze a city's fringe, but a path mostly inside
|
|
// one middle-sized urban envelope is rendered like an accidental city
|
|
// expressway. Drop those short/looping urban motorway fragments.
|
|
if (expressways.length > 1 && tangle.maxCity && (tangle.maxCity.population || 0) < 260000 && tangle.share > 0.46 && len < 105) {
|
|
urbanPruned++;
|
|
continue;
|
|
}
|
|
if (tangle.coreHits > (tangle.maxCity && (tangle.maxCity.population || 0) >= 260000 ? 8 : 3)) {
|
|
urbanPruned++;
|
|
continue;
|
|
}
|
|
cleaned.push(cleanedPath);
|
|
}
|
|
expressways.length = 0;
|
|
expressways.push(...cleaned);
|
|
pruneParallelSameMode(expressways, "expressway", transportFields.expresswayPotential, { threshold: 0.08, radius: 92, minKeep: 1, shortLength: 220 });
|
|
if (debug) debug.expresswaySanitization = { before, after: expressways.length, loopTrimmed, urbanPruned, shortPruned };
|
|
return { before, after: expressways.length, loopTrimmed, urbanPruned, shortPruned };
|
|
}
|
|
|
|
function buildFieldBackbone(debug, mode, outPaths, anchors, costField, potentialField, options = {}) {
|
|
const nodes = anchors.slice(0, options.maxNodes ?? (mode === "expressway" ? 38 : 76));
|
|
if (nodes.length < 2) return;
|
|
const policy = fieldBackbonePolicy(mode);
|
|
const label = mode === "expressway" ? "expresswayCorridors" : "nationalCorridors";
|
|
const penalty = new Float32Array(SIZE);
|
|
const accepted = new Float32Array(SIZE);
|
|
for (const path of outPaths) markPathInfluence(accepted, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), 1);
|
|
if (mode === "national") {
|
|
for (const path of expressways) markPathInfluence(penalty, path, 8, 0.16);
|
|
for (const path of externalRoads) markPathInfluence(accepted, path, 5, 0.55);
|
|
}
|
|
|
|
const degree = new Map();
|
|
const keyOf = (p) => `${p.x},${p.y}`;
|
|
const { find, unite } = makeUnionFind(nodes, keyOf);
|
|
|
|
const pairs = [];
|
|
for (let a = 0; a < nodes.length; a++) {
|
|
for (let b = a + 1; b < nodes.length; b++) {
|
|
const A = nodes[a];
|
|
const B = nodes[b];
|
|
const d = Math.hypot(A.x - B.x, A.y - B.y);
|
|
if (d < (options.minDistance ?? (mode === "expressway" ? 42 : 13)) || d > (options.maxDistance ?? (mode === "expressway" ? 186 : 126))) continue;
|
|
const c = approximateLineCost(A, B, costField);
|
|
if (!Number.isFinite(c) || c >= INF) continue;
|
|
const demand = Math.sqrt(pointPopulationProxy(A, mode) * pointPopulationProxy(B, mode)) / (mode === "expressway" ? 115000 : 48000);
|
|
const roleNeed =
|
|
((A.role || "").includes("gateway") || (B.role || "").includes("gateway") ? (mode === "expressway" ? 0.42 : 0.22) : 0) +
|
|
((A.role || "").includes("port") || (B.role || "").includes("port") ? (mode === "expressway" ? 0.24 : 0.18) : 0) +
|
|
(A.regionId !== B.regionId ? (mode === "expressway" ? 0.26 : 0.16) : 0);
|
|
const localScore = (A.score + B.score) * 0.16;
|
|
const jitter = hash2(A.x + B.x * 3, A.y + B.y * 5, seed + (mode === "expressway" ? 18720 : 18721)) * 0.05;
|
|
pairs.push({ A, B, d, score: d * c / Math.max(0.35, demand + roleNeed + localScore) + jitter });
|
|
}
|
|
}
|
|
pairs.sort((a, b) => a.score - b.score);
|
|
const skip = { pairs: pairs.length, degree: 0, noPath: 0, length: 0, mountain: 0, mountainReasons: {}, acceptable: 0, acceptableReasons: {}, parallel: 0, added: 0 };
|
|
|
|
let connectedAdds = 0;
|
|
let extraAdds = 0;
|
|
const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(10, Math.max(4, Math.ceil(nodes.length / 4))) : Math.min(34, Math.max(16, Math.ceil(nodes.length * 0.46))));
|
|
const maxExtra = options.maxExtra ?? (mode === "expressway" ? 2 : 7);
|
|
const maxDegree = options.maxDegree ?? (mode === "expressway" ? 2 : 4);
|
|
for (const pair of pairs) {
|
|
if (outPaths.length >= maxAdded) break;
|
|
const ak = keyOf(pair.A);
|
|
const bk = keyOf(pair.B);
|
|
const connects = find(ak) !== find(bk);
|
|
if (!connects && extraAdds >= maxExtra) { skip.degree++; continue; }
|
|
if ((degree.get(ak) || 0) >= maxDegree || (degree.get(bk) || 0) >= maxDegree) { skip.degree++; continue; }
|
|
const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, costField, penalty, {
|
|
curvePenalty: mode === "expressway" ? 0.125 : 0.065,
|
|
penaltyStrength: mode === "expressway" ? 7.20 : 1.05,
|
|
terrainFlowBias: mode === "expressway" ? 0.08 : 0.24,
|
|
surfaceGrain: mode === "expressway" ? 0.006 : 0.030,
|
|
relaxRadius: mode === "expressway" ? 1 : 2,
|
|
relaxLineWeight: mode === "expressway" ? 0.19 : 0.28,
|
|
maxPathLength: pair.d * (mode === "expressway" ? 2.75 : 2.55) + (mode === "expressway" ? 84 : 42),
|
|
snapRadius: mode === "expressway" ? 3.5 : 2.5,
|
|
heuristicWeight: mode === "expressway" ? 0.72 : 0.50,
|
|
});
|
|
const len = pathLengthCells(path);
|
|
if (!path.length) { skip.noPath++; continue; }
|
|
if (len < policy.minLength || len > pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd) { skip.length++; continue; }
|
|
if (mode === "expressway") {
|
|
const mountainCheck = mountainRouteAssessment(path, "expresswayMountainOnly");
|
|
if (!mountainCheck.ok) {
|
|
skip.mountain++;
|
|
countReason(skip, "mountainReasons", mountainCheck.reason);
|
|
continue;
|
|
}
|
|
const acceptCheck = routeAcceptableForMode(path, mode, potentialField, penalty, {}, { allowFallback: true, allowApproach: true });
|
|
if (!acceptCheck.ok) {
|
|
skip.acceptable++;
|
|
countReason(skip, "acceptableReasons", acceptCheck.reason);
|
|
continue;
|
|
}
|
|
} else {
|
|
const mountainCheck = mountainRouteAssessment(path, "national");
|
|
if (!mountainCheck.ok) {
|
|
skip.mountain++;
|
|
countReason(skip, "mountainReasons", mountainCheck.reason);
|
|
continue;
|
|
}
|
|
const acceptCheck = routeAcceptableForMode(path, "national", potentialField, penalty, {
|
|
minLength: policy.minLength,
|
|
maxLength: pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd,
|
|
maxHighElevationShare: policy.maxHighElevationShare,
|
|
maxSteepShare: policy.maxSteepShare,
|
|
});
|
|
if (!acceptCheck.ok) {
|
|
skip.acceptable++;
|
|
countReason(skip, "acceptableReasons", acceptCheck.reason);
|
|
continue;
|
|
}
|
|
}
|
|
const parallel = existingParallelShare(path, accepted, mode === "expressway" ? 0.010 : 0.18);
|
|
if (parallel > (connects ? policy.parallelConnected : policy.parallelExtra)) { skip.parallel++; continue; }
|
|
outPaths.push(path);
|
|
markPathInfluence(accepted, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), 1);
|
|
addCorridorInfluencePenalty(penalty, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), options.penaltyStrengthMark ?? (mode === "expressway" ? 2.10 : 0.30));
|
|
degree.set(ak, (degree.get(ak) || 0) + 1);
|
|
degree.set(bk, (degree.get(bk) || 0) + 1);
|
|
if (connects) {
|
|
unite(ak, bk);
|
|
connectedAdds++;
|
|
} else {
|
|
extraAdds++;
|
|
}
|
|
skip.added++;
|
|
debug[label].push({ from: pair.A.role, to: pair.B.role, length: Math.round(len), distance: Math.round(pair.d), score: Math.round(pair.score * 100) / 100, path });
|
|
}
|
|
debug[`${mode}AnchorCount`] = nodes.length;
|
|
debug[`${mode}ConnectedAdds`] = connectedAdds;
|
|
debug[`${mode}ExtraAdds`] = extraAdds;
|
|
debug[`${mode}SkipStats`] = skip;
|
|
}
|
|
|
|
function pathComesOutOfCity(path, city, anchor, mode = "national") {
|
|
if (!path?.length || !city || !anchor) return false;
|
|
const nearAnchorRadius = mode === "expressway" ? 5.5 : 4.5;
|
|
const exitRadius = mode === "expressway"
|
|
? Math.max(28, (city.urbanRadius || 12) * 1.75)
|
|
: Math.max(16, (city.urbanRadius || 9) * 1.18);
|
|
let touchesAnchor = false;
|
|
let leavesUrbanEnvelope = false;
|
|
for (const [x, y] of path) {
|
|
if (Math.hypot(x - anchor.x, y - anchor.y) <= nearAnchorRadius) touchesAnchor = true;
|
|
if (Math.hypot(x - city.x, y - city.y) >= exitRadius) leavesUrbanEnvelope = true;
|
|
if (touchesAnchor && leavesUrbanEnvelope) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function pathServesCityCenter(path, city, mode = "national") {
|
|
if (!path?.length || !city) return false;
|
|
const nearRadius = mode === "expressway"
|
|
? Math.max(20, (city.urbanRadius || 12) * 1.55)
|
|
: Math.max(10, (city.urbanRadius || 9) * 0.95);
|
|
const exitRadius = mode === "expressway"
|
|
? Math.max(28, (city.urbanRadius || 12) * 1.75)
|
|
: Math.max(16, (city.urbanRadius || 9) * 1.18);
|
|
let near = false;
|
|
let far = false;
|
|
for (const [x, y] of path) {
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
if (d <= nearRadius) near = true;
|
|
if (d >= exitRadius) far = true;
|
|
if (near && far) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function ensureExpresswayCityIntercity(debug, expressAnchors, expressCost) {
|
|
const forceCost = new Float32Array(SIZE);
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (sea[i]) { forceCost[i] = INF; continue; }
|
|
if (Number.isFinite(expressCost[i]) && expressCost[i] < INF) {
|
|
forceCost[i] = expressCost[i];
|
|
} else {
|
|
const nationalFallback = Number.isFinite(transportFields.national[i]) && transportFields.national[i] < INF ? transportFields.national[i] + 2.2 : 5.8;
|
|
forceCost[i] = nationalFallback + denseCorePenaltyAt(i) * 3.4 + preliminaryVillageInfluence[i] * 1.3 + Math.max(0, elevation[i] - 0.58) * 3.1 + slope[i] * 1.6 + ridgeField[i] * 1.4;
|
|
}
|
|
}
|
|
function forcedExpresswayAnchorForCity(city) {
|
|
const primary = cityPortalAnchors(city, "expressway")[0] || majorCitySuburbanAnchor(city) || portalSearchAroundPoint(city, "expressway", "urban-fringe-ic");
|
|
if (primary) return primary;
|
|
const inner = Math.max(8, Math.round((city.coreRadius || 4) + 5));
|
|
const outer = Math.max(inner + 10, Math.round((city.urbanRadius || 13) * 2.3));
|
|
let best = null;
|
|
for (let dy = -outer; dy <= outer; dy++) {
|
|
for (let dx = -outer; dx <= outer; dx++) {
|
|
const x = city.x + dx, y = city.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] || forceCost[i] >= INF) continue;
|
|
const target = Math.max(inner + 3, Math.min(outer - 2, (city.urbanRadius || 13) * 1.65));
|
|
const score = -forceCost[i] - Math.abs(d - target) * 0.045 - denseCorePenaltyAt(i) * 1.5 - preliminaryVillageInfluence[i] * 0.8 - ridgeField[i] * 0.4 + urbanEdge[i] * 0.28 + hash2(x, y, seed + 18891 + city.x * 11 + city.y * 17) * 0.04;
|
|
if (!best || score > best.score) best = { x, y, score, role: "urban-fringe-ic", regionId: regionIdAt(x, y), population: city.population || 0, city };
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
const eligibleCities = modernCities
|
|
.filter((c) => (c.population || 0) >= 150000 || c.isRegionalCapital || c.isPrefecturalCapital)
|
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
const cityAnchors = eligibleCities
|
|
.map((city) => {
|
|
const anchor = forcedExpresswayAnchorForCity(city);
|
|
return anchor ? { ...anchor, city, role: "urban-fringe-ic", population: city.population || anchor.population || 0, score: (anchor.score || 0) + Math.sqrt(city.population || 80000) / 900 } : null;
|
|
})
|
|
.filter(Boolean);
|
|
if (cityAnchors.length < 1) return;
|
|
const logisticsTargets = expressAnchors
|
|
.filter((p) => p && (p.role === "port-fringe" || p.role === "external-gateway"))
|
|
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
const otherTargets = [...cityAnchors, ...logisticsTargets]
|
|
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
const penalty = new Float32Array(SIZE);
|
|
const accepted = new Float32Array(SIZE);
|
|
for (const path of expressways) {
|
|
markPathInfluence(penalty, path, 64, 9.20);
|
|
markPathInfluence(accepted, path, 64, 1.0);
|
|
}
|
|
let added = 0;
|
|
const stats = { cities: cityAnchors.length, covered: 0, noCandidates: 0, tried: 0, noPath: 0, length: 0, notOutbound: 0, mountain: 0, mountainReasons: {}, unacceptable: 0, unacceptableReasons: {}, parallel: 0 };
|
|
for (const anchor of cityAnchors) {
|
|
const city = anchor.city;
|
|
if (expressways.some((path) => pathServesCityCenter(path, city, "expressway"))) { stats.covered++; continue; }
|
|
const candidates = otherTargets
|
|
.filter((q) => q !== anchor && q.city !== city)
|
|
.map((q) => {
|
|
const d = Math.hypot(q.x - anchor.x, q.y - anchor.y);
|
|
const c0 = approximateLineCost(anchor, q, forceCost);
|
|
const c = Number.isFinite(c0) && c0 < INF ? c0 : 2.8;
|
|
return { q, d, c };
|
|
})
|
|
.filter((e) => e.d >= 32 && e.d <= 260)
|
|
.sort((a, b) => {
|
|
const aCity = a.q.role === "urban-fringe-ic" ? -22 : 0;
|
|
const bCity = b.q.role === "urban-fringe-ic" ? -22 : 0;
|
|
return (a.d * a.c + aCity) - (b.d * b.c + bCity);
|
|
});
|
|
if (!candidates.length) stats.noCandidates++;
|
|
for (const opt of candidates.slice(0, 10)) {
|
|
stats.tried++;
|
|
const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", forceCost, penalty, {
|
|
curvePenalty: 0.13,
|
|
penaltyStrength: 13.80,
|
|
terrainFlowBias: 0.08,
|
|
surfaceGrain: 0.006,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.18,
|
|
maxPathLength: opt.d * 3.35 + 150,
|
|
snapRadius: 2.2,
|
|
heuristicWeight: 0.80,
|
|
searchPad: Math.ceil(Math.max(64, Math.min(132, opt.d * 0.72))),
|
|
});
|
|
const len = pathLengthCells(path);
|
|
if (!path.length) { stats.noPath++; continue; }
|
|
if (len < 20 || len > opt.d * 3.55 + 168) { stats.length++; continue; }
|
|
if (!pathComesOutOfCity(path, city, anchor, "expressway")) { stats.notOutbound++; continue; }
|
|
const mountainCheck = mountainRouteAssessment(path, "expresswayMountainOnly");
|
|
if (!mountainCheck.ok) {
|
|
stats.mountain++;
|
|
countReason(stats, "mountainReasons", mountainCheck.reason);
|
|
continue;
|
|
}
|
|
const acceptCheck = expresswayRouteAssessment(path, { allowFallback: true, allowApproach: true });
|
|
if (!acceptCheck.ok) {
|
|
const risk = acceptCheck.risk;
|
|
if (risk.cityCoreShare > 0.36 || risk.villageCoreShare > 0.38 || risk.denseShare > 0.42) {
|
|
stats.unacceptable++;
|
|
countReason(stats, "unacceptableReasons", acceptCheck.reason);
|
|
continue;
|
|
}
|
|
}
|
|
const parallel = existingParallelShare(path, accepted, 0.010);
|
|
if (parallel > 0.105) { stats.parallel++; continue; }
|
|
expressways.push(path);
|
|
markPathInfluence(accepted, path, 86, 1.0);
|
|
addCorridorInfluencePenalty(penalty, path, 82, 12.40);
|
|
debug.expresswayCorridors.push({ from: "forced-city-intercity", city: city.name, to: opt.q.city?.name || opt.q.role, distance: Math.round(opt.d), length: Math.round(len), path });
|
|
added++;
|
|
break;
|
|
}
|
|
}
|
|
debug.forcedExpresswayCityConnections = (debug.forcedExpresswayCityConnections || 0) + added;
|
|
debug.forcedExpresswayCityStats = stats;
|
|
}
|
|
|
|
function ensureNationalCityOutbound(debug, nationalAnchors, nationalCost) {
|
|
const targetAnchors = nationalAnchors
|
|
.filter((p) => p && ["urban-portal", "market-portal", "port", "external-gateway", "large-village"].includes(p.role))
|
|
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
const penalty = new Float32Array(SIZE);
|
|
const accepted = new Float32Array(SIZE);
|
|
for (const path of nationalRoads) {
|
|
markPathInfluence(penalty, path, 6, 0.42);
|
|
markPathInfluence(accepted, path, 6, 1.0);
|
|
}
|
|
for (const path of externalRoads) markPathInfluence(accepted, path, 5, 0.55);
|
|
let added = 0;
|
|
const cities = modernCities
|
|
.filter((c) => (c.population || 0) >= 52000 || c.isRegionalCapital || c.isPrefecturalCapital)
|
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
for (const city of cities) {
|
|
const start = cityPortalAnchors(city, "national")[0] || portalSearchAroundPoint(city, "national", "urban-portal");
|
|
if (!start) continue;
|
|
if (nationalRoads.some((path) => pathServesCityCenter(path, city, "national"))) continue;
|
|
const candidates = targetAnchors
|
|
.filter((q) => q !== start && q.city !== city && q.source !== city)
|
|
.map((q) => ({ q, d: Math.hypot(q.x - start.x, q.y - start.y), c: approximateLineCost(start, q, nationalCost) }))
|
|
.filter((e) => e.d >= 14 && e.d <= 126 && Number.isFinite(e.c) && e.c < INF)
|
|
.sort((a, b) => {
|
|
const aCity = a.q.role === "urban-portal" ? -10 : 0;
|
|
const bCity = b.q.role === "urban-portal" ? -10 : 0;
|
|
return (a.d * a.c + aCity) - (b.d * b.c + bCity);
|
|
});
|
|
for (const opt of candidates.slice(0, 8)) {
|
|
const path = routeBetweenTrafficCandidates(start, opt.q, "national", nationalCost, penalty, {
|
|
curvePenalty: 0.060,
|
|
penaltyStrength: 1.04,
|
|
terrainFlowBias: 0.26,
|
|
surfaceGrain: 0.030,
|
|
relaxRadius: 2,
|
|
relaxLineWeight: 0.28,
|
|
maxPathLength: opt.d * 3.05 + 74,
|
|
snapRadius: 2.0,
|
|
heuristicWeight: 0.60,
|
|
searchPad: Math.ceil(Math.max(42, Math.min(110, opt.d * 0.62))),
|
|
});
|
|
const len = pathLengthCells(path);
|
|
if (len < 5 || len > opt.d * 3.15 + 82) continue;
|
|
if (!pathComesOutOfCity(path, city, start, "national")) continue;
|
|
if (routeTooStraightAcrossMountains(path, "national")) continue;
|
|
if (!transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 5, maxLength: opt.d * 2.85 + 58, maxHighElevationShare: 0.36, maxSteepShare: 0.50 })) continue;
|
|
const parallel = existingParallelShare(path, accepted, 0.18);
|
|
if (parallel > 0.58) continue;
|
|
nationalRoads.push(path);
|
|
markPathInfluence(accepted, path, 6, 1.0);
|
|
addCorridorInfluencePenalty(penalty, path, 6, 0.38);
|
|
debug.nationalCorridors.push({ from: "forced-city-outbound", city: city.name, to: opt.q.city?.name || opt.q.source?.name || opt.q.role, length: Math.round(len), path });
|
|
added++;
|
|
break;
|
|
}
|
|
}
|
|
debug.forcedNationalCityConnections = (debug.forcedNationalCityConnections || 0) + added;
|
|
}
|
|
|
|
function buildDensityFlowRoadSystem(debug) {
|
|
const expressAnchors = roadAnchorsForMode("expressway");
|
|
const nationalAnchors = roadAnchorsForMode("national");
|
|
debug.majorCitySuburbanAnchors = expressAnchors
|
|
.filter((p) => p.role === "urban-fringe-ic")
|
|
.map((p) => ({ x: p.x, y: p.y, city: p.city?.name, population: p.population }));
|
|
|
|
const expressFlow = buildTrafficFlowField("expressway", expressAnchors, expresswayCorridorCost, 14);
|
|
const expressCost = fieldAdjustedCost(expresswayCorridorCost, "expressway", expressFlow);
|
|
buildFieldBackbone(debug, "expressway", expressways, expressAnchors, expressCost, transportFields.expresswayPotential, {
|
|
maxNodes: 48,
|
|
maxAdded: Math.min(4, Math.max(2, Math.ceil(expressAnchors.length / 12))),
|
|
maxExtra: 0,
|
|
minDistance: 38,
|
|
maxDistance: 190,
|
|
maxDegree: 2,
|
|
parallelRadius: 84,
|
|
penaltyStrengthMark: 16.50,
|
|
});
|
|
ensureExpresswayCityIntercity(debug, expressAnchors, expressCost);
|
|
|
|
// Recompute national flow with expressways already present. National roads
|
|
// are allowed to cross/approach motorways but are discouraged from becoming
|
|
// a duplicate motorway frontage road for long distances.
|
|
const nationalFlow = buildTrafficFlowField("national", nationalAnchors, transportFields.national, 50);
|
|
const nationalCost = fieldAdjustedCost(transportFields.national, "national", nationalFlow);
|
|
buildFieldBackbone(debug, "national", nationalRoads, nationalAnchors, nationalCost, transportFields.nationalPotential, {
|
|
maxNodes: 78,
|
|
maxAdded: Math.min(38, Math.max(18, Math.ceil(nationalAnchors.length * 0.45))),
|
|
maxExtra: 8,
|
|
minDistance: 12,
|
|
maxDistance: 130,
|
|
maxDegree: 4,
|
|
parallelRadius: 6,
|
|
penaltyStrengthMark: 0.32,
|
|
});
|
|
|
|
// Guarantee light national access to large urban areas whose portals were
|
|
// deduped away. The target remains a portal/field anchor, not the city point.
|
|
const nationalInfluence = influenceFromPaths([...nationalRoads, ...externalRoads], 7);
|
|
const nationalPenalty = influenceFromPaths(nationalRoads, 6);
|
|
const nationalTargets = dedupeAnchors([...nationalAnchors, ...externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 }))], 5);
|
|
for (const city of modernCities.filter((c) => (c.population || 0) >= 90000).sort((a, b) => (b.population || 0) - (a.population || 0))) {
|
|
if ((nationalInfluence[indexOf(city.x, city.y)] || 0) > 0.18) continue;
|
|
const portals = cityPortalAnchors(city, "national");
|
|
const start = portals[0] || portalSearchAroundPoint(city, "national", "urban-portal");
|
|
if (!start) continue;
|
|
const target = nationalTargets
|
|
.filter((q) => Math.hypot(q.x - start.x, q.y - start.y) > 8)
|
|
.map((q) => ({ q, d: Math.hypot(q.x - start.x, q.y - start.y), c: approximateLineCost(start, q, nationalCost) }))
|
|
.filter((e) => e.d <= 82 && Number.isFinite(e.c) && e.c < INF)
|
|
.sort((a, b) => (a.d * a.c) - (b.d * b.c))[0];
|
|
if (!target) continue;
|
|
const path = routeBetweenTrafficCandidates(start, target.q, "national", nationalCost, nationalPenalty, {
|
|
curvePenalty: 0.060,
|
|
penaltyStrength: 0.82,
|
|
terrainFlowBias: 0.24,
|
|
surfaceGrain: 0.030,
|
|
relaxRadius: 2,
|
|
relaxLineWeight: 0.28,
|
|
maxPathLength: target.d * 2.6 + 38,
|
|
snapRadius: 2.5,
|
|
});
|
|
const len = pathLengthCells(path);
|
|
if (len >= 5 && len <= target.d * 2.7 + 44 && !routeTooStraightAcrossMountains(path, "national")) {
|
|
nationalRoads.push(path);
|
|
debug.nationalCorridors.push({ from: "city-portal-guarantee", city: city.name, to: target.q.role, length: Math.round(len), path });
|
|
addCorridorInfluencePenalty(nationalPenalty, path, 6, 0.28);
|
|
}
|
|
}
|
|
|
|
ensureNationalCityOutbound(debug, nationalAnchors, nationalCost);
|
|
|
|
pruneParallelSameMode(expressways, "expressway", transportFields.expresswayPotential, { threshold: 0.08, radius: 92, minKeep: 1, shortLength: 220 });
|
|
pruneParallelSameMode(nationalRoads, "national", transportFields.nationalPotential, { threshold: 0.62, radius: 2, minKeep: 10, shortLength: 18 });
|
|
|
|
// Pruning may remove short stubs that were serving as the only outward
|
|
// connection for a city. Run the hard city-outbound guarantees after pruning
|
|
// as the final road graph invariant.
|
|
ensureExpresswayCityIntercity(debug, expressAnchors, expressCost);
|
|
ensureNationalCityOutbound(debug, nationalAnchors, nationalCost);
|
|
sanitizeExpresswayNetwork(debug);
|
|
}
|
|
|
|
function rebuildRoadTransportByCorridors() {
|
|
const debug = {
|
|
strategy: "density-field-portals-plus-flow-backbone",
|
|
routePolicySummary: {
|
|
expresswayAcceptance: routePolicies.expresswayAcceptance,
|
|
fieldBackbone: routePolicies.fieldBackbone,
|
|
},
|
|
clearedGeneratedNational: nationalRoads.length,
|
|
clearedGeneratedExpressways: expressways.length,
|
|
majorCitySuburbanAnchors: [],
|
|
expresswayCorridors: [],
|
|
nationalCorridors: [],
|
|
};
|
|
nationalRoads.length = 0;
|
|
expressways.length = 0;
|
|
minorRoads.length = 0;
|
|
buildDensityFlowRoadSystem(debug);
|
|
return debug;
|
|
}
|
|
|
|
function addShortConnector(outPaths, a, b, mode = "local") {
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
const costField = mode === "expressway" ? expresswayCorridorCost : mode === "national" ? transportFields.national : transportFields.local;
|
|
let path = [];
|
|
if (d > 2.2) {
|
|
path = routeBetweenTrafficCandidates(a, b, mode === "expressway" ? "expressway" : mode === "national" ? "national" : "local", costField, null, {
|
|
curvePenalty: mode === "expressway" ? 0.095 : mode === "national" ? 0.045 : 0.035,
|
|
penaltyStrength: 0.0,
|
|
terrainFlowBias: mode === "expressway" ? 0.12 : mode === "national" ? 0.22 : 0.28,
|
|
surfaceGrain: 0.020,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.22,
|
|
maxPathLength: d * 2.6 + 8,
|
|
snapRadius: 0.5,
|
|
searchPad: Math.max(6, Math.ceil(d + 4)),
|
|
});
|
|
} else {
|
|
const steps = Math.max(1, Math.ceil(d));
|
|
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)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || costField[i] >= INF) return false;
|
|
if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]);
|
|
}
|
|
}
|
|
if (path.length >= 2 && maxSegmentLength(path) <= 1.6 && pathLengthCells(path) <= d * 2.25 + 10) {
|
|
if (mode === "local" && !localRouteAcceptableStrict(path, { maxLength: Math.max(10, d * 2.45 + 12) })) return false;
|
|
outPaths.push(path);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function stitchRasterNearContacts() {
|
|
const debug = { expressway: 0, national: 0, localToNational: 0, local: 0, broadNearMisses: 0 };
|
|
function sampledCells(paths, step = 1) {
|
|
const cells = [];
|
|
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
|
|
const path = paths[pathId];
|
|
for (let k = 0; k < path.length; k += step) {
|
|
const [x, y] = path[k];
|
|
if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, pathId });
|
|
}
|
|
}
|
|
return cells;
|
|
}
|
|
function endpointListWithIds(paths) {
|
|
const out = [];
|
|
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
|
|
const p = paths[pathId];
|
|
if (!p || p.length < 2) continue;
|
|
out.push({ x: p[0][0], y: p[0][1], pathId });
|
|
const q = p[p.length - 1];
|
|
out.push({ x: q[0], y: q[1], pathId });
|
|
}
|
|
return out;
|
|
}
|
|
function nearestWithin(source, targets, radius, excludeSamePath = true) {
|
|
let best = null;
|
|
let bestD = radius + 1;
|
|
for (const t of targets) {
|
|
if (excludeSamePath && source.pathId != null && t.pathId === source.pathId) continue;
|
|
const d = Math.hypot(source.x - t.x, source.y - t.y);
|
|
if (d > 0.01 && d < bestD) {
|
|
bestD = d;
|
|
best = t;
|
|
}
|
|
}
|
|
return best ? { target: best, d: bestD } : null;
|
|
}
|
|
function stitchEndpoints(paths, mode, targets, maxAdds, radius) {
|
|
let added = 0;
|
|
const endpoints = endpointListWithIds(paths);
|
|
for (const ep of endpoints) {
|
|
if (added >= maxAdds) break;
|
|
const near = nearestWithin(ep, targets, radius, true);
|
|
if (near && addShortConnector(paths, ep, near.target, mode)) added++;
|
|
}
|
|
return added;
|
|
}
|
|
const expresswayCells = sampledCells(expressways, 1);
|
|
const nationalCells = sampledCells([...nationalRoads, ...externalRoads], 1);
|
|
const localCells = sampledCells(minorRoads, 1);
|
|
debug.expressway += stitchEndpoints(expressways, "expressway", expresswayCells, 8, 34.0);
|
|
debug.national += stitchEndpoints(nationalRoads, "national", nationalCells, 48, 14.0);
|
|
debug.localToNational += stitchEndpoints(minorRoads, "local", nationalCells, 240, 20.0);
|
|
debug.local += stitchEndpoints(minorRoads, "local", localCells, 260, 16.0);
|
|
|
|
// Also stitch near-miss interiors: visually crossing/adjacent roads that do
|
|
// not share a raster cell. This addresses line simplification and diagonal
|
|
// near-contact cases not caught by endpoint-only repair.
|
|
const allLocalTargets = [...nationalCells, ...localCells];
|
|
const candidates = sampledCells([...nationalRoads, ...minorRoads], 3)
|
|
.sort((a, b) => hash2(a.x, a.y, seed + 18377) - hash2(b.x, b.y, seed + 18377));
|
|
for (const c of candidates) {
|
|
if (debug.broadNearMisses >= 260) break;
|
|
const near = nearestWithin(c, allLocalTargets, 4.75, true);
|
|
if (!near) continue;
|
|
const out = c.pathId < nationalRoads.length ? nationalRoads : minorRoads;
|
|
const mode = out === nationalRoads ? "national" : "local";
|
|
if (addShortConnector(out, c, near.target, mode)) debug.broadNearMisses++;
|
|
}
|
|
// Expressway stitching can add new motorway cells; remove loopbacks before
|
|
// regenerating the IC/access layer so final spacing is applied to the
|
|
// simplified motorway graph.
|
|
sanitizeExpresswayNetwork();
|
|
generateInterchangesForExpressways();
|
|
return debug;
|
|
}
|
|
|
|
function stitchLongLocalBranches() {
|
|
const debug = { localToTrunk: 0, localToLocal: 0 };
|
|
function cells(paths, step = 2) {
|
|
const out = [];
|
|
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
|
|
const path = paths[pathId];
|
|
for (let k = 0; k < path.length; k += step) {
|
|
const [x, y] = path[k];
|
|
if (inside(x, y) && !sea[indexOf(x, y)]) out.push({ x, y, pathId });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
function endpoints(paths) {
|
|
const out = [];
|
|
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
|
|
const p = paths[pathId];
|
|
if (!p || p.length < 2) continue;
|
|
out.push({ x: p[0][0], y: p[0][1], pathId });
|
|
const q = p[p.length - 1];
|
|
out.push({ x: q[0], y: q[1], pathId });
|
|
}
|
|
return out;
|
|
}
|
|
function nearest(source, targets, radius, excludePath = null) {
|
|
let best = null;
|
|
let bestD = radius + 1;
|
|
for (const t of targets) {
|
|
if (excludePath != null && t.pathId === excludePath) continue;
|
|
const d = Math.hypot(source.x - t.x, source.y - t.y);
|
|
if (d > 0.01 && d < bestD) {
|
|
bestD = d;
|
|
best = t;
|
|
}
|
|
}
|
|
return best ? { ...best, d: bestD } : null;
|
|
}
|
|
const trunkCells = cells([...nationalRoads, ...externalRoads], 2);
|
|
const localCells = cells(minorRoads, 2);
|
|
const eps = endpoints(minorRoads)
|
|
.sort((a, b) => hash2(a.x, a.y, seed + 18491) - hash2(b.x, b.y, seed + 18491));
|
|
for (const ep of eps) {
|
|
if (debug.localToTrunk >= 160) break;
|
|
const hit = nearest(ep, trunkCells, 20, null);
|
|
if (!hit) continue;
|
|
if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToTrunk++;
|
|
}
|
|
const eps2 = endpoints(minorRoads)
|
|
.sort((a, b) => hash2(a.x, a.y, seed + 18493) - hash2(b.x, b.y, seed + 18493));
|
|
for (const ep of eps2) {
|
|
if (debug.localToLocal >= 150) break;
|
|
const hit = nearest(ep, localCells, 17, ep.pathId);
|
|
if (!hit) continue;
|
|
if (addShortConnector(minorRoads, ep, hit, "local")) debug.localToLocal++;
|
|
}
|
|
return debug;
|
|
}
|
|
|
|
const corridorRoadNetworkDebug = rebuildRoadTransportByCorridors();
|
|
|
|
const transportDebugLayers = {
|
|
packedHeatmaps: true,
|
|
expresswayPotential: packDebugField(transportFields.expresswayPotential),
|
|
railPotential: packDebugField(transportFields.railPotential),
|
|
nationalRoadPotential: packDebugField(transportFields.nationalPotential),
|
|
slopeSeaPenalty: (() => {
|
|
const src = new Float32Array(SIZE);
|
|
for (let i = 0; i < SIZE; i++) src[i] = sea[i] ? 1 : clamp(slope[i] * 1.55 + Math.max(0, elevation[i] - 0.58) * 2.2 + ridgeField[i] * 0.42);
|
|
return packDebugField(src);
|
|
})(),
|
|
components: [],
|
|
repairedSegments: [],
|
|
unservedSettlements: [],
|
|
corridorRoadNetwork: {
|
|
strategy: corridorRoadNetworkDebug.strategy,
|
|
routePolicySummary: corridorRoadNetworkDebug.routePolicySummary,
|
|
clearedGeneratedNational: corridorRoadNetworkDebug.clearedGeneratedNational,
|
|
clearedGeneratedExpressways: corridorRoadNetworkDebug.clearedGeneratedExpressways,
|
|
majorCitySuburbanAnchors: corridorRoadNetworkDebug.majorCitySuburbanAnchors,
|
|
expresswayCorridorCount: corridorRoadNetworkDebug.expresswayCorridors.length,
|
|
nationalCorridorCount: corridorRoadNetworkDebug.nationalCorridors.length,
|
|
expresswayAnchorCount: corridorRoadNetworkDebug.expresswayAnchorCount,
|
|
nationalAnchorCount: corridorRoadNetworkDebug.nationalAnchorCount,
|
|
expresswaySkipStats: corridorRoadNetworkDebug.expresswaySkipStats,
|
|
nationalSkipStats: corridorRoadNetworkDebug.nationalSkipStats,
|
|
forcedExpresswayCityConnections: corridorRoadNetworkDebug.forcedExpresswayCityConnections || 0,
|
|
forcedNationalCityConnections: corridorRoadNetworkDebug.forcedNationalCityConnections || 0,
|
|
forcedExpresswayCityStats: corridorRoadNetworkDebug.forcedExpresswayCityStats,
|
|
},
|
|
};
|
|
// Corridor selection gives the road hierarchy, but a small topology pass is
|
|
// still needed to remove isolated fragments and near-miss components. Keep
|
|
// the repair budget bounded so it connects existing backbones rather than
|
|
// recreating the removed field-generated spaghetti layer.
|
|
for (const repair of [
|
|
repairTransportConnectivity(expressways, "expressway", expresswayCorridorCost, transportFields.expresswayPotential, {
|
|
minImportance: 5.5,
|
|
minComponentCells: 10,
|
|
maxComponents: 8,
|
|
maxRepairs: 3,
|
|
maxRepairDistance: 145,
|
|
minRepairDistance: 18,
|
|
searchPad: 48,
|
|
penaltyRadius: 18,
|
|
penaltyStrength: 4.2,
|
|
curvePenalty: 0.13,
|
|
terrainFlowBias: 0.09,
|
|
surfaceGrain: 0.006,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.20,
|
|
highPotentialThreshold: 0.24,
|
|
}),
|
|
repairTransportConnectivity(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, {
|
|
minImportance: 4.0,
|
|
minComponentCells: 7,
|
|
maxComponents: 14,
|
|
maxRepairs: 12,
|
|
maxRepairDistance: 96,
|
|
minRepairDistance: 8,
|
|
searchPad: 36,
|
|
penaltyRadius: 6,
|
|
penaltyStrength: 1.15,
|
|
curvePenalty: 0.060,
|
|
terrainFlowBias: 0.24,
|
|
surfaceGrain: 0.030,
|
|
relaxRadius: 2,
|
|
relaxLineWeight: 0.28,
|
|
highPotentialThreshold: 0.28,
|
|
}),
|
|
repairTransportConnectivity(railways, "rail", transportFields.rail, transportFields.railPotential, {
|
|
minImportance: 10.5,
|
|
minComponentCells: 18,
|
|
maxComponents: 6,
|
|
maxRepairs: 3,
|
|
maxRepairDistance: 105,
|
|
penaltyRadius: 7,
|
|
penaltyStrength: 2.0,
|
|
curvePenalty: 0.16,
|
|
terrainFlowBias: 0.13,
|
|
surfaceGrain: 0.008,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.50,
|
|
highPotentialThreshold: 0.36,
|
|
}),
|
|
]) {
|
|
transportDebugLayers.components.push(...repair.components);
|
|
transportDebugLayers.repairedSegments.push(...repair.repairs);
|
|
}
|
|
|
|
function pruneShortTransportSegments(paths, minLength, minKeep = 1) {
|
|
const kept = [];
|
|
let pruned = 0;
|
|
const sorted = (paths || []).map((path, index) => ({ path, index, len: pathLengthCells(path) }));
|
|
for (const row of sorted) {
|
|
if ((row.len >= minLength || kept.length < minKeep) && (row.path?.length || 0) >= 2) kept.push(row.path);
|
|
else pruned++;
|
|
}
|
|
paths.length = 0;
|
|
paths.push(...kept);
|
|
return { pruned, kept: kept.length };
|
|
}
|
|
|
|
const nationalEndpointRepair = repairDanglingTransportEndpoints(nationalRoads, "national", transportFields.national, [...externalRoads, ...expressways, ...railways], transportFields.nationalPotential, {
|
|
maxAdded: 18,
|
|
maxTargetDistance: 58,
|
|
maxPathLength: 86,
|
|
curvePenalty: 0.060,
|
|
terrainFlowBias: 0.24,
|
|
surfaceGrain: 0.030,
|
|
relaxRadius: 2,
|
|
relaxLineWeight: 0.28,
|
|
targetRadius: 6,
|
|
penaltyRadius: 6,
|
|
addedPenalty: 0.24,
|
|
});
|
|
transportDebugLayers.endpointRepairs = [nationalEndpointRepair];
|
|
transportDebugLayers.repairedSegments.push(...nationalEndpointRepair.added);
|
|
|
|
const expressEndpointRepair = repairDanglingTransportEndpoints(expressways, "expressway", expresswayCorridorCost, [...externalExpressways, ...nationalRoads], transportFields.expresswayPotential, {
|
|
maxAdded: 3,
|
|
maxTargetDistance: 78,
|
|
maxPathLength: 128,
|
|
curvePenalty: 0.13,
|
|
terrainFlowBias: 0.09,
|
|
surfaceGrain: 0.006,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.20,
|
|
targetRadius: 9,
|
|
penaltyRadius: 20,
|
|
penaltyStrength: 4.4,
|
|
addedPenalty: 1.10,
|
|
});
|
|
transportDebugLayers.endpointRepairs.push(expressEndpointRepair);
|
|
transportDebugLayers.repairedSegments.push(...expressEndpointRepair.added);
|
|
transportDebugLayers.expresswaySanitizationAfterRepair = sanitizeExpresswayNetwork();
|
|
|
|
function downgradeShortNationalRoads(minLength = 18, protectedKeep = 8) {
|
|
const kept = [];
|
|
const downgraded = [];
|
|
for (const path of nationalRoads) {
|
|
if (!path || path.length < 2) continue;
|
|
const len = pathLengthCells(path);
|
|
if (len < minLength && nationalRoads.length - downgraded.length > protectedKeep) downgraded.push(path);
|
|
else kept.push(path);
|
|
}
|
|
nationalRoads.length = 0;
|
|
nationalRoads.push(...kept);
|
|
minorRoads.push(...downgraded);
|
|
return { threshold: minLength, downgraded: downgraded.length, kept: kept.length };
|
|
}
|
|
|
|
transportDebugLayers.shortNationalDowngrade = downgradeShortNationalRoads(18, 10);
|
|
transportDebugLayers.shortSegmentPruning = {
|
|
expressway: pruneShortTransportSegments(expressways, 12, 1),
|
|
national: pruneShortTransportSegments(nationalRoads, 9, 8),
|
|
};
|
|
|
|
const expressTerminalPrune = pruneDanglingTerminalSegments(expressways, "expressway", [...externalExpressways, ...nationalRoads], {
|
|
oneInvalidMax: 26,
|
|
bothInvalidMax: 42,
|
|
minKeep: 1,
|
|
targetRadius: 9,
|
|
});
|
|
const nationalTerminalPrune = pruneDanglingTerminalSegments(nationalRoads, "national", [...externalRoads, ...expressways, ...railways], {
|
|
oneInvalidMax: 14,
|
|
bothInvalidMax: 26,
|
|
minKeep: 12,
|
|
targetRadius: 6,
|
|
});
|
|
|
|
transportDebugLayers.graphCandidateNetworks = [];
|
|
transportDebugLayers.parallelPruning = [];
|
|
transportDebugLayers.prunedDanglingSegments = [expressTerminalPrune, nationalTerminalPrune];
|
|
transportDebugLayers.expresswaySanitizationFinal = sanitizeExpresswayNetwork();
|
|
|
|
function generateLocalRoadsForUnservedSettlements() {
|
|
const trunkInfluence = cachedInfluenceFromPaths([...nationalRoads, ...railways, ...externalRoads], 8, "local:trunk");
|
|
const candidates = [...villages, ...markets, ...ports]
|
|
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)])
|
|
.map((p) => {
|
|
const i = indexOf(p.x, p.y);
|
|
const settlementWeight = p.portClass ? 1.2 : p.population ? clamp(p.population / 18000, 0.35, 1.4) : 0.45;
|
|
return { ...p, score: settlementWeight + transportFields.localPotential[i] * 0.65 - trunkInfluence[i] * 1.15 };
|
|
})
|
|
.filter((p) => p.score > 0.06 && trunkInfluence[indexOf(p.x, p.y)] < 0.34)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 150);
|
|
const localPenalty = new Float32Array(SIZE);
|
|
const paths = [];
|
|
const served = [];
|
|
for (const start of candidates) {
|
|
if (paths.length >= 115) break;
|
|
if (distanceToNearest(served, start.x, start.y) < 4.5) continue;
|
|
let path = traceCorridorByCost(
|
|
start,
|
|
(x, y, i) => trunkInfluence[i] > 0.18 || (paths.length > 6 && localPenalty[i] > 0.045),
|
|
transportFields.local,
|
|
localPenalty,
|
|
{ curvePenalty: 0.08, penaltyStrength: 1.00, minGoalDistance: 5, regionId: start.regionId, maxExpanded: SIZE }
|
|
);
|
|
if (path.length < 4 || path.length > 86) continue;
|
|
if (!localRouteAcceptableStrict(path, { maxLength: 72 })) continue;
|
|
if (!transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength: 86, maxHighElevationShare: 0.34, maxSteepShare: 0.50 })) continue;
|
|
paths.push(path);
|
|
served.push(start);
|
|
addCorridorInfluencePenalty(localPenalty, path, 4, 0.22);
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
function runLocalAccessPass({
|
|
candidates,
|
|
accessInfluence,
|
|
localPenalty,
|
|
maxAdded = 80,
|
|
minSpacing = 3.5,
|
|
maxLength = 82,
|
|
debugMode = "local-access",
|
|
from = "unserved",
|
|
to = "network",
|
|
targetPredicate = null,
|
|
}) {
|
|
const served = [];
|
|
let added = 0;
|
|
for (const start of candidates) {
|
|
if (added >= maxAdded) break;
|
|
if (distanceToNearest(served, start.x, start.y) < minSpacing) continue;
|
|
let path = traceCorridorByCost(
|
|
start,
|
|
targetPredicate || ((x, y, i) => accessInfluence[i] > 0.16 || localPenalty[i] > 0.075),
|
|
transportFields.local,
|
|
localPenalty,
|
|
{ curvePenalty: 0.020, penaltyStrength: 0.90, minGoalDistance: 3, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.72), terrainFlowBias: 0.26, surfaceGrain: 0.042 }
|
|
);
|
|
path = relaxRouteToTerrain(path, transportFields.local, { radius: 2, lineWeight: 0.25, grain: 0.048, iterations: 1 });
|
|
const strictMaxLength = Math.min(maxLength, 74);
|
|
const ok = path.length >= 4 && path.length <= maxLength && localRouteAcceptableStrict(path, { maxLength: strictMaxLength }) && transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength: strictMaxLength, maxHighElevationShare: 0.34, maxSteepShare: 0.50 });
|
|
transportDebugLayers.unservedSettlements.push({ x: start.x, y: start.y, kind: start.kind || "Unserved Settlement", mode: debugMode, repaired: ok });
|
|
if (!ok) continue;
|
|
minorRoads.push(path);
|
|
served.push(start);
|
|
added++;
|
|
transportDebugLayers.repairedSegments.push({ mode: "local", path, from, to });
|
|
addCorridorInfluencePenalty(localPenalty, path, 4, 0.18);
|
|
if (accessInfluence) addCorridorInfluencePenalty(accessInfluence, path, 5, 0.22);
|
|
}
|
|
return added;
|
|
}
|
|
|
|
minorRoads.push(...generateLocalRoadsForUnservedSettlements());
|
|
const localEndpointRepair = repairDanglingTransportEndpoints(minorRoads, "local", transportFields.local, [...nationalRoads, ...externalRoads, ...railways], transportFields.localPotential, {
|
|
maxAdded: 36,
|
|
maxTargetDistance: 34,
|
|
curvePenalty: 0.055,
|
|
terrainFlowBias: 0.26,
|
|
surfaceGrain: 0.048,
|
|
relaxRadius: 2,
|
|
relaxLineWeight: 0.30,
|
|
targetRadius: 5,
|
|
});
|
|
transportDebugLayers.endpointRepairs.push(localEndpointRepair);
|
|
transportDebugLayers.repairedSegments.push(...localEndpointRepair.added);
|
|
const localDanglingPrune = pruneDanglingTerminalSegments(minorRoads, "local", [...nationalRoads, ...externalRoads, ...railways], {
|
|
oneInvalidMax: 11,
|
|
bothInvalidMax: 18,
|
|
minKeep: 24,
|
|
});
|
|
transportDebugLayers.prunedDanglingSegments.push(localDanglingPrune);
|
|
const localParallelPruning = pruneParallelSameMode(minorRoads, "local", transportFields.localPotential, {
|
|
threshold: 0.70,
|
|
radius: 1,
|
|
minKeep: 28,
|
|
shortLength: 13,
|
|
});
|
|
transportDebugLayers.parallelPruning.push(localParallelPruning);
|
|
transportDebugLayers.localSanitizationInitial = sanitizeLocalRoads();
|
|
function sampledNetworkCells(paths, step = 2) {
|
|
const cells = [];
|
|
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
|
|
const path = paths[pathId];
|
|
for (let k = 0; k < (path?.length || 0); k += step) {
|
|
const [x, y] = path[k];
|
|
if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, pathId });
|
|
}
|
|
}
|
|
return cells;
|
|
}
|
|
const nationalForIc = sampledNetworkCells([...nationalRoads, ...externalRoads], 2).map((q) => ({ ...q, roadClass: "national" }));
|
|
const generalRoadForIc = sampledNetworkCells([...nationalRoads, ...externalRoads, ...minorRoads], 2).map((q, idx) => ({ ...q, roadClass: idx < nationalForIc.length ? "national" : "local" }));
|
|
const nationalRoadIcIndex = makeSpatialIndex(nationalForIc, 16);
|
|
const generalRoadIcIndex = makeSpatialIndex(generalRoadForIc, 16);
|
|
function nearestRoadForIc(p, maxDistance = 24.0, preferNational = true) {
|
|
let best = null;
|
|
let bestD = maxDistance + 1;
|
|
const pool = preferNational ? nationalRoadIcIndex.near(p.x, p.y, maxDistance) : generalRoadIcIndex.near(p.x, p.y, maxDistance);
|
|
for (const q of pool) {
|
|
const d2 = squaredDistance(q.x, q.y, p.x, p.y);
|
|
if (d2 <= 1.2 * 1.2 || d2 >= bestD * bestD) continue;
|
|
const d = Math.sqrt(d2);
|
|
if (d < bestD) {
|
|
bestD = d;
|
|
best = q;
|
|
}
|
|
}
|
|
if (!best && preferNational) return nearestRoadForIc(p, maxDistance + 8, false);
|
|
return best ? { ...best, d: bestD } : null;
|
|
}
|
|
|
|
function pathCumulativeLengths(path) {
|
|
const cum = [0];
|
|
for (let k = 1; k < (path?.length || 0); k++) cum.push(cum[cum.length - 1] + Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1]));
|
|
return cum;
|
|
}
|
|
|
|
function pointAtPathDistance(path, cum, dist) {
|
|
if (!path?.length) return null;
|
|
if (dist <= 0) return { x: path[0][0], y: path[0][1], s: 0 };
|
|
const total = cum[cum.length - 1] || 0;
|
|
if (dist >= total) {
|
|
const p = path[path.length - 1];
|
|
return { x: p[0], y: p[1], s: total };
|
|
}
|
|
let k = 1;
|
|
while (k < cum.length && cum[k] < dist) k++;
|
|
const a = path[k - 1];
|
|
const b = path[k];
|
|
const seg = Math.max(0.0001, cum[k] - cum[k - 1]);
|
|
const t = clamp((dist - cum[k - 1]) / seg);
|
|
return { x: Math.round(a[0] + (b[0] - a[0]) * t), y: Math.round(a[1] + (b[1] - a[1]) * t), s: dist };
|
|
}
|
|
|
|
function meanFieldAround(field, x, y, radius = 8) {
|
|
let sum = 0, n = 0;
|
|
const r = Math.ceil(radius);
|
|
for (let dy = -r; dy <= r; dy++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
if (dx * dx + dy * dy > radius * radius) continue;
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const i = indexOf(nx, ny);
|
|
if (sea[i]) continue;
|
|
const w = 1 - Math.hypot(dx, dy) / Math.max(1, radius);
|
|
sum += (field?.[i] || 0) * (0.35 + w);
|
|
n += 0.35 + w;
|
|
}
|
|
}
|
|
return n ? sum / n : 0;
|
|
}
|
|
|
|
const icDemandCache = new Map();
|
|
function icDemandAt(p) {
|
|
if (!p || !inside(p.x, p.y)) return 0;
|
|
const key = `${p.x},${p.y}`;
|
|
const cached = icDemandCache.get(key);
|
|
if (cached !== undefined) return cached;
|
|
const demand = clamp(
|
|
meanFieldAround(settlementDemand, p.x, p.y, 11) * 0.64 +
|
|
meanFieldAround(preliminaryTownInfluence, p.x, p.y, 13) * 0.42 +
|
|
meanFieldAround(preliminaryVillageInfluence, p.x, p.y, 9) * 0.18 +
|
|
meanFieldAround(urbanEdge, p.x, p.y, 10) * 0.28 +
|
|
meanFieldAround(logisticsPreSuitability, p.x, p.y, 10) * 0.24
|
|
);
|
|
icDemandCache.set(key, demand);
|
|
return demand;
|
|
}
|
|
|
|
function icSpacingForPoint(p) {
|
|
const demand = icDemandAt(p);
|
|
// Sparse rural sections: 20-25 km. Dense urban fringe: 6-12 km.
|
|
return {
|
|
demand,
|
|
minGap: clamp(8.0 - demand * 3.0, 5.0, 8.0),
|
|
idealGap: clamp(22.0 - demand * 15.0, 7.0, 22.0),
|
|
maxGap: clamp(25.0 - demand * 10.5, 12.0, 25.0),
|
|
};
|
|
}
|
|
|
|
function icCoveragePenalty(p) {
|
|
const demand = icDemandAt(p);
|
|
if (demand <= 0.001 || !interchanges.length) return 0;
|
|
let nearest = Infinity;
|
|
for (const q of interchanges) nearest = Math.min(nearest, Math.hypot(q.x - p.x, q.y - p.y));
|
|
const desired = clamp(22.0 - demand * 14.0, 7.0, 22.0);
|
|
return nearest < desired * 0.72 ? (desired * 0.72 - nearest) * 0.040 : 0;
|
|
}
|
|
|
|
function interchangeScore(p, hit) {
|
|
if (!p || !inside(p.x, p.y)) return -INF;
|
|
const i = indexOf(p.x, p.y);
|
|
if (sea[i] || denseCorePenaltyAt(i) > 0.92) return -INF;
|
|
const demand = icDemandAt(p);
|
|
return (hit ? 0.72 - hit.d * 0.018 : -0.20) +
|
|
transportFields.expresswayPotential[i] * 0.46 +
|
|
urbanEdge[i] * 0.34 +
|
|
logisticsPreSuitability[i] * 0.28 +
|
|
demand * 0.42 +
|
|
settlementDemand[i] * 0.10 -
|
|
icCoveragePenalty(p) -
|
|
slope[i] * 0.34 -
|
|
ridgeField[i] * 0.28;
|
|
}
|
|
|
|
|
|
function directLandConnector(a, b) {
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
const steps = Math.max(1, Math.ceil(d));
|
|
const path = [];
|
|
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 [];
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || highAltitudeRoadClosed(i) || transportFields.local[i] >= INF) return [];
|
|
if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function addInterchange(p, hit) {
|
|
if (!p || !inside(p.x, p.y)) return false;
|
|
if (interchanges.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 5.0)) return false;
|
|
const i = indexOf(p.x, p.y);
|
|
if (sea[i]) return false;
|
|
const roadHit = hit || nearestRoadForIc(p, 55.0, false);
|
|
if (!roadHit) return false;
|
|
const connector = routeBetweenTrafficCandidates(p, roadHit, "local", transportFields.local, null, {
|
|
curvePenalty: 0.030,
|
|
penaltyStrength: 0,
|
|
terrainFlowBias: 0.24,
|
|
surfaceGrain: 0.025,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.20,
|
|
maxPathLength: roadHit.d * 3.20 + 18,
|
|
snapRadius: 0.5,
|
|
searchPad: Math.max(10, Math.ceil(roadHit.d + 10)),
|
|
});
|
|
const finalConnector = connector.length >= 2 ? connector : directLandConnector(p, roadHit);
|
|
if (finalConnector.length < 2) return false;
|
|
interchanges.push({ x: p.x, y: p.y, kind: "Interchange", score: transportFields.expresswayPotential[i] + (roadHit ? 0.24 : 0), regionId: regionIdAt(p.x, p.y) });
|
|
icAccessRoads.push(finalConnector);
|
|
minorRoads.push(finalConnector);
|
|
return true;
|
|
}
|
|
|
|
function generateInterchangesForExpressways() {
|
|
const absoluteMinGap = 5.0;
|
|
const absoluteMaxGap = 25.0;
|
|
if (icAccessRoads.length) {
|
|
const oldAccess = new Set(icAccessRoads);
|
|
for (let k = minorRoads.length - 1; k >= 0; k--) if (oldAccess.has(minorRoads[k])) minorRoads.splice(k, 1);
|
|
}
|
|
interchanges.length = 0;
|
|
icAccessRoads.length = 0;
|
|
for (const path of expressways) {
|
|
if (!path || path.length < 3) continue;
|
|
const cum = pathCumulativeLengths(path);
|
|
const total = cum[cum.length - 1] || 0;
|
|
if (total < absoluteMinGap * 1.6) continue;
|
|
let lastS = Math.min(8, Math.max(4, total * 0.10));
|
|
while (lastS < total - absoluteMinGap) {
|
|
const current = pointAtPathDistance(path, cum, lastS) || { x: path[0][0], y: path[0][1] };
|
|
const spacing = icSpacingForPoint(current);
|
|
const minGap = spacing.minGap;
|
|
const maxGap = spacing.maxGap;
|
|
const idealGap = spacing.idealGap;
|
|
const windowStart = Math.min(total - absoluteMinGap, lastS + minGap);
|
|
const windowEnd = Math.min(total - absoluteMinGap, lastS + Math.min(absoluteMaxGap, maxGap));
|
|
if (windowEnd < windowStart) break;
|
|
let best = null;
|
|
for (let s = windowStart; s <= windowEnd; s += 1.5) {
|
|
const p = pointAtPathDistance(path, cum, s);
|
|
if (!p || !inside(p.x, p.y) || sea[indexOf(p.x, p.y)]) continue;
|
|
const hit = nearestRoadForIc(p, 30.0 + icDemandAt(p) * 14.0, true);
|
|
const localSpacing = icSpacingForPoint(p);
|
|
const score = interchangeScore(p, hit) - Math.abs((s - lastS) - localSpacing.idealGap) * 0.020;
|
|
if (!best || score > best.score) best = { ...p, hit, score };
|
|
}
|
|
if (!best || best.score < -0.26) {
|
|
best = pointAtPathDistance(path, cum, Math.min(windowEnd, lastS + idealGap));
|
|
if (best) best.hit = nearestRoadForIc(best, 36.0 + icDemandAt(best) * 12.0, true);
|
|
}
|
|
if (!best) break;
|
|
const added = addInterchange(best, best.hit);
|
|
// Keep advancing even if the candidate could not be connected; otherwise
|
|
// a no-road window can trap the loop. The next window may find a road.
|
|
lastS = best.s || Math.min(windowEnd, lastS + idealGap);
|
|
if (!added && lastS < windowEnd) lastS = windowEnd;
|
|
}
|
|
}
|
|
}
|
|
|
|
generateInterchangesForExpressways();
|
|
|
|
function connectAllRoadNetworksFinal(maxAdds = 72) {
|
|
const debug = { beforeComponents: 0, afterComponents: 0, added: 0, failed: 0 };
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
|
const roadGroups = () => [minorRoads, nationalRoads, externalRoads, expressways, externalExpressways];
|
|
function groupCostField(groupIndex) {
|
|
if (groupIndex === 3 || groupIndex === 4) return expresswayCorridorCost;
|
|
if (groupIndex === 1 || groupIndex === 2) return transportFields.national;
|
|
return transportFields.local;
|
|
}
|
|
function splitPathToValidCells(path, costField, minCells = 2) {
|
|
const chunks = [];
|
|
let cur = [];
|
|
function valid(x, y) {
|
|
if (!inside(x, y)) return false;
|
|
const i = indexOf(x, y);
|
|
return !sea[i] && !highAltitudeRoadClosed(i) && costField[i] < INF;
|
|
}
|
|
function pushPoint(x, y) {
|
|
if (!valid(x, y)) {
|
|
if (cur.length >= minCells) chunks.push(cur);
|
|
cur = [];
|
|
return;
|
|
}
|
|
if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.push([x, y]);
|
|
}
|
|
for (let k = 0; k < (path?.length || 0); k++) {
|
|
const a = path[k];
|
|
const b = path[Math.min(k + 1, path.length - 1)];
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
|
|
for (let s = 0; s <= steps; s++) {
|
|
if (k > 0 && s === 0) continue;
|
|
const t = s / steps;
|
|
pushPoint(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t));
|
|
}
|
|
}
|
|
if (cur.length >= minCells) chunks.push(cur);
|
|
return chunks;
|
|
}
|
|
function normalizeRoadGroups() {
|
|
const groups = roadGroups();
|
|
for (let gi = 0; gi < groups.length; gi++) {
|
|
const group = groups[gi];
|
|
const costField = groupCostField(gi);
|
|
const out = [];
|
|
for (const path of group || []) out.push(...splitPathToValidCells(path, costField, 2));
|
|
group.length = 0;
|
|
group.push(...out);
|
|
}
|
|
}
|
|
normalizeRoadGroups();
|
|
const keepAliveSettlements = dedupeAnchors([
|
|
...modernCities,
|
|
...markets,
|
|
...villages.filter((p) => (p.population || 0) >= 600),
|
|
...ports,
|
|
], 4);
|
|
function pathNearKeptSettlement(path, radius = 7.5) {
|
|
if (!path?.length) return false;
|
|
for (const [x, y] of path) {
|
|
for (const p of keepAliveSettlements) {
|
|
if (Math.hypot(p.x - x, p.y - y) <= radius) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function roadComponents() {
|
|
const occ = new Uint8Array(SIZE);
|
|
function markSegmented(path, fn) {
|
|
for (let k = 0; k < (path?.length || 0); k++) {
|
|
const [x0, y0] = path[k];
|
|
const [x1, y1] = path[Math.min(k + 1, path.length - 1)];
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
|
|
for (let s = 0; s <= steps; s++) {
|
|
const t = s / steps;
|
|
const x = Math.round(x0 + (x1 - x0) * t);
|
|
const y = Math.round(y0 + (y1 - y0) * t);
|
|
fn(x, y);
|
|
}
|
|
}
|
|
}
|
|
for (const group of roadGroups()) {
|
|
for (const path of group || []) {
|
|
markSegmented(path, (x, y) => {
|
|
if (inside(x, y) && !sea[indexOf(x, y)]) occ[indexOf(x, y)] = 1;
|
|
});
|
|
}
|
|
}
|
|
const seen = new Uint8Array(SIZE);
|
|
const comps = [];
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (!occ[i] || seen[i]) continue;
|
|
const queue = [i];
|
|
const cells = [];
|
|
seen[i] = 1;
|
|
let sx = 0, sy = 0;
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
cells.push(cur);
|
|
const [x, y] = xyOf(cur);
|
|
sx += x; sy += y;
|
|
for (let dy = -2; dy <= 2; dy++) {
|
|
for (let dx = -2; dx <= 2; dx++) {
|
|
if (!dx && !dy) continue;
|
|
if (dx * dx + dy * dy > 5) continue;
|
|
const nx = x + dx, ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const ni = indexOf(nx, ny);
|
|
if (!occ[ni] || seen[ni]) continue;
|
|
seen[ni] = 1;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
}
|
|
comps.push({ id: comps.length, cells, size: cells.length, cx: sx / Math.max(1, cells.length), cy: sy / Math.max(1, cells.length) });
|
|
}
|
|
comps.sort((a, b) => b.size - a.size);
|
|
return comps;
|
|
}
|
|
function sampleComponent(comp, limit = 96) {
|
|
const step = Math.max(1, Math.floor(comp.cells.length / limit));
|
|
const out = [];
|
|
for (let k = 0; k < comp.cells.length; k += step) {
|
|
const [x, y] = xyOf(comp.cells[k]);
|
|
out.push({ x, y, regionId: regionIdAt(x, y) });
|
|
if (out.length >= limit) break;
|
|
}
|
|
return out;
|
|
}
|
|
function bestAnchorPair(a, b) {
|
|
const as = sampleComponent(a, 72);
|
|
const bs = sampleComponent(b, 72);
|
|
let best = null;
|
|
for (const pa of as) for (const pb of bs) {
|
|
const d = Math.hypot(pa.x - pb.x, pa.y - pb.y);
|
|
if (d < 1.5) continue;
|
|
if (!best || d < best.d) best = { a: pa, b: pb, d };
|
|
}
|
|
return best;
|
|
}
|
|
function pathIsLand(path) {
|
|
return path?.length >= 2 && path.every(([x, y]) => inside(x, y) && !sea[indexOf(x, y)] && !highAltitudeRoadClosed(indexOf(x, y)) && transportFields.local[indexOf(x, y)] < INF);
|
|
}
|
|
let comps = roadComponents();
|
|
debug.beforeComponents = comps.length;
|
|
for (let pass = 0; pass < maxAdds && comps.length > 1; pass++) {
|
|
const main = comps[0];
|
|
let best = null;
|
|
const candidates = comps.slice(1, Math.min(comps.length, 18));
|
|
for (const comp of candidates) {
|
|
const pair = bestAnchorPair(main, comp);
|
|
if (!pair) continue;
|
|
const coastBridgeRisk = pair.d > 18 && approximateLineCost(pair.a, pair.b, transportFields.local) >= INF;
|
|
if (coastBridgeRisk) continue;
|
|
const score = pair.d / Math.sqrt(Math.max(4, comp.size));
|
|
if (!best || score < best.score) best = { comp, pair, score };
|
|
}
|
|
if (!best) break;
|
|
const { a, b, d } = best.pair;
|
|
const penalty = cachedInfluenceFromPaths(minorRoads, 3, `all-road-connect:${pass}`);
|
|
if (d > 72) {
|
|
debug.failed++;
|
|
comps.splice(comps.indexOf(best.comp), 1);
|
|
comps.push(best.comp);
|
|
continue;
|
|
}
|
|
const routed = routeBetweenTrafficCandidates(a, b, "local", transportFields.local, penalty, {
|
|
curvePenalty: 0.055,
|
|
penaltyStrength: 0.55,
|
|
terrainFlowBias: 0.34,
|
|
surfaceGrain: 0.040,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.18,
|
|
maxPathLength: d * 2.05 + 24,
|
|
snapRadius: 0.5,
|
|
heuristicWeight: 0.50,
|
|
searchPad: Math.ceil(Math.max(12, Math.min(72, d * 0.45 + 10))),
|
|
});
|
|
const rawPath = routed?.length ? routed : (d <= 12 ? directLandConnector(a, b) : []);
|
|
const connectedPath = rawPath?.length ? [[a.x, a.y], ...rawPath, [b.x, b.y]] : [];
|
|
const dedupedPath = [];
|
|
for (const pt of connectedPath) {
|
|
if (!dedupedPath.length || dedupedPath[dedupedPath.length - 1][0] !== pt[0] || dedupedPath[dedupedPath.length - 1][1] !== pt[1]) dedupedPath.push(pt);
|
|
}
|
|
const routeLen = pathLengthCells(dedupedPath);
|
|
const terrainOk = localRouteAcceptableStrict(dedupedPath, { maxLength: Math.min(76, Math.max(26, d * 2.10 + 24)) })
|
|
&& routeLen <= d * 2.15 + 24
|
|
&& maxSegmentLength(dedupedPath) <= 1.6
|
|
&& !routeTooStraightMountainOnly(dedupedPath);
|
|
if (!pathIsLand(dedupedPath) || !terrainOk) {
|
|
debug.failed++;
|
|
// Drop this candidate by marking it tiny relative to the main component for this pass.
|
|
comps.splice(comps.indexOf(best.comp), 1);
|
|
comps.push(best.comp);
|
|
continue;
|
|
}
|
|
const before = comps.length;
|
|
minorRoads.push(dedupedPath);
|
|
normalizeRoadGroups();
|
|
comps = roadComponents();
|
|
if (comps.length < before) debug.added++;
|
|
else debug.failed++;
|
|
}
|
|
for (let prunePass = 0; prunePass < 4; prunePass++) {
|
|
comps = roadComponents();
|
|
if (comps.length <= 1) break;
|
|
const mainMask = new Uint8Array(SIZE);
|
|
for (const ci of comps[0].cells) {
|
|
const [cx, cy] = xyOf(ci);
|
|
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
|
|
if (dx * dx + dy * dy > 5) continue;
|
|
const nx = cx + dx, ny = cy + dy;
|
|
if (inside(nx, ny)) mainMask[indexOf(nx, ny)] = 1;
|
|
}
|
|
}
|
|
function touchesMain(path) {
|
|
let hit = 0, n = 0;
|
|
for (let k = 0; k < (path?.length || 0); k++) {
|
|
const [x0, y0] = path[k];
|
|
const [x1, y1] = path[Math.min(k + 1, path.length - 1)];
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
|
|
for (let s = 0; s <= steps; s++) {
|
|
const t = s / steps;
|
|
const x = Math.round(x0 + (x1 - x0) * t);
|
|
const y = Math.round(y0 + (y1 - y0) * t);
|
|
if (!inside(x, y) || sea[indexOf(x, y)]) continue;
|
|
n++;
|
|
if (mainMask[indexOf(x, y)]) hit++;
|
|
}
|
|
}
|
|
return n > 0 && hit / n >= (prunePass === 0 ? 0.12 : 0.01);
|
|
}
|
|
function pruneGroup(group, key) {
|
|
const kept = [];
|
|
let pruned = 0;
|
|
for (const path of group || []) {
|
|
if (touchesMain(path) || pathNearKeptSettlement(path)) kept.push(path);
|
|
else pruned++;
|
|
}
|
|
group.length = 0;
|
|
group.push(...kept);
|
|
debug.prunedIsolated[key] = (debug.prunedIsolated[key] || 0) + pruned;
|
|
}
|
|
if (!debug.prunedIsolated) debug.prunedIsolated = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 };
|
|
pruneGroup(minorRoads, "minor");
|
|
pruneGroup(nationalRoads, "national");
|
|
pruneGroup(externalRoads, "external");
|
|
pruneGroup(expressways, "expressway");
|
|
pruneGroup(externalExpressways, "externalExpressway");
|
|
}
|
|
normalizeRoadGroups();
|
|
// Absolute invariant for the rendered modern road graph: retain the largest
|
|
// terrain-valid component. Unconnectable orphan fragments are more harmful
|
|
// than being absent, because they force implausible long repairs.
|
|
let finalComps = roadComponents();
|
|
if (finalComps.length > 1) {
|
|
const main = new Uint8Array(SIZE);
|
|
for (const ci of finalComps[0].cells) main[ci] = 1;
|
|
function pruneToMain(group, key) {
|
|
const kept = [];
|
|
let pruned = 0;
|
|
for (const path of group || []) {
|
|
let hit = 0;
|
|
let n = 0;
|
|
for (const [x, y] of path || []) {
|
|
if (!inside(x, y) || sea[indexOf(x, y)]) continue;
|
|
n++;
|
|
if (main[indexOf(x, y)]) hit++;
|
|
}
|
|
if (hit > 0 || n === 0 || pathNearKeptSettlement(path)) kept.push(path);
|
|
else pruned++;
|
|
}
|
|
group.length = 0;
|
|
group.push(...kept);
|
|
debug.prunedIsolated[key] = (debug.prunedIsolated[key] || 0) + pruned;
|
|
}
|
|
if (!debug.prunedIsolated) debug.prunedIsolated = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 };
|
|
pruneToMain(minorRoads, "minor");
|
|
pruneToMain(nationalRoads, "national");
|
|
pruneToMain(externalRoads, "external");
|
|
pruneToMain(expressways, "expressway");
|
|
pruneToMain(externalExpressways, "externalExpressway");
|
|
normalizeRoadGroups();
|
|
finalComps = roadComponents();
|
|
}
|
|
debug.afterComponents = finalComps.length;
|
|
return debug;
|
|
}
|
|
|
|
|
|
return { transportDebugLayers, corridorRoadNetworkDebug, runLocalAccessPass, stitchRasterNearContacts, stitchLongLocalBranches, sanitizeLocalRoads, downgradeShortNationalRoads, connectAllRoadNetworksFinal };
|
|
}
|