From d2e7a80e7241e9f5e77f91bc81302259780dbde5 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 28 May 2026 02:47:33 +0900 Subject: [PATCH 1/7] splittintting --- mapFeatureContext.js | 327 +++++++++++++ mapFeatureLanduse.js | 214 ++++++++ mapFeatureSettlements.js | 47 ++ mapFeatureTransportTools.js | 191 ++++++++ mapFeatures.js | 751 +++------------------------- mapPostAdminTransport.js | 111 ++++- mapPrefectureStage.js | 34 ++ mapTransport.js | 944 +++++++++++------------------------- mapTransportUtils.js | 75 +++ names.js | 8 +- renderer.js | 12 +- 11 files changed, 1353 insertions(+), 1361 deletions(-) create mode 100644 mapFeatureContext.js create mode 100644 mapFeatureLanduse.js create mode 100644 mapFeatureSettlements.js create mode 100644 mapFeatureTransportTools.js diff --git a/mapFeatureContext.js b/mapFeatureContext.js new file mode 100644 index 0000000..cbfc2d6 --- /dev/null +++ b/mapFeatureContext.js @@ -0,0 +1,327 @@ +import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, valueNoise } from "./mapUtils.js"; + +export function buildFeatureContext(seed, terrain) { + const { + elevation, + slope, + sea, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + basinField, + coastalLowland, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + portSuitability, + prefectureMask, + prefectureRegionId, + naturalBarrierScore, + } = terrain; + + const geography = terrain.geography || {}; + const geoHabitability = geography.habitability || null; + const geoAccessibility = geography.accessibility || null; + const geoNaturalCentrality = geography.naturalCentrality || geography.centrality || null; + const geoLowlandCapacity = geography.lowlandCapacity || null; + const geoValleyAccess = geography.valleyAccess || null; + const geoCoastalAccess = geography.coastalAccess || null; + const geoBarrier = geography.geographicBarrier || naturalBarrierScore || null; + const geoCorridorSuitability = geography.corridorSuitability || null; + + function fieldValue(field, i, fallback = 0) { + const v = field?.[i]; + return Number.isFinite(v) ? v : fallback; + } + + function regionIdAt(x, y) { + if (!inside(x, y)) return -1; + const i = indexOf(x, y); + if (sea[i]) return -1; + if (prefectureMask?.[i]) return 0; + if (!prefectureRegionId) return 0; + const id = prefectureRegionId?.[i]; + return id !== undefined && id >= 0 ? id : -1; + } + + function inFocusedPrefecture(p) { + return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); + } + + function localConfluenceScore(x, y) { + let arms = 0; + let strong = 0; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const rv = river[indexOf(nx, ny)]; + if (rv > 0.18) arms++; + if (rv > 0.34) strong++; + } + return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04); + } + + // --- 1. Human context: one full raster pass ----------------------------- + const developable = new Float32Array(SIZE); + const ruralSuitability = new Float32Array(SIZE); + const townSuitability = new Float32Array(SIZE); + const valleySettlement = new Float32Array(SIZE); + const coastalSettlement = new Float32Array(SIZE); + const confluenceField = new Float32Array(SIZE); + const barrierCost = new Float32Array(SIZE); + const corridorCost = new Float32Array(SIZE); + const settlementCluster = new Float32Array(SIZE); + const settlementScore = new Float32Array(SIZE); + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) { + barrierCost[i] = INF; + corridorCost[i] = INF; + continue; + } + const naturalBarrier = naturalBarrierScore?.[i] || 0; + const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90; + const highPenalty = Math.max(0, elevation[i] - 0.56); + const lowSlope = clamp(1 - slope[i] * 2.3); + const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0; + const openPlainPotential = clamp(plain[i] * 0.68 + agriculture[i] * 0.54 + basinField[i] * 0.30 + lowSlope * 0.22 - river[i] * 0.18 - valleyField[i] * 0.08 - ridgeField[i] * 0.22 - slope[i] * 0.26); + const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38; + confluenceField[i] = confluence; + + const geoH = fieldValue(geoHabitability, i, 0); + const geoLow = fieldValue(geoLowlandCapacity, i, 0); + const geoValley = fieldValue(geoValleyAccess, i, 0); + const geoCoast = fieldValue(geoCoastalAccess, i, 0); + const geoB = fieldValue(geoBarrier, i, naturalBarrier); + const localDevelopable = clamp( + plain[i] * 0.34 + + agriculture[i] * 0.24 + + basinField[i] * 0.24 + + valleyField[i] * 0.24 + + coastalLowland[i] * 0.18 + + depositional * 0.22 + + lowSlope * 0.10 - + slope[i] * 0.82 - + ridgeField[i] * 0.52 - + spine * 0.24 - + highPenalty * 1.14 - + floodplain[i] * 0.03 + ); + developable[i] = clamp(localDevelopable * 0.68 + geoH * 0.34 + geoLow * 0.16 - geoB * 0.05); + valleySettlement[i] = clamp(( + valleyField[i] * 0.52 + + river[i] * 0.08 + + confluence * 0.38 + + depositional * 0.20 + + basinField[i] * 0.16 + + plain[i] * 0.08 + + lowSlope * 0.12 - + slope[i] * 0.54 - + ridgeField[i] * 0.30 - + spine * 0.16 - + highPenalty * 0.70 - + floodplain[i] * 0.10 + ) * 0.74 + geoValley * 0.30 + geoH * 0.08 - geoB * 0.04); + coastalSettlement[i] = clamp(( + coastalLowland[i] * 0.50 + + (portSuitability?.[i] || 0) * 0.30 + + (deltaField?.[i] || 0) * 0.20 + + plain[i] * 0.10 - + slope[i] * 0.52 - + ridgeField[i] * 0.24 - + spine * 0.12 + ) * 0.76 + geoCoast * 0.32 + geoH * 0.06 - geoB * 0.04); + const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16; + settlementCluster[i] = clamp((developable[i] * 0.42 + valleySettlement[i] * 0.12 + coastalSettlement[i] * 0.22 + agriculture[i] * 0.48 + plain[i] * 0.32 + openPlainPotential * 0.46) * clusterNoise); + ruralSuitability[i] = clamp( + agriculture[i] * 0.54 + + developable[i] * 0.30 + + valleySettlement[i] * 0.18 + + coastalSettlement[i] * 0.20 + + openPlainPotential * 0.34 + + settlementCluster[i] * 0.30 - + Math.max(0, elevation[i] - 0.64) * 0.56 + ); + townSuitability[i] = clamp( + developable[i] * 0.38 + + agriculture[i] * 0.18 + + valleySettlement[i] * 0.16 + + coastalSettlement[i] * 0.30 + + confluence * 0.20 + + basinField[i] * 0.18 + + plain[i] * 0.26 + + openPlainPotential * 0.44 + + settlementCluster[i] * 0.22 - + slope[i] * 0.34 - + ridgeField[i] * 0.17 - + spine * 0.10 + ); + settlementScore[i] = clamp( + ruralSuitability[i] * 0.48 + + townSuitability[i] * 0.30 + + confluence * 0.08 + + fieldValue(geoHabitability, i, developable[i]) * 0.18 + + fieldValue(geoNaturalCentrality, i, 0) * 0.12 - + fieldValue(geoBarrier, i, 0) * 0.06 + ); + barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14; + corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05); + } + } + + // --- region statistics --------------------------------------------------- + const regionStats = new Map(); + function ensureRegion(regionId) { + let st = regionStats.get(regionId); + if (!st) { + st = { + id: regionId, + area: 0, + developableCells: 0, + developableSum: 0, + valleyCells: 0, + coastCells: 0, + townCells: 0, + plainCells: 0, + highCentralityCells: 0, + habitabilitySum: 0, + accessibilitySum: 0, + centralitySum: 0, + lowlandCapacitySum: 0, + minX: MAP_W, + minY: MAP_H, + maxX: 0, + maxY: 0, + }; + regionStats.set(regionId, st); + } + return st; + } + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const regionId = regionIdAt(x, y); + if (regionId < 0) continue; + const st = ensureRegion(regionId); + st.area++; + const gHabit = fieldValue(geoHabitability, i, developable[i]); + const gAccess = fieldValue(geoAccessibility, i, 0); + const gCentral = fieldValue(geoNaturalCentrality, i, townSuitability[i]); + const gLow = fieldValue(geoLowlandCapacity, i, plain[i]); + st.developableSum += developable[i]; + st.habitabilitySum += gHabit; + st.accessibilitySum += gAccess; + st.centralitySum += gCentral; + st.lowlandCapacitySum += gLow; + if (developable[i] > 0.16 || gHabit > 0.24) st.developableCells++; + if (valleySettlement[i] > 0.24 || fieldValue(geoValleyAccess, i, 0) > 0.25) st.valleyCells++; + if (coastalSettlement[i] > 0.25 || fieldValue(geoCoastalAccess, i, 0) > 0.24) st.coastCells++; + if (townSuitability[i] > 0.28 || gCentral > 0.31) st.townCells++; + if (plain[i] > 0.24 || gLow > 0.26) st.plainCells++; + if (gCentral > 0.36 && gHabit > 0.18) st.highCentralityCells++; + st.minX = Math.min(st.minX, x); + st.minY = Math.min(st.minY, y); + st.maxX = Math.max(st.maxX, x); + st.maxY = Math.max(st.maxY, y); + } + } + + function visibilityFactor(regionId, st) { + if (!st || st.area <= 0) return 0; + // Treat the focused prefecture and neighboring prefectures with the same + // density curve. Only genuinely clipped map-edge slivers are downscaled. + return clamp(Math.sqrt(st.area / 1900), 0.32, 1.05); + } + + function pickRegionalPoints(scoreArray, { + stride = 1, + threshold = 0.25, + minDistance = 6, + totalMax = 100, + seedOffset = 0, + quotaForRegion, + predicate = () => true, + kind = "Point", + extraScore = () => 0, + }) { + const byRegion = new Map(); + for (let y = 2; y < MAP_H - 2; y += stride) { + for (let x = 2; x < MAP_W - 2; x += stride) { + const i = indexOf(x, y); + if (sea[i] || !predicate(x, y, i)) continue; + const regionId = regionIdAt(x, y); + if (regionId < 0) continue; + const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055; + if (score < threshold) continue; + if (!byRegion.has(regionId)) byRegion.set(regionId, []); + byRegion.get(regionId).push({ x, y, score, kind, regionId }); + } + } + const out = []; + for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) { + const st = regionStats.get(regionId); + const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0; + if (quota <= 0) continue; + out.push(...pickEntities(candidates, { + max: quota, + minDistance, + threshold, + seed: seed + seedOffset + regionId * 1009, + jitter: 0.04, + })); + } + return out.sort((a, b) => b.score - a.score).slice(0, totalMax); + } + + function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) { + const candidates = []; + for (let y = 2; y < MAP_H - 2; y += stride) { + for (let x = 2; x < MAP_W - 2; x += stride) { + const i = indexOf(x, y); + if (sea[i] || !predicate(x, y, i)) continue; + const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07; + if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) }); + } + } + return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset }); + } + + + return { + geography, + geoHabitability, + geoAccessibility, + geoNaturalCentrality, + geoLowlandCapacity, + geoValleyAccess, + geoCoastalAccess, + geoBarrier, + geoCorridorSuitability, + fieldValue, + regionIdAt, + inFocusedPrefecture, + developable, + ruralSuitability, + townSuitability, + valleySettlement, + coastalSettlement, + confluenceField, + barrierCost, + corridorCost, + settlementCluster, + settlementScore, + regionStats, + visibilityFactor, + pickRegionalPoints, + pickGlobalPoints, + }; +} \ No newline at end of file diff --git a/mapFeatureLanduse.js b/mapFeatureLanduse.js new file mode 100644 index 0000000..aaccc11 --- /dev/null +++ b/mapFeatureLanduse.js @@ -0,0 +1,214 @@ +import { MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside } from "./mapUtils.js"; +import { LANDUSE } from "./landuseCodes.js"; + +export function buildFeatureLanduse(ctx) { + const { + seed, + elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, + developable, ruralSuitability, valleySettlement, coastalSettlement, + modernCities, logisticsParks, + roadLanduseInfluence, roadInfluence, roadDensityInfluence, railInfluence2, stationInfluence, stationDensityInfluence, villageInfluence, + cityInfluence, coreInfluence, oldTownInfluence, townInfluence, industrialInfluence, logisticsInfluence, + } = ctx; + const populationDensity = new Float32Array(SIZE); + + const landuse = new Uint8Array(SIZE); + + // Re-run land-use classification after landuse allocation. The loop above is + // intentionally inside a helper to keep all thresholds in one place. + function classifyLanduse() { + landuse.fill(LANDUSE.RURAL); + let maxDensity = 0; + const baseNoiseSeed = seed + 15000; + const urbanCapacity = new Float32Array(SIZE); + const ruralDensityFloor = new Float32Array(SIZE); + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); + const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85); + const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.30 + roadInfluence[i] * 0.14 + railInfluence2[i] * 0.10; + const core = coreInfluence[i]; + const oldTown = oldTownInfluence[i] * 0.70 + townInfluence[i] * 0.38; + const rural = villageInfluence[i] * 0.24 + ruralSuitability[i] * 0.30; + const riverUrban = clamp(river[i] * 0.12 + valleyField[i] * 0.10 + plain[i] * 0.08 + basinField[i] * 0.08 - floodplain[i] * 0.10); + urbanCapacity[i] = clamp( + developable[i] * 0.66 + + plain[i] * 0.16 + + basinField[i] * 0.16 + + valleyField[i] * 0.16 + + coastalLowland[i] * 0.12 + + roadInfluence[i] * 0.14 + roadDensityInfluence[i] * 0.16 + transport * 0.08 + + riverUrban * 0.14 - + slope[i] * 0.18 - + ridgeField[i] * 0.12 - + floodplain[i] * 0.08 + ); + const highPenaltyDensity = Math.max(0, elevation[i] - 0.58); + const agrarianDensity = clamp( + agriculture[i] * 0.045 + + ruralSuitability[i] * 0.035 + + developable[i] * 0.028 + + plain[i] * 0.018 + + basinField[i] * 0.014 + + valleySettlement[i] * 0.014 + + coastalSettlement[i] * 0.012 + + villageInfluence[i] * 0.040 + + townInfluence[i] * 0.022 + + roadDensityInfluence[i] * 0.038 + + stationDensityInfluence[i] * 0.020 + + railInfluence2[i] * 0.012 - + slope[i] * 0.030 - + ridgeField[i] * 0.020 - + highPenaltyDensity * 0.058 + ); + const remoteWilderness = elevation[i] > 0.60 && slope[i] > 0.34 && ridgeField[i] > 0.38 && densityTransport < 0.035 && villageInfluence[i] < 0.025 && townInfluence[i] < 0.025 && cityInfluence[i] < 0.025; + ruralDensityFloor[i] = remoteWilderness ? 0 : clamp(agrarianDensity, 0, elevation[i] > 0.62 || slope[i] > 0.42 ? 0.052 : 0.105); + populationDensity[i] = clamp( + urban * 0.66 + + core * 0.46 + + oldTown * 0.28 + + townInfluence[i] * 0.16 + + villageInfluence[i] * 0.14 + + roadDensityInfluence[i] * 0.42 + + stationDensityInfluence[i] * 0.34 + + railInfluence2[i] * 0.12 + + transport * 0.05 + + agrarianDensity * 0.34 + ); + maxDensity = Math.max(maxDensity, populationDensity[i]); + + if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) { + landuse[i] = LANDUSE.FOREST; + continue; + } + if (industrialInfluence[i] > 0.22 && urbanCapacity[i] > 0.10) { + landuse[i] = LANDUSE.INDUSTRIAL; + continue; + } + if (logisticsInfluence[i] > 0.24 && urbanCapacity[i] > 0.08 && (roadInfluence[i] > 0.08 || railInfluence2[i] > 0.06)) { + landuse[i] = LANDUSE.LOGISTICS; + continue; + } + if (core > 0.38 && urbanCapacity[i] > 0.10) { + landuse[i] = LANDUSE.CBD; + continue; + } + if (oldTown > 0.18 && urbanCapacity[i] > 0.09) { + landuse[i] = LANDUSE.OLD_URBAN; + continue; + } + + const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08; + const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadLanduseInfluence[i] * 0.10 + 0.28); + const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42); + const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12; + if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) { + landuse[i] = LANDUSE.SUBURB; + } else if (roadLanduseInfluence[i] > 0.30 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.05 || cityInfluence[i] > 0.11)) { + landuse[i] = LANDUSE.SUBURB; + } else if (agriculture[i] > 0.16 || rural > 0.18 || (developable[i] > 0.13 && plain[i] > 0.13) || (basinField[i] > 0.18 && slope[i] < 0.34) || (coastalLowland[i] > 0.16 && slope[i] < 0.32)) { + landuse[i] = LANDUSE.FARMLAND; + } else { + const usablePlain = slope[i] < 0.30 && (plain[i] > 0.18 || developable[i] > 0.20 || basinField[i] > 0.20 || coastalLowland[i] > 0.18); + landuse[i] = elevation[i] > 0.58 || slope[i] > 0.38 ? LANDUSE.FOREST : usablePlain ? LANDUSE.FARMLAND : LANDUSE.RURAL; + } + } + } + + const baseLanduse = landuse.slice(); + const isBuilt = (lu) => lu >= LANDUSE.OLD_URBAN && lu <= LANDUSE.ROADSIDE; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i] || baseLanduse[i] === LANDUSE.FOREST) continue; + const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); + const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85); + let urbanNeighbors = 0; + let cbdNeighbors = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const lu = baseLanduse[i + dy * MAP_W + dx]; + if (isBuilt(lu)) urbanNeighbors++; + if (lu === LANDUSE.CBD) cbdNeighbors++; + } + } + if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) { + landuse[i] = LANDUSE.CBD; + continue; + } + if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) { + const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08; + const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24; + if (fringeChance > 0.34 + noise) { + landuse[i] = LANDUSE.SUBURB; + } + } + if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadLanduseInfluence[i] > 0.22) && urbanNeighbors >= 3 && landuse[i] <= LANDUSE.FARMLAND && urbanCapacity[i] > 0.09) { + landuse[i] = cbdNeighbors >= 1 || coreInfluence[i] > 0.15 ? LANDUSE.OLD_URBAN : LANDUSE.SUBURB; + } + } + } + + for (const park of logisticsParks) { + const r = 3; + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = park.x + dx; + const y = park.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i] || landuse[i] === LANDUSE.CBD || landuse[i] === LANDUSE.FOREST) continue; + if (Math.hypot(dx, dy) <= r && (agriculture[i] > 0.18 || plain[i] > 0.15 || roadInfluence[i] > 0.06 || railInfluence2[i] > 0.05)) { + landuse[i] = LANDUSE.LOGISTICS; + } + } + } + } + + if (maxDensity > 0) { + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + const lu = landuse[i]; + let floor = ruralDensityFloor[i]; + if (lu === LANDUSE.FARMLAND) { + floor = Math.max(floor, clamp(0.024 + agriculture[i] * 0.044 + ruralSuitability[i] * 0.024 + roadDensityInfluence[i] * 0.030 + stationDensityInfluence[i] * 0.026 + villageInfluence[i] * 0.018, 0, 0.110)); + } else if (lu === LANDUSE.LOGISTICS) { + floor = Math.max(floor, clamp(0.018 + roadDensityInfluence[i] * 0.026 + railInfluence2[i] * 0.014 + logisticsInfluence[i] * 0.012, 0, 0.060)); + } else if (lu === LANDUSE.RURAL) { + floor = Math.max(floor, clamp(0.012 + ruralSuitability[i] * 0.020 + developable[i] * 0.014 + roadDensityInfluence[i] * 0.024 + stationDensityInfluence[i] * 0.020, 0, 0.070)); + } else if (lu === LANDUSE.FOREST) { + floor = Math.min(floor, (roadDensityInfluence[i] > 0.04 || villageInfluence[i] > 0.03) ? 0.026 : 0); + } + const normalized = populationDensity[i] / maxDensity; + populationDensity[i] = clamp(Math.max(normalized, floor)); + if (lu === LANDUSE.FOREST && floor === 0 && populationDensity[i] < 0.012) populationDensity[i] = 0; + } + } + } + classifyLanduse(); + + for (const city of modernCities) { + let urbanFootprintCells = 0; + let coreFootprintCells = 0; + const r = Math.ceil((city.urbanRadius || 8) * 1.3); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + if (Math.hypot(dx, dy) > r) continue; + if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++; + if (landuse[i] === LANDUSE.CBD) coreFootprintCells++; + } + } + city.urbanFootprintCells = urbanFootprintCells; + city.coreFootprintCells = coreFootprintCells; + } + + return { landuse, populationDensity }; +} diff --git a/mapFeatureSettlements.js b/mapFeatureSettlements.js new file mode 100644 index 0000000..2b48307 --- /dev/null +++ b/mapFeatureSettlements.js @@ -0,0 +1,47 @@ +import { MAP_H, MAP_W, SIZE, clamp, indexOf } from "./mapUtils.js"; +import { influenceFromPoints } from "./mapGeneratorHelpers.js"; + +export function buildSettlementDemandFields(ctx) { + const { + sea, agriculture, plain, basinField, coastalLowland, slope, ridgeField, + modernCities, markets, commercialPorts, villages, + } = ctx; + + const preliminaryUrbanInfluence = influenceFromPoints(modernCities, 18, (c) => clamp((c.population || 60000) / 260000, 0.55, 2.0)); + const preliminaryTownInfluence = influenceFromPoints([...markets, ...commercialPorts], 10, (p) => p.portClass === "major" ? 1.35 : clamp((p.population || 12000) / 36000, 0.42, 1.1)); + const preliminaryVillageInfluence = influenceFromPoints(villages, 7, (v) => clamp((v.population || 1800) / 5200, 0.22, 0.9)); + const settlementDemand = new Float32Array(SIZE); + const urbanEdge = new Float32Array(SIZE); + const logisticsPreSuitability = new Float32Array(SIZE); + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const density = clamp(preliminaryUrbanInfluence[i] * 0.62 + preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.16); + settlementDemand[i] = density; + urbanEdge[i] = clamp(1 - Math.abs(density - 0.46) / 0.32); + logisticsPreSuitability[i] = clamp( + agriculture[i] * 0.30 + + plain[i] * 0.24 + + basinField[i] * 0.14 + + coastalLowland[i] * 0.12 + + preliminaryTownInfluence[i] * 0.18 + + urbanEdge[i] * 0.34 - + preliminaryUrbanInfluence[i] * 0.20 - + slope[i] * 0.50 - + ridgeField[i] * 0.32 + ); + } + } + + + return { + preliminaryUrbanInfluence, + preliminaryTownInfluence, + preliminaryVillageInfluence, + settlementDemand, + urbanEdge, + logisticsPreSuitability, + }; +} \ No newline at end of file diff --git a/mapFeatureTransportTools.js b/mapFeatureTransportTools.js new file mode 100644 index 0000000..2d4cb92 --- /dev/null +++ b/mapFeatureTransportTools.js @@ -0,0 +1,191 @@ +import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside } from "./mapUtils.js"; + +export function buildFeatureTransportCostFields(ctx) { + const { + seed, + sea, elevation, slope, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, + portSuitability, passSuitability, crossingSuitability, naturalBarrierScore, + settlementDemand, urbanEdge, logisticsPreSuitability, + preliminaryTownInfluence, preliminaryVillageInfluence, + valleySettlement, coastalSettlement, developable, + } = ctx; + const expressway = new Float32Array(SIZE); + const rail = new Float32Array(SIZE); + const national = new Float32Array(SIZE); + const local = new Float32Array(SIZE); + const expresswayPotential = new Float32Array(SIZE); + const railPotential = new Float32Array(SIZE); + const nationalPotential = new Float32Array(SIZE); + const localPotential = new Float32Array(SIZE); + + function seaAdjacency(x, y, radius = 1) { + let sum = 0; + let total = 0; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (!dx && !dy) continue; + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + total++; + if (sea[indexOf(nx, ny)]) sum += 1; + } + } + return total > 0 ? sum / total : 0; + } + + function highAltitudeTransportClosed(i) { + // Above this contour the generator should treat mountains as no-road + // terrain. A strong mapped pass is the exception, so genuine saddle + // crossings can still exist without roads drilling through entire ranges. + return elevation[i] >= 0.70; + } + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i] || highAltitudeTransportClosed(i)) { + expressway[i] = rail[i] = national[i] = local[i] = INF; + expresswayPotential[i] = railPotential[i] = nationalPotential[i] = localPotential[i] = 0; + continue; + } + const density = settlementDemand[i]; + const mediumDensity = clamp(1 - Math.abs(density - 0.42) / 0.30); + const highDensity = clamp((density - 0.32) / 0.50); + const lowland = clamp(plain[i] * 0.48 + basinField[i] * 0.28 + valleyField[i] * 0.24 + coastalLowland[i] * 0.26 + agriculture[i] * 0.16); + const pass = passSuitability?.[i] || 0; + const crossing = crossingSuitability?.[i] || 0; + const waterCrossingPenalty = river[i] > 0.18 ? (1 - crossing) * (0.72 + river[i] * 1.35) : 0; + const seaNear = seaAdjacency(x, y, 1); + const seaBroad = seaAdjacency(x, y, 3); + const seaWide = seaAdjacency(x, y, 5); + // Roads should use coastal lowlands when there is a settlement/port reason, + // but should not casually trace beaches or hop over small bays. + const coastalTraversePenalty = clamp(seaBroad * 1.72 + seaWide * 0.82 - coastalLowland[i] * 0.48 - (portSuitability?.[i] || 0) * 0.30); + const highMountain = clamp((elevation[i] - 0.52) * 3.6 + slope[i] * 0.95 + ridgeField[i] * 1.05 - pass * 0.55 - valleyField[i] * 0.12); + const extremeMountain = clamp((elevation[i] - 0.64) * 4.8 + slope[i] * 1.55 + ridgeField[i] * 1.45 - pass * 0.80); + const denseCorePenalty = clamp((density - 0.66) / 0.28); + const openPlainParallelPenalty = clamp(plain[i] * 0.48 + agriculture[i] * 0.26 - density * 0.20 - valleyField[i] * 0.20 - coastalLowland[i] * 0.12); + const boundaryRidgePenalty = Math.pow(naturalBarrierScore?.[i] || 0, 2); + + if (extremeMountain > 0.92 && pass < 0.34) { + expressway[i] = rail[i] = INF; + national[i] = elevation[i] >= 0.68 ? INF : 2.9 + extremeMountain * 2.8 + waterCrossingPenalty; + local[i] = elevation[i] >= 0.69 ? INF : 2.2 + extremeMountain * 2.3 + waterCrossingPenalty * 0.55; + expresswayPotential[i] = 0; + railPotential[i] = 0; + nationalPotential[i] = clamp(lowland * 0.18 + pass * 0.24 - extremeMountain * 0.55); + localPotential[i] = clamp(valleySettlement[i] * 0.14 + pass * 0.18 - extremeMountain * 0.28); + continue; + } + + expresswayPotential[i] = clamp( + mediumDensity * 0.62 + + urbanEdge[i] * 0.38 + + logisticsPreSuitability[i] * 0.54 + + lowland * 0.36 + + agriculture[i] * 0.16 - + denseCorePenalty * 0.54 - + slope[i] * 0.82 - + highMountain * 0.92 - + coastalTraversePenalty * 0.32 - + river[i] * 0.14 + ); + railPotential[i] = clamp( + highDensity * 0.80 + + preliminaryTownInfluence[i] * 0.22 + + lowland * 0.46 + + valleyField[i] * 0.22 + + coastalLowland[i] * 0.22 - + slope[i] * 1.28 - + highMountain * 1.10 - + coastalTraversePenalty * 0.26 - + ridgeField[i] * 0.34 + ); + nationalPotential[i] = clamp( + density * 0.46 + + preliminaryTownInfluence[i] * 0.32 + + preliminaryVillageInfluence[i] * 0.20 + + agriculture[i] * 0.24 + + valleyField[i] * 0.28 + + coastalLowland[i] * 0.26 + + pass * 0.18 + + crossing * 0.18 - + slope[i] * 0.48 - + highMountain * 0.24 - + coastalTraversePenalty * 0.16 - + ridgeField[i] * 0.18 + ); + localPotential[i] = clamp( + preliminaryVillageInfluence[i] * 0.52 + + agriculture[i] * 0.38 + + coastalSettlement[i] * 0.30 + + valleySettlement[i] * 0.30 + + developable[i] * 0.18 - + slope[i] * 0.34 - + coastalTraversePenalty * 0.08 - + ridgeField[i] * 0.10 + ); + + expressway[i] = Math.max(0.18, + 1.62 - expresswayPotential[i] * 0.96 + + denseCorePenalty * 1.30 + + slope[i] * 5.4 + + highMountain * 5.8 + + extremeMountain * 4.2 + + boundaryRidgePenalty * 4.2 + + waterCrossingPenalty * 2.1 + + coastalTraversePenalty * 2.65 + + seaNear * 2.35 + + seaWide * 1.10 + + openPlainParallelPenalty * 0.12 + + hash2(x, y, seed + 13301) * 0.04 + ); + rail[i] = Math.max(0.16, + 1.48 - railPotential[i] * 1.02 + + slope[i] * 7.2 + + highMountain * 7.0 + + extremeMountain * 4.8 + + boundaryRidgePenalty * 2.4 + + waterCrossingPenalty * 1.7 + + coastalTraversePenalty * 1.75 + + seaNear * 1.50 + + seaWide * 0.70 + + hash2(x, y, seed + 13302) * 0.03 + ); + national[i] = Math.max(0.16, + 1.28 - nationalPotential[i] * 0.84 + + slope[i] * 2.8 + + ridgeField[i] * 1.18 + + Math.max(0, elevation[i] - 0.62) * 3.0 + + highMountain * 2.9 + + boundaryRidgePenalty * 1.8 + + waterCrossingPenalty * 1.25 - + valleyField[i] * 0.18 - + coastalLowland[i] * 0.08 + + coastalTraversePenalty * 1.55 + + seaNear * 0.84 + + seaWide * 0.48 - + pass * 0.42 + + hash2(x, y, seed + 13303) * 0.05 + ); + local[i] = Math.max(0.14, + 1.12 - localPotential[i] * 0.86 + + slope[i] * 1.72 + + ridgeField[i] * 0.82 + + Math.max(0, elevation[i] - 0.68) * 1.9 + + highMountain * 1.24 + + boundaryRidgePenalty * 0.72 + + waterCrossingPenalty * 0.65 - + valleyField[i] * 0.22 - + coastalLowland[i] * 0.10 + + coastalTraversePenalty * 0.82 + + seaNear * 0.48 + + seaWide * 0.26 + + hash2(x, y, seed + 13304) * 0.07 + ); + } + } + return { expressway, rail, national, local, expresswayPotential, railPotential, nationalPotential, localPotential }; + } + diff --git a/mapFeatures.js b/mapFeatures.js index 254c77d..c5e4d86 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -1,8 +1,11 @@ -import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js"; +import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js"; import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js"; -import { LANDUSE } from "./landuseCodes.js"; import { buildDensityFlowRoadTransportSystem, createPathInfluenceCache, packDebugField, pathAverageField, pathLengthCells, routeQualityAcceptable } from "./mapTransport.js"; import { buildUnifiedRailODNetwork } from "./mapTransportOD.js"; +import { buildFeatureContext } from "./mapFeatureContext.js"; +import { buildFeatureLanduse } from "./mapFeatureLanduse.js"; +import { buildSettlementDemandFields } from "./mapFeatureSettlements.js"; +import { buildFeatureTransportCostFields } from "./mapFeatureTransportTools.js"; // Lightweight Human Geography V2 // -------------------------------- @@ -16,7 +19,6 @@ import { buildUnifiedRailODNetwork } from "./mapTransportOD.js"; export function generateMapFeatures(seed, terrain) { const { elevation, - moisture, slope, sea, river, @@ -28,290 +30,41 @@ export function generateMapFeatures(seed, terrain) { basinField, coastalLowland, flowAccum, - arcSpineField, - branchRidgeField, depositionalLowland, - alluvialFanField, deltaField, portSuitability, crossingSuitability, passSuitability, - prefectureMask, - prefectureRegionId, naturalBarrierScore, } = terrain; - const geography = terrain.geography || {}; - const geoHabitability = geography.habitability || null; - const geoAccessibility = geography.accessibility || null; - const geoNaturalCentrality = geography.naturalCentrality || geography.centrality || null; - const geoLowlandCapacity = geography.lowlandCapacity || null; - const geoValleyAccess = geography.valleyAccess || null; - const geoCoastalAccess = geography.coastalAccess || null; - const geoBarrier = geography.geographicBarrier || naturalBarrierScore || null; - const geoCorridorSuitability = geography.corridorSuitability || null; - - function fieldValue(field, i, fallback = 0) { - const v = field?.[i]; - return Number.isFinite(v) ? v : fallback; - } - - function regionIdAt(x, y) { - if (!inside(x, y)) return -1; - const i = indexOf(x, y); - if (sea[i]) return -1; - if (prefectureMask?.[i]) return 0; - if (!prefectureRegionId) return 0; - const id = prefectureRegionId?.[i]; - return id !== undefined && id >= 0 ? id : -1; - } - - function inFocusedPrefecture(p) { - return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); - } - - function localConfluenceScore(x, y) { - let arms = 0; - let strong = 0; - for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { - const nx = x + dx; - const ny = y + dy; - if (!inside(nx, ny)) continue; - const rv = river[indexOf(nx, ny)]; - if (rv > 0.18) arms++; - if (rv > 0.34) strong++; - } - return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04); - } - - // --- 1. Human context: one full raster pass ----------------------------- - const developable = new Float32Array(SIZE); - const ruralSuitability = new Float32Array(SIZE); - const townSuitability = new Float32Array(SIZE); - const valleySettlement = new Float32Array(SIZE); - const coastalSettlement = new Float32Array(SIZE); - const confluenceField = new Float32Array(SIZE); - const barrierCost = new Float32Array(SIZE); - const corridorCost = new Float32Array(SIZE); - const settlementCluster = new Float32Array(SIZE); - const settlementScore = new Float32Array(SIZE); - - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (sea[i]) { - barrierCost[i] = INF; - corridorCost[i] = INF; - continue; - } - const naturalBarrier = naturalBarrierScore?.[i] || 0; - const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90; - const highPenalty = Math.max(0, elevation[i] - 0.56); - const lowSlope = clamp(1 - slope[i] * 2.3); - const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0; - const openPlainPotential = clamp(plain[i] * 0.68 + agriculture[i] * 0.54 + basinField[i] * 0.30 + lowSlope * 0.22 - river[i] * 0.18 - valleyField[i] * 0.08 - ridgeField[i] * 0.22 - slope[i] * 0.26); - const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38; - confluenceField[i] = confluence; - - const geoH = fieldValue(geoHabitability, i, 0); - const geoLow = fieldValue(geoLowlandCapacity, i, 0); - const geoValley = fieldValue(geoValleyAccess, i, 0); - const geoCoast = fieldValue(geoCoastalAccess, i, 0); - const geoB = fieldValue(geoBarrier, i, naturalBarrier); - const localDevelopable = clamp( - plain[i] * 0.34 + - agriculture[i] * 0.24 + - basinField[i] * 0.24 + - valleyField[i] * 0.24 + - coastalLowland[i] * 0.18 + - depositional * 0.22 + - lowSlope * 0.10 - - slope[i] * 0.82 - - ridgeField[i] * 0.52 - - spine * 0.24 - - highPenalty * 1.14 - - floodplain[i] * 0.03 - ); - developable[i] = clamp(localDevelopable * 0.68 + geoH * 0.34 + geoLow * 0.16 - geoB * 0.05); - valleySettlement[i] = clamp(( - valleyField[i] * 0.52 + - river[i] * 0.08 + - confluence * 0.38 + - depositional * 0.20 + - basinField[i] * 0.16 + - plain[i] * 0.08 + - lowSlope * 0.12 - - slope[i] * 0.54 - - ridgeField[i] * 0.30 - - spine * 0.16 - - highPenalty * 0.70 - - floodplain[i] * 0.10 - ) * 0.74 + geoValley * 0.30 + geoH * 0.08 - geoB * 0.04); - coastalSettlement[i] = clamp(( - coastalLowland[i] * 0.50 + - (portSuitability?.[i] || 0) * 0.30 + - (deltaField?.[i] || 0) * 0.20 + - plain[i] * 0.10 - - slope[i] * 0.52 - - ridgeField[i] * 0.24 - - spine * 0.12 - ) * 0.76 + geoCoast * 0.32 + geoH * 0.06 - geoB * 0.04); - const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16; - settlementCluster[i] = clamp((developable[i] * 0.42 + valleySettlement[i] * 0.12 + coastalSettlement[i] * 0.22 + agriculture[i] * 0.48 + plain[i] * 0.32 + openPlainPotential * 0.46) * clusterNoise); - ruralSuitability[i] = clamp( - agriculture[i] * 0.54 + - developable[i] * 0.30 + - valleySettlement[i] * 0.18 + - coastalSettlement[i] * 0.20 + - openPlainPotential * 0.34 + - settlementCluster[i] * 0.30 - - Math.max(0, elevation[i] - 0.64) * 0.56 - ); - townSuitability[i] = clamp( - developable[i] * 0.38 + - agriculture[i] * 0.18 + - valleySettlement[i] * 0.16 + - coastalSettlement[i] * 0.30 + - confluence * 0.20 + - basinField[i] * 0.18 + - plain[i] * 0.26 + - openPlainPotential * 0.44 + - settlementCluster[i] * 0.22 - - slope[i] * 0.34 - - ridgeField[i] * 0.17 - - spine * 0.10 - ); - settlementScore[i] = clamp( - ruralSuitability[i] * 0.48 + - townSuitability[i] * 0.30 + - confluence * 0.08 + - fieldValue(geoHabitability, i, developable[i]) * 0.18 + - fieldValue(geoNaturalCentrality, i, 0) * 0.12 - - fieldValue(geoBarrier, i, 0) * 0.06 - ); - barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14; - corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05); - } - } - - // --- region statistics --------------------------------------------------- - const regionStats = new Map(); - function ensureRegion(regionId) { - let st = regionStats.get(regionId); - if (!st) { - st = { - id: regionId, - area: 0, - developableCells: 0, - developableSum: 0, - valleyCells: 0, - coastCells: 0, - townCells: 0, - plainCells: 0, - highCentralityCells: 0, - habitabilitySum: 0, - accessibilitySum: 0, - centralitySum: 0, - lowlandCapacitySum: 0, - minX: MAP_W, - minY: MAP_H, - maxX: 0, - maxY: 0, - }; - regionStats.set(regionId, st); - } - return st; - } - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const regionId = regionIdAt(x, y); - if (regionId < 0) continue; - const st = ensureRegion(regionId); - st.area++; - const gHabit = fieldValue(geoHabitability, i, developable[i]); - const gAccess = fieldValue(geoAccessibility, i, 0); - const gCentral = fieldValue(geoNaturalCentrality, i, townSuitability[i]); - const gLow = fieldValue(geoLowlandCapacity, i, plain[i]); - st.developableSum += developable[i]; - st.habitabilitySum += gHabit; - st.accessibilitySum += gAccess; - st.centralitySum += gCentral; - st.lowlandCapacitySum += gLow; - if (developable[i] > 0.16 || gHabit > 0.24) st.developableCells++; - if (valleySettlement[i] > 0.24 || fieldValue(geoValleyAccess, i, 0) > 0.25) st.valleyCells++; - if (coastalSettlement[i] > 0.25 || fieldValue(geoCoastalAccess, i, 0) > 0.24) st.coastCells++; - if (townSuitability[i] > 0.28 || gCentral > 0.31) st.townCells++; - if (plain[i] > 0.24 || gLow > 0.26) st.plainCells++; - if (gCentral > 0.36 && gHabit > 0.18) st.highCentralityCells++; - st.minX = Math.min(st.minX, x); - st.minY = Math.min(st.minY, y); - st.maxX = Math.max(st.maxX, x); - st.maxY = Math.max(st.maxY, y); - } - } - - function visibilityFactor(regionId, st) { - if (!st || st.area <= 0) return 0; - // Treat the focused prefecture and neighboring prefectures with the same - // density curve. Only genuinely clipped map-edge slivers are downscaled. - return clamp(Math.sqrt(st.area / 1900), 0.32, 1.05); - } - - function pickRegionalPoints(scoreArray, { - stride = 1, - threshold = 0.25, - minDistance = 6, - totalMax = 100, - seedOffset = 0, - quotaForRegion, - predicate = () => true, - kind = "Point", - extraScore = () => 0, - }) { - const byRegion = new Map(); - for (let y = 2; y < MAP_H - 2; y += stride) { - for (let x = 2; x < MAP_W - 2; x += stride) { - const i = indexOf(x, y); - if (sea[i] || !predicate(x, y, i)) continue; - const regionId = regionIdAt(x, y); - if (regionId < 0) continue; - const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055; - if (score < threshold) continue; - if (!byRegion.has(regionId)) byRegion.set(regionId, []); - byRegion.get(regionId).push({ x, y, score, kind, regionId }); - } - } - const out = []; - for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) { - const st = regionStats.get(regionId); - const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0; - if (quota <= 0) continue; - out.push(...pickEntities(candidates, { - max: quota, - minDistance, - threshold, - seed: seed + seedOffset + regionId * 1009, - jitter: 0.04, - })); - } - return out.sort((a, b) => b.score - a.score).slice(0, totalMax); - } - - function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) { - const candidates = []; - for (let y = 2; y < MAP_H - 2; y += stride) { - for (let x = 2; x < MAP_W - 2; x += stride) { - const i = indexOf(x, y); - if (sea[i] || !predicate(x, y, i)) continue; - const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07; - if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) }); - } - } - return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset }); - } - + const featureContext = buildFeatureContext(seed, terrain); + const { + geoHabitability, + geoAccessibility, + geoNaturalCentrality, + geoLowlandCapacity, + geoValleyAccess, + geoCoastalAccess, + geoBarrier, + fieldValue, + regionIdAt, + inFocusedPrefecture, + developable, + ruralSuitability, + townSuitability, + valleySettlement, + coastalSettlement, + confluenceField, + barrierCost, + corridorCost, + settlementCluster, + settlementScore, + regionStats, + visibilityFactor, + pickRegionalPoints, + pickGlobalPoints, + } = featureContext; // --- 2. Sparse points ---------------------------------------------------- let ports = pickGlobalPoints(portSuitability || coastalSettlement, { threshold: 0.30 + rand(seed, 1001) * 0.08, @@ -797,216 +550,25 @@ export function generateMapFeatures(seed, terrain) { } // --- 4. Field-derived transport corridors ------------------------------- - const preliminaryUrbanInfluence = influenceFromPoints(modernCities, 18, (c) => clamp((c.population || 60000) / 260000, 0.55, 2.0)); - const preliminaryTownInfluence = influenceFromPoints([...markets, ...commercialPorts], 10, (p) => p.portClass === "major" ? 1.35 : clamp((p.population || 12000) / 36000, 0.42, 1.1)); - const preliminaryVillageInfluence = influenceFromPoints(villages, 7, (v) => clamp((v.population || 1800) / 5200, 0.22, 0.9)); - const settlementDemand = new Float32Array(SIZE); - const urbanEdge = new Float32Array(SIZE); - const logisticsPreSuitability = new Float32Array(SIZE); - - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const density = clamp(preliminaryUrbanInfluence[i] * 0.62 + preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.16); - settlementDemand[i] = density; - urbanEdge[i] = clamp(1 - Math.abs(density - 0.46) / 0.32); - logisticsPreSuitability[i] = clamp( - agriculture[i] * 0.30 + - plain[i] * 0.24 + - basinField[i] * 0.14 + - coastalLowland[i] * 0.12 + - preliminaryTownInfluence[i] * 0.18 + - urbanEdge[i] * 0.34 - - preliminaryUrbanInfluence[i] * 0.20 - - slope[i] * 0.50 - - ridgeField[i] * 0.32 - ); - } - } - - function buildTransportCostFields() { - const expressway = new Float32Array(SIZE); - const rail = new Float32Array(SIZE); - const national = new Float32Array(SIZE); - const local = new Float32Array(SIZE); - const expresswayPotential = new Float32Array(SIZE); - const railPotential = new Float32Array(SIZE); - const nationalPotential = new Float32Array(SIZE); - const localPotential = new Float32Array(SIZE); - - function seaAdjacency(x, y, radius = 1) { - let sum = 0; - let total = 0; - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - if (!dx && !dy) continue; - const nx = x + dx; - const ny = y + dy; - if (!inside(nx, ny)) continue; - total++; - if (sea[indexOf(nx, ny)]) sum += 1; - } - } - return total > 0 ? sum / total : 0; - } - - function highAltitudeTransportClosed(i) { - // Above this contour the generator should treat mountains as no-road - // terrain. A strong mapped pass is the exception, so genuine saddle - // crossings can still exist without roads drilling through entire ranges. - return elevation[i] >= 0.70; - } - - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (sea[i] || highAltitudeTransportClosed(i)) { - expressway[i] = rail[i] = national[i] = local[i] = INF; - expresswayPotential[i] = railPotential[i] = nationalPotential[i] = localPotential[i] = 0; - continue; - } - const density = settlementDemand[i]; - const mediumDensity = clamp(1 - Math.abs(density - 0.42) / 0.30); - const highDensity = clamp((density - 0.32) / 0.50); - const lowland = clamp(plain[i] * 0.48 + basinField[i] * 0.28 + valleyField[i] * 0.24 + coastalLowland[i] * 0.26 + agriculture[i] * 0.16); - const pass = passSuitability?.[i] || 0; - const crossing = crossingSuitability?.[i] || 0; - const waterCrossingPenalty = river[i] > 0.18 ? (1 - crossing) * (0.72 + river[i] * 1.35) : 0; - const seaNear = seaAdjacency(x, y, 1); - const seaBroad = seaAdjacency(x, y, 3); - const seaWide = seaAdjacency(x, y, 5); - // Roads should use coastal lowlands when there is a settlement/port reason, - // but should not casually trace beaches or hop over small bays. - const coastalTraversePenalty = clamp(seaBroad * 1.72 + seaWide * 0.82 - coastalLowland[i] * 0.48 - (portSuitability?.[i] || 0) * 0.30); - const highMountain = clamp((elevation[i] - 0.52) * 3.6 + slope[i] * 0.95 + ridgeField[i] * 1.05 - pass * 0.55 - valleyField[i] * 0.12); - const extremeMountain = clamp((elevation[i] - 0.64) * 4.8 + slope[i] * 1.55 + ridgeField[i] * 1.45 - pass * 0.80); - const denseCorePenalty = clamp((density - 0.66) / 0.28); - const openPlainParallelPenalty = clamp(plain[i] * 0.48 + agriculture[i] * 0.26 - density * 0.20 - valleyField[i] * 0.20 - coastalLowland[i] * 0.12); - const boundaryRidgePenalty = Math.pow(naturalBarrierScore?.[i] || 0, 2); - - if (extremeMountain > 0.92 && pass < 0.34) { - expressway[i] = rail[i] = INF; - national[i] = elevation[i] >= 0.68 ? INF : 2.9 + extremeMountain * 2.8 + waterCrossingPenalty; - local[i] = elevation[i] >= 0.69 ? INF : 2.2 + extremeMountain * 2.3 + waterCrossingPenalty * 0.55; - expresswayPotential[i] = 0; - railPotential[i] = 0; - nationalPotential[i] = clamp(lowland * 0.18 + pass * 0.24 - extremeMountain * 0.55); - localPotential[i] = clamp(valleySettlement[i] * 0.14 + pass * 0.18 - extremeMountain * 0.28); - continue; - } - - expresswayPotential[i] = clamp( - mediumDensity * 0.62 + - urbanEdge[i] * 0.38 + - logisticsPreSuitability[i] * 0.54 + - lowland * 0.36 + - agriculture[i] * 0.16 - - denseCorePenalty * 0.54 - - slope[i] * 0.82 - - highMountain * 0.92 - - coastalTraversePenalty * 0.32 - - river[i] * 0.14 - ); - railPotential[i] = clamp( - highDensity * 0.80 + - preliminaryTownInfluence[i] * 0.22 + - lowland * 0.46 + - valleyField[i] * 0.22 + - coastalLowland[i] * 0.22 - - slope[i] * 1.28 - - highMountain * 1.10 - - coastalTraversePenalty * 0.26 - - ridgeField[i] * 0.34 - ); - nationalPotential[i] = clamp( - density * 0.46 + - preliminaryTownInfluence[i] * 0.32 + - preliminaryVillageInfluence[i] * 0.20 + - agriculture[i] * 0.24 + - valleyField[i] * 0.28 + - coastalLowland[i] * 0.26 + - pass * 0.18 + - crossing * 0.18 - - slope[i] * 0.48 - - highMountain * 0.24 - - coastalTraversePenalty * 0.16 - - ridgeField[i] * 0.18 - ); - localPotential[i] = clamp( - preliminaryVillageInfluence[i] * 0.52 + - agriculture[i] * 0.38 + - coastalSettlement[i] * 0.30 + - valleySettlement[i] * 0.30 + - developable[i] * 0.18 - - slope[i] * 0.34 - - coastalTraversePenalty * 0.08 - - ridgeField[i] * 0.10 - ); - - expressway[i] = Math.max(0.18, - 1.62 - expresswayPotential[i] * 0.96 + - denseCorePenalty * 1.30 + - slope[i] * 5.4 + - highMountain * 5.8 + - extremeMountain * 4.2 + - boundaryRidgePenalty * 4.2 + - waterCrossingPenalty * 2.1 + - coastalTraversePenalty * 2.65 + - seaNear * 2.35 + - seaWide * 1.10 + - openPlainParallelPenalty * 0.12 + - hash2(x, y, seed + 13301) * 0.04 - ); - rail[i] = Math.max(0.16, - 1.48 - railPotential[i] * 1.02 + - slope[i] * 7.2 + - highMountain * 7.0 + - extremeMountain * 4.8 + - boundaryRidgePenalty * 2.4 + - waterCrossingPenalty * 1.7 + - coastalTraversePenalty * 1.75 + - seaNear * 1.50 + - seaWide * 0.70 + - hash2(x, y, seed + 13302) * 0.03 - ); - national[i] = Math.max(0.16, - 1.28 - nationalPotential[i] * 0.84 + - slope[i] * 2.8 + - ridgeField[i] * 1.18 + - Math.max(0, elevation[i] - 0.62) * 3.0 + - highMountain * 2.9 + - boundaryRidgePenalty * 1.8 + - waterCrossingPenalty * 1.25 - - valleyField[i] * 0.18 - - coastalLowland[i] * 0.08 + - coastalTraversePenalty * 1.55 + - seaNear * 0.84 + - seaWide * 0.48 - - pass * 0.42 + - hash2(x, y, seed + 13303) * 0.05 - ); - local[i] = Math.max(0.14, - 1.12 - localPotential[i] * 0.86 + - slope[i] * 1.72 + - ridgeField[i] * 0.82 + - Math.max(0, elevation[i] - 0.68) * 1.9 + - highMountain * 1.24 + - boundaryRidgePenalty * 0.72 + - waterCrossingPenalty * 0.65 - - valleyField[i] * 0.22 - - coastalLowland[i] * 0.10 + - coastalTraversePenalty * 0.82 + - seaNear * 0.48 + - seaWide * 0.26 + - hash2(x, y, seed + 13304) * 0.07 - ); - } - } - return { expressway, rail, national, local, expresswayPotential, railPotential, nationalPotential, localPotential }; - } - - const transportFields = buildTransportCostFields(); + const { + preliminaryUrbanInfluence, + preliminaryTownInfluence, + preliminaryVillageInfluence, + settlementDemand, + urbanEdge, + logisticsPreSuitability, + } = buildSettlementDemandFields({ + sea, agriculture, plain, basinField, coastalLowland, slope, ridgeField, + modernCities, markets, commercialPorts, villages, + }); + const transportFields = buildFeatureTransportCostFields({ + seed, + sea, elevation, slope, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, + portSuitability, passSuitability, crossingSuitability, naturalBarrierScore, + settlementDemand, urbanEdge, logisticsPreSuitability, + preliminaryTownInfluence, preliminaryVillageInfluence, + valleySettlement, coastalSettlement, developable, + }); const cachedInfluenceFromPaths = createPathInfluenceCache(influenceFromPaths); const componentCityInfluence = influenceFromPoints([...modernCities, ...markets], 11, (p) => clamp((p.population || 8000) / 50000, 0.18, 8.0)); @@ -1333,11 +895,11 @@ export function generateMapFeatures(seed, terrain) { const water = pathWaterCrossingStats(path); const tunnel = pathTunnelStats(path); const bridgeLimit = overrides.bridgeLimit ?? (mode === "expressway" ? 20 : 10); - const tunnelLimit = overrides.tunnelLimit ?? (mode === "expressway" ? 10 : 0); + const tunnelLimit = overrides.tunnelLimit ?? (mode === "expressway" ? 10 : mode === "national" ? 10 : 0); const maxSeaRun = overrides.maxSeaRun ?? bridgeLimit; const maxTunnelRun = overrides.maxTunnelRun ?? tunnelLimit; const maxSeaShare = overrides.maxSeaShare ?? (mode === "expressway" ? 0.22 : mode === "rail" ? 0.030 : mode === "national" ? 0.10 : 0.05); - const maxTunnelShare = overrides.maxTunnelShare ?? (mode === "expressway" ? 0.12 : 0); + const maxTunnelShare = overrides.maxTunnelShare ?? (mode === "expressway" ? 0.12 : mode === "national" ? 0.12 : 0); if (water.maxSeaRun > maxSeaRun || water.seaShare > maxSeaShare) return false; if (tunnel.maxTunnelRun > maxTunnelRun || tunnel.tunnelShare > maxTunnelShare) return false; if (water.seaCells > 0) { @@ -2265,7 +1827,6 @@ const premodernRoads = []; const cityInfluence = new Float32Array(SIZE); const coreInfluence = new Float32Array(SIZE); const oldTownInfluence = influenceFromPoints([...markets, ...castleTowns, ...ports], 7, (p) => p.kind === "Major Port" ? 1.2 : 0.9); - const populationDensity = new Float32Array(SIZE); function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true, combine = "max") { const r = Math.ceil(radius); @@ -2730,204 +2291,14 @@ const premodernRoads = []; transportDebugLayers.postConnectivityMajorCityExpresswayGuarantee = ensureMajorCityExpresswayConnections(100000); transportDebugLayers.postConnectivityExpresswayDedup = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 }); transportDebugLayers.postConnectivityExpresswayEndpointICs = ensureExpresswayEndpointsHaveICs(); - var landuse = new Uint8Array(SIZE); - - // Re-run land-use classification after landuse allocation. The loop above is - // intentionally inside a helper to keep all thresholds in one place. - function classifyLanduse() { - landuse.fill(LANDUSE.RURAL); - let maxDensity = 0; - const baseNoiseSeed = seed + 15000; - const urbanCapacity = new Float32Array(SIZE); - const ruralDensityFloor = new Float32Array(SIZE); - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); - const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85); - const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.30 + roadInfluence[i] * 0.14 + railInfluence2[i] * 0.10; - const core = coreInfluence[i]; - const oldTown = oldTownInfluence[i] * 0.70 + townInfluence[i] * 0.38; - const rural = villageInfluence[i] * 0.24 + ruralSuitability[i] * 0.30; - const riverUrban = clamp(river[i] * 0.12 + valleyField[i] * 0.10 + plain[i] * 0.08 + basinField[i] * 0.08 - floodplain[i] * 0.10); - urbanCapacity[i] = clamp( - developable[i] * 0.66 + - plain[i] * 0.16 + - basinField[i] * 0.16 + - valleyField[i] * 0.16 + - coastalLowland[i] * 0.12 + - roadInfluence[i] * 0.14 + roadDensityInfluence[i] * 0.16 + transport * 0.08 + - riverUrban * 0.14 - - slope[i] * 0.18 - - ridgeField[i] * 0.12 - - floodplain[i] * 0.08 - ); - const highPenaltyDensity = Math.max(0, elevation[i] - 0.58); - const agrarianDensity = clamp( - agriculture[i] * 0.045 + - ruralSuitability[i] * 0.035 + - developable[i] * 0.028 + - plain[i] * 0.018 + - basinField[i] * 0.014 + - valleySettlement[i] * 0.014 + - coastalSettlement[i] * 0.012 + - villageInfluence[i] * 0.040 + - townInfluence[i] * 0.022 + - roadDensityInfluence[i] * 0.038 + - stationDensityInfluence[i] * 0.020 + - railInfluence2[i] * 0.012 - - slope[i] * 0.030 - - ridgeField[i] * 0.020 - - highPenaltyDensity * 0.058 - ); - const remoteWilderness = elevation[i] > 0.60 && slope[i] > 0.34 && ridgeField[i] > 0.38 && densityTransport < 0.035 && villageInfluence[i] < 0.025 && townInfluence[i] < 0.025 && cityInfluence[i] < 0.025; - ruralDensityFloor[i] = remoteWilderness ? 0 : clamp(agrarianDensity, 0, elevation[i] > 0.62 || slope[i] > 0.42 ? 0.052 : 0.105); - populationDensity[i] = clamp( - urban * 0.66 + - core * 0.46 + - oldTown * 0.28 + - townInfluence[i] * 0.16 + - villageInfluence[i] * 0.14 + - roadDensityInfluence[i] * 0.42 + - stationDensityInfluence[i] * 0.34 + - railInfluence2[i] * 0.12 + - transport * 0.05 + - agrarianDensity * 0.34 - ); - maxDensity = Math.max(maxDensity[i]); - - if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) { - landuse[i] = LANDUSE.FOREST; - continue; - } - if (industrialInfluence[i] > 0.22 && urbanCapacity[i] > 0.10) { - landuse[i] = LANDUSE.INDUSTRIAL; - continue; - } - if (logisticsInfluence[i] > 0.24 && urbanCapacity[i] > 0.08 && (roadInfluence[i] > 0.08 || railInfluence2[i] > 0.06)) { - landuse[i] = LANDUSE.LOGISTICS; - continue; - } - if (core > 0.38 && urbanCapacity[i] > 0.10) { - landuse[i] = LANDUSE.CBD; - continue; - } - if (oldTown > 0.18 && urbanCapacity[i] > 0.09) { - landuse[i] = LANDUSE.OLD_URBAN; - continue; - } - - const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08; - const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadLanduseInfluence[i] * 0.10 + 0.28); - const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42); - const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12; - if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) { - landuse[i] = LANDUSE.SUBURB; - } else if (roadLanduseInfluence[i] > 0.30 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.05 || cityInfluence[i] > 0.11)) { - landuse[i] = LANDUSE.SUBURB; - } else if (agriculture[i] > 0.16 || rural > 0.18 || (developable[i] > 0.13 && plain[i] > 0.13) || (basinField[i] > 0.18 && slope[i] < 0.34) || (coastalLowland[i] > 0.16 && slope[i] < 0.32)) { - landuse[i] = LANDUSE.FARMLAND; - } else { - const usablePlain = slope[i] < 0.30 && (plain[i] > 0.18 || developable[i] > 0.20 || basinField[i] > 0.20 || coastalLowland[i] > 0.18); - landuse[i] = elevation[i] > 0.58 || slope[i] > 0.38 ? LANDUSE.FOREST : usablePlain ? LANDUSE.FARMLAND : LANDUSE.RURAL; - } - } - } - - const baseLanduse = landuse.slice(); - const isBuilt = (lu) => lu >= LANDUSE.OLD_URBAN && lu <= LANDUSE.ROADSIDE; - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i] || baseLanduse[i] === LANDUSE.FOREST) continue; - const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); - const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85); - let urbanNeighbors = 0; - let cbdNeighbors = 0; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - if (!dx && !dy) continue; - const lu = baseLanduse[indexOf(x + dx, y + dy)]; - if (isBuilt(lu)) urbanNeighbors++; - if (lu === LANDUSE.CBD) cbdNeighbors++; - } - } - if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) { - landuse[i] = LANDUSE.CBD; - continue; - } - if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) { - const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08; - const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24; - if (fringeChance > 0.34 + noise) { - landuse[i] = LANDUSE.SUBURB; - } - } - if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadLanduseInfluence[i] > 0.22) && urbanNeighbors >= 3 && landuse[i] <= LANDUSE.FARMLAND && urbanCapacity[i] > 0.09) { - landuse[i] = cbdNeighbors >= 1 || coreInfluence[i] > 0.15 ? LANDUSE.OLD_URBAN : LANDUSE.SUBURB; - } - } - } - - for (const park of logisticsParks) { - const r = 3; - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - const x = park.x + dx; - const y = park.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (sea[i] || landuse[i] === LANDUSE.CBD || landuse[i] === LANDUSE.FOREST) continue; - if (Math.hypot(dx, dy) <= r && (agriculture[i] > 0.18 || plain[i] > 0.15 || roadInfluence[i] > 0.06 || railInfluence2[i] > 0.05)) { - landuse[i] = LANDUSE.LOGISTICS; - } - } - } - } - - if (maxDensity > 0) { - for (let i = 0; i < SIZE; i++) { - if (sea[i]) continue; - const lu = landuse[i]; - let floor = ruralDensityFloor[i]; - if (lu === LANDUSE.FARMLAND) { - floor = Math.max(floor, clamp(0.024 + agriculture[i] * 0.044 + ruralSuitability[i] * 0.024 + roadDensityInfluence[i] * 0.030 + stationDensityInfluence[i] * 0.026 + villageInfluence[i] * 0.018, 0, 0.110)); - } else if (lu === LANDUSE.LOGISTICS) { - floor = Math.max(floor, clamp(0.018 + roadDensityInfluence[i] * 0.026 + railInfluence2[i] * 0.014 + logisticsInfluence[i] * 0.012, 0, 0.060)); - } else if (lu === LANDUSE.RURAL) { - floor = Math.max(floor, clamp(0.012 + ruralSuitability[i] * 0.020 + developable[i] * 0.014 + roadDensityInfluence[i] * 0.024 + stationDensityInfluence[i] * 0.020, 0, 0.070)); - } else if (lu === LANDUSE.FOREST) { - floor = Math.min(floor, (roadDensityInfluence[i] > 0.04 || villageInfluence[i] > 0.03) ? 0.026 : 0); - } - const normalized = populationDensity[i] / maxDensity; - populationDensity[i] = clamp(Math.max(normalized, floor)); - if (lu === LANDUSE.FOREST && floor === 0 && populationDensity[i] < 0.012) populationDensity[i] = 0; - } - } - } - classifyLanduse(); - - for (const city of modernCities) { - let urbanFootprintCells = 0; - let coreFootprintCells = 0; - const r = Math.ceil((city.urbanRadius || 8) * 1.3); - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - const x = city.x + dx; - const y = city.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (sea[i]) continue; - if (Math.hypot(dx, dy) > r) continue; - if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++; - if (landuse[i] === LANDUSE.CBD) coreFootprintCells++; - } - } - city.urbanFootprintCells = urbanFootprintCells; - city.coreFootprintCells = coreFootprintCells; - } - + const { landuse, populationDensity } = buildFeatureLanduse({ + seed, + elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, + developable, ruralSuitability, valleySettlement, coastalSettlement, + modernCities, logisticsParks, + roadLanduseInfluence, roadInfluence, roadDensityInfluence, railInfluence2, stationInfluence, stationDensityInfluence, villageInfluence, + cityInfluence, coreInfluence, oldTownInfluence, townInfluence, industrialInfluence, logisticsInfluence, + }); const transportDebug = { humanStageVersion: "v2-sparse-raster", aStarRoutes: 0, diff --git a/mapPostAdminTransport.js b/mapPostAdminTransport.js index ca18d4d..67d1166 100644 --- a/mapPostAdminTransport.js +++ b/mapPostAdminTransport.js @@ -155,7 +155,7 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge ...(features.villages || []).filter((p) => (p.population || 0) >= 5000), ...(features.ports || []).filter((p) => (p.population || 0) >= 5000 || p.portClass === "major" || p.portClass === "regional" || p.portClass === "fishing"), ]; - const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0 }; + const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0, expresswayMajorCityLinksAdded: 0, railCityChainsAdded: 0, railCityChainCitiesCovered: 0 }; // Local roads after admin: every municipal office cell should lie on a road. const settlementTargets = [...(features.modernCities || []), ...(features.markets || []), ...(features.villages || []), ...(features.ports || [])]; @@ -230,23 +230,23 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge const parts = []; const before = nearestTrunkOrHub(chain[0], 80); if (before) { - const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 }); + const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); } for (let i = 1; i < chain.length; i++) { const d = Math.hypot(chain[i - 1].x - chain[i].x, chain[i - 1].y - chain[i].y); - const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 0 }); + const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); } const after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null; if (after) { - const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 }); + const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); } let path = concatPaths(parts); if (path.length < 2) { const target = nearestTrunkOrHub(chain[0], 90); - path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 0 }) : []; + path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 10 }) : []; } if (path.length >= 2 && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) { nationalRoads.push(path); @@ -261,6 +261,106 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge debug.nationalTownChainTownsCovered = chainDebug.townsCovered; debug.nationalTownSpursAdded = chainDebug.chainsAdded; + function majorCityKey(city) { return city?.name || `${Math.round(city.x)},${Math.round(city.y)}`; } + function expresswayCityComponents(cities, radius = 8.0) { + const parent = new Map(); + function find(k) { + const p = parent.get(k); + if (p === k) return k; + const r = find(p); + parent.set(k, r); + return r; + } + function unite(a, b) { + const ra = find(a), rb = find(b); + if (ra !== rb) parent.set(ra, rb); + } + for (const city of cities) parent.set(majorCityKey(city), majorCityKey(city)); + for (const path of [...expressways, ...externalExpressways]) { + const near = cities.filter((city) => pathTouchesCell(path, city.x, city.y, radius)); + if (near.length >= 2) { + const first = majorCityKey(near[0]); + for (const city of near.slice(1)) unite(first, majorCityKey(city)); + } + } + return new Map(cities.map((city) => [majorCityKey(city), find(majorCityKey(city))])); + } + + function ensureMajorCityExpresswayLinks(minPopulation = 100000) { + const cities = (features.modernCities || []) + .filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y)) + .sort((a, b) => (b.population || 0) - (a.population || 0)); + const result = { minPopulation, checked: cities.length, added: 0 }; + if (cities.length < 2) return result; + for (let iter = 0; iter < cities.length * 2; iter++) { + const comps = expresswayCityComponents(cities); + if (new Set(comps.values()).size <= 1) break; + let best = null; + for (const a of cities) { + for (const b of cities) { + if (a === b || comps.get(majorCityKey(a)) === comps.get(majorCityKey(b))) continue; + const d = Math.hypot(a.x - b.x, a.y - b.y); + const score = d / Math.max(1, Math.log2(Math.sqrt((a.population || 1) * (b.population || 1)))); + if (!best || score < best.score) best = { a, b, d, score }; + } + } + if (!best) break; + let path = directPath(best.a, best.b, { maxLength: best.d * 1.18 + 12, terrain, maxSeaRun: 20 }); + if (!path.length) path = directPath(best.a, best.b, { maxLength: best.d * 1.38 + 28, terrain }); + if (!path.length) break; + path = smoothPath(path, 2); + expressways.push(path); + result.added++; + } + return result; + } + + function cityRailChain(minPopulation = 50000) { + const cities = (features.modernCities || []) + .filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y)) + .sort((a, b) => a.x - b.x || a.y - b.y); + const result = { minPopulation, checked: cities.length, chainsAdded: 0, citiesCovered: 0 }; + if (cities.length < 2) return result; + const existingRail = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])]; + const uncovered = cities.filter((city) => !anyPathTouches(existingRail, city, 1.2)); + if (!uncovered.length) return result; + const remaining = uncovered.slice(); + let chain = [remaining.shift()]; + while (remaining.length) { + const cur = chain[chain.length - 1]; + let bestIndex = 0; + let bestD = Infinity; + for (let i = 0; i < remaining.length; i++) { + const d = Math.hypot(cur.x - remaining[i].x, cur.y - remaining[i].y); + if (d < bestD) { bestD = d; bestIndex = i; } + } + chain.push(remaining.splice(bestIndex, 1)[0]); + } + const parts = []; + for (let i = 1; i < chain.length; i++) { + const a = chain[i - 1], b = chain[i]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + let p = directPath(a, b, { maxLength: d * 1.55 + 20, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); + if (!p.length) p = directPath(a, b, { maxLength: d * 1.85 + 40, terrain }); + if (p.length) parts.push(p); + } + const path = concatPaths(parts); + if (path.length >= 2) { + features.railways = features.railways || []; + features.railways.push(smoothPath(path, 1)); + result.chainsAdded = 1; + result.citiesCovered = chain.filter((city) => pathTouchesCell(path, city.x, city.y, 1.2)).length; + } + return result; + } + + const railDebug = cityRailChain(50000); + debug.railCityChainsAdded = railDebug.chainsAdded; + debug.railCityChainCitiesCovered = railDebug.citiesCovered; + + const expressDebug = ensureMajorCityExpresswayLinks(100000); + debug.expresswayMajorCityLinksAdded = expressDebug.added; + // Expressway finalization after administration: smooth and ensure both endpoints are ICs. for (let i = 0; i < expressways.length; i++) { const smoothed = smoothPath(expressways[i], 2); @@ -282,6 +382,7 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge features.externalRoads = dedupePaths(externalRoads, 1); features.expressways = dedupePaths(expressways, 2); features.externalExpressways = dedupePaths(externalExpressways, 2); + features.railways = dedupePaths(features.railways || [], 2); features.interchanges = interchanges; // Final invariant: after all dedupe passes, every municipal office cell has at least a local road cell on it. diff --git a/mapPrefectureStage.js b/mapPrefectureStage.js index ce1c31e..5a14081 100644 --- a/mapPrefectureStage.js +++ b/mapPrefectureStage.js @@ -748,6 +748,35 @@ export function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCo return changed; } +export function lockPrefectureCapitalNeighborMunicipalities(owner, nodes, seeds = [], maxNeighbors = 6) { + let changed = 0; + const seedIds = new Set((seeds || []).map((node) => node?.id).filter((id) => id !== undefined)); + for (const seedNode of seeds || []) { + if (!seedNode || !nodes.has(seedNode.id)) continue; + const prefId = owner.get(seedNode.id); + if (prefId === undefined || prefId < 0) continue; + const neighbors = [...(nodes.get(seedNode.id)?.adjacent || [])] + .map(([id, edge]) => ({ node: nodes.get(id), id, edge })) + .filter((row) => row.node && owner.get(row.id) !== prefId && !seedIds.has(row.id)) + .sort((a, b) => (a.edge.crossingCost ?? 1) - (b.edge.crossingCost ?? 1) || Math.hypot(a.node.x - seedNode.x, a.node.y - seedNode.y) - Math.hypot(b.node.x - seedNode.x, b.node.y - seedNode.y)); + let taken = 0; + for (const row of neighbors) { + if (taken >= maxNeighbors) break; + const donorPref = owner.get(row.id); + if (donorPref === undefined || donorPref < 0 || donorPref === prefId) continue; + if (!wouldRemainConnectedAfterRemoval(nodes, owner, row.id, donorPref)) continue; + owner.set(row.id, prefId); + changed++; + taken++; + } + } + if (changed) { + repairPrefectureMunicipalityConnectivity(nodes, owner); + repairPrefectureMunicipalityEnclaves(nodes, owner, 6); + } + return changed; +} + function averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, field) { if (!field) return 0; let sum = 0; @@ -803,6 +832,10 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) { changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 10); changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); + const changedForCapitalNeighborLock = lockPrefectureCapitalNeighborMunicipalities(owner, graph.nodes, seeds, 7); + changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); + changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12); + changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]); const municipalityToPrefectureId = new Int16Array(maxAdminId + 1); municipalityToPrefectureId.fill(-1); @@ -846,6 +879,7 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) { prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0), prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0, prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0, + prefectureCapitalNeighborLockChangedMunicipalities: changedForCapitalNeighborLock || 0, finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()), finalRegionalMunicipalityCountCap: 88, finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()), diff --git a/mapTransport.js b/mapTransport.js index 068224d..b1da100 100644 --- a/mapTransport.js +++ b/mapTransport.js @@ -5,11 +5,16 @@ import { assessMountainRoute, countReason, fieldBackbonePolicy, + markPathInfluence as markPathInfluenceBase, makeSpatialIndex, makeUnionFind, + meanFieldAround as meanFieldAroundBase, packDebugField, + pathCumulativeLengths, pathAverageField, pathLengthCells, + pointAtPathDistance, + sampledNetworkCells as sampledNetworkCellsBase, squaredDistance, TRANSPORT_ROUTE_POLICIES, } from "./mapTransportUtils.js"; @@ -147,6 +152,83 @@ export function buildDensityFlowRoadTransportSystem(ctx) { } const routePolicies = TRANSPORT_ROUTE_POLICIES; + const ROAD_MODE = { + expressway: { + debugBucket: "expresswayCorridors", + costField: () => expresswayCorridorCost, + potentialField: () => transportFields.expresswayPotential, + routeOptions: { + curvePenalty: 0.125, + penaltyStrength: 7.20, + terrainFlowBias: 0.08, + surfaceGrain: 0.006, + relaxRadius: 1, + relaxLineWeight: 0.19, + snapRadius: 3.5, + heuristicWeight: 0.72, + }, + backbone: { minDistance: 38, maxDistance: 190, maxDegree: 2, maxExtra: 0, parallelRadius: 84, penaltyStrengthMark: 16.50 }, + fieldPolicyMode: "expressway", + mountainMode: "expresswayMountainOnly", + connector: { curvePenalty: 0.095, terrainFlowBias: 0.12 }, + }, + national: { + debugBucket: "nationalCorridors", + costField: () => transportFields.national, + potentialField: () => transportFields.nationalPotential, + routeOptions: { + curvePenalty: 0.065, + penaltyStrength: 1.05, + terrainFlowBias: 0.24, + surfaceGrain: 0.030, + relaxRadius: 2, + relaxLineWeight: 0.28, + snapRadius: 2.5, + heuristicWeight: 0.50, + }, + backbone: { minDistance: 12, maxDistance: 130, maxDegree: 4, maxExtra: 8, parallelRadius: 6, penaltyStrengthMark: 0.32 }, + fieldPolicyMode: "national", + mountainMode: "national", + connector: { curvePenalty: 0.045, terrainFlowBias: 0.22 }, + }, + local: { + debugBucket: "localCorridors", + costField: () => transportFields.local, + potentialField: () => transportFields.localPotential, + routeOptions: { + curvePenalty: 0.035, + penaltyStrength: 0.90, + terrainFlowBias: 0.26, + surfaceGrain: 0.042, + relaxRadius: 2, + relaxLineWeight: 0.30, + snapRadius: 0.5, + heuristicWeight: 0.30, + }, + connector: { curvePenalty: 0.035, terrainFlowBias: 0.28 }, + }, + }; + + function roadMode(mode) { + return ROAD_MODE[mode] || ROAD_MODE.national; + } + + function recordSkipReason(stats, bucket, reason) { + stats[bucket]++; + countReason(stats, `${bucket}Reasons`, reason); + } + + function recordCorridor(debug, mode, from, to, path, extra = {}) { + const bucket = roadMode(mode).debugBucket; + if (!debug[bucket]) debug[bucket] = []; + debug[bucket].push({ + from, + to, + length: Math.round(pathLengthCells(path)), + ...extra, + path, + }); + } function mountainRouteAssessment(path, mode = "road") { return assessMountainRoute(path, mode, pathTerrainRisk, routePolicies); @@ -255,463 +337,6 @@ export function buildDensityFlowRoadTransportSystem(ctx) { 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 // ------------------------------------------------------------------------- @@ -747,23 +372,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { ); } - 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; - } - } - } - } + const markPathInfluence = (field, path, radius = 5, strength = 1) => markPathInfluenceBase(field, path, radius, strength, sea); function fieldAdjustedCost(baseCost, mode, flowField = null) { const out = new Float32Array(SIZE); @@ -1159,14 +768,102 @@ export function buildDensityFlowRoadTransportSystem(ctx) { return { before, after: expressways.length, loopTrimmed, urbanPruned, shortPruned }; } + function routeScoredPairs({ + pairs, + mode, + outPaths, + costField, + potentialField, + penalty, + accepted, + debug, + options = {}, + skip, + keyOf, + degree, + find, + unite, + }) { + const config = roadMode(mode); + const policy = fieldBackbonePolicy(config.fieldPolicyMode || mode); + let connectedAdds = 0; + let extraAdds = 0; + const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(10, Math.max(4, Math.ceil((options.nodeCount || 1) / 4))) : Math.min(34, Math.max(16, Math.ceil((options.nodeCount || 1) * 0.46)))); + const maxExtra = options.maxExtra ?? config.backbone?.maxExtra ?? 0; + const maxDegree = options.maxDegree ?? config.backbone?.maxDegree ?? 3; + 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 routeOptions = { + ...config.routeOptions, + maxPathLength: pair.d * (mode === "expressway" ? 2.75 : 2.55) + (mode === "expressway" ? 84 : 42), + ...options.routeOptions, + }; + const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, costField, penalty, routeOptions); + 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, config.mountainMode); + if (!mountainCheck.ok) { + recordSkipReason(skip, "mountain", mountainCheck.reason); + continue; + } + const acceptCheck = routeAcceptableForMode(path, mode, potentialField, penalty, {}, { allowFallback: true, allowApproach: true }); + if (!acceptCheck.ok) { + recordSkipReason(skip, "acceptable", acceptCheck.reason); + continue; + } + } else { + const mountainCheck = mountainRouteAssessment(path, config.mountainMode); + if (!mountainCheck.ok) { + recordSkipReason(skip, "mountain", 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) { + recordSkipReason(skip, "acceptable", 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 ?? config.backbone?.parallelRadius ?? 6, 1); + addCorridorInfluencePenalty(penalty, path, options.parallelRadius ?? config.backbone?.parallelRadius ?? 6, options.penaltyStrengthMark ?? config.backbone?.penaltyStrengthMark ?? 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++; + recordCorridor(debug, mode, pair.A.role, pair.B.role, path, { + distance: Math.round(pair.d), + score: Math.round(pair.score * 100) / 100, + }); + } + return { connectedAdds, extraAdds }; + } + function buildFieldBackbone(debug, mode, outPaths, anchors, costField, potentialField, options = {}) { + const config = roadMode(mode); 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); + for (const path of outPaths) markPathInfluence(accepted, path, options.parallelRadius ?? config.backbone?.parallelRadius ?? 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); @@ -1198,80 +895,22 @@ export function buildDensityFlowRoadTransportSystem(ctx) { 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 }); - } + const { connectedAdds, extraAdds } = routeScoredPairs({ + pairs, + mode, + outPaths, + costField, + potentialField, + penalty, + accepted, + debug, + options: { ...options, nodeCount: nodes.length }, + skip, + keyOf, + degree, + find, + unite, + }); debug[`${mode}AnchorCount`] = nodes.length; debug[`${mode}ConnectedAdds`] = connectedAdds; debug[`${mode}ExtraAdds`] = extraAdds; @@ -1313,6 +952,63 @@ export function buildDensityFlowRoadTransportSystem(ctx) { return false; } + function ensureCityOutboundConnections({ + debug, + mode, + cities, + startForCity, + targetAnchors, + costField, + penalty, + paths, + acceptedPaths = [], + maxTargets = 8, + minDistance = 14, + maxDistance = 126, + routeOptions = {}, + acceptable, + recordFrom, + counterKey, + }) { + let added = 0; + const config = roadMode(mode); + const accepted = new Float32Array(SIZE); + for (const path of paths) markPathInfluence(accepted, path, config.backbone?.parallelRadius ?? 6, 1.0); + for (const path of acceptedPaths) markPathInfluence(accepted, path, config.backbone?.parallelRadius ?? 6, mode === "national" ? 0.55 : 1.0); + for (const city of cities) { + const start = startForCity(city); + if (!start) continue; + if (paths.some((path) => pathServesCityCenter(path, city, mode))) 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, costField) })) + .filter((e) => e.d >= minDistance && e.d <= maxDistance && Number.isFinite(e.c) && e.c < INF) + .sort((a, b) => { + const aCity = a.q.role === (mode === "expressway" ? "urban-fringe-ic" : "urban-portal") ? -10 : 0; + const bCity = b.q.role === (mode === "expressway" ? "urban-fringe-ic" : "urban-portal") ? -10 : 0; + return (a.d * a.c + aCity) - (b.d * b.c + bCity); + }); + for (const opt of candidates.slice(0, maxTargets)) { + const path = routeBetweenTrafficCandidates(start, opt.q, mode, costField, penalty, { + ...config.routeOptions, + ...routeOptions, + maxPathLength: typeof routeOptions.maxPathLength === "function" ? routeOptions.maxPathLength(opt.d) : routeOptions.maxPathLength ?? opt.d * 3.05 + 74, + searchPad: typeof routeOptions.searchPad === "function" ? routeOptions.searchPad(opt.d) : routeOptions.searchPad, + }); + const len = pathLengthCells(path); + if (!acceptable(path, len, opt, city, start, accepted)) continue; + paths.push(path); + markPathInfluence(accepted, path, config.backbone?.parallelRadius ?? 6, 1.0); + addCorridorInfluencePenalty(penalty, path, config.backbone?.parallelRadius ?? 6, mode === "expressway" ? 1.10 : 0.38); + recordCorridor(debug, mode, recordFrom, opt.q.city?.name || opt.q.source?.name || opt.q.role, path, { city: city.name }); + added++; + break; + } + } + if (counterKey) debug[counterKey] = (debug[counterKey] || 0) + added; + return added; + } + function ensureExpresswayCityIntercity(debug, expressAnchors, expressCost) { const forceCost = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) { @@ -1424,7 +1120,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { 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 }); + recordCorridor(debug, "expressway", "forced-city-intercity", opt.q.city?.name || opt.q.role, path, { city: city.name, distance: Math.round(opt.d) }); added++; break; } @@ -1438,58 +1134,49 @@ export function buildDensityFlowRoadTransportSystem(ctx) { .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, { + ensureCityOutboundConnections({ + debug, + mode: "national", + cities, + startForCity: (city) => cityPortalAnchors(city, "national")[0] || portalSearchAroundPoint(city, "national", "urban-portal"), + targetAnchors, + costField: nationalCost, + penalty, + paths: nationalRoads, + acceptedPaths: externalRoads, + minDistance: 14, + maxDistance: 126, + maxTargets: 8, + routeOptions: { curvePenalty: 0.060, penaltyStrength: 1.04, terrainFlowBias: 0.26, surfaceGrain: 0.030, relaxRadius: 2, relaxLineWeight: 0.28, - maxPathLength: opt.d * 3.05 + 74, + maxPathLength: (d) => 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; + searchPad: (d) => Math.ceil(Math.max(42, Math.min(110, d * 0.62))), + }, + acceptable: (path, len, opt, city, start, accepted) => { + if (len < 5 || len > opt.d * 3.15 + 82) return false; + if (!pathComesOutOfCity(path, city, start, "national")) return false; + if (routeTooStraightAcrossMountains(path, "national")) return false; + if (!transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 5, maxLength: opt.d * 2.85 + 58, maxHighElevationShare: 0.36, maxSteepShare: 0.50 })) return false; 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; + if (parallel > 0.58) return false; + return true; + }, + recordFrom: "forced-city-outbound", + counterKey: "forcedNationalCityConnections", + }); } function buildDensityFlowRoadSystem(debug) { @@ -1502,14 +1189,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) { const expressFlow = buildTrafficFlowField("expressway", expressAnchors, expresswayCorridorCost, 14); const expressCost = fieldAdjustedCost(expresswayCorridorCost, "expressway", expressFlow); buildFieldBackbone(debug, "expressway", expressways, expressAnchors, expressCost, transportFields.expresswayPotential, { + ...roadMode("expressway").backbone, 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); @@ -1519,14 +1201,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) { const nationalFlow = buildTrafficFlowField("national", nationalAnchors, transportFields.national, 50); const nationalCost = fieldAdjustedCost(transportFields.national, "national", nationalFlow); buildFieldBackbone(debug, "national", nationalRoads, nationalAnchors, nationalCost, transportFields.nationalPotential, { + ...roadMode("national").backbone, 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 @@ -1558,7 +1235,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { 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 }); + recordCorridor(debug, "national", "city-portal-guarantee", target.q.role, path, { city: city.name }); addCorridorInfluencePenalty(nationalPenalty, path, 6, 0.28); } } @@ -1598,13 +1275,14 @@ export function buildDensityFlowRoadTransportSystem(ctx) { 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; + const config = roadMode(mode); + const costField = config.costField(); 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, + path = routeBetweenTrafficCandidates(a, b, mode, costField, null, { + curvePenalty: config.connector.curvePenalty, penaltyStrength: 0.0, - terrainFlowBias: mode === "expressway" ? 0.12 : mode === "national" ? 0.22 : 0.28, + terrainFlowBias: config.connector.terrainFlowBias, surfaceGrain: 0.020, relaxRadius: 1, relaxLineWeight: 0.22, @@ -2045,17 +1723,8 @@ export function buildDensityFlowRoadTransportSystem(ctx) { }); 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 sampledNetworkCells = (paths, step = 2) => sampledNetworkCellsBase(paths, step, sea); + 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); @@ -2077,46 +1746,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { 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 meanFieldAround = (field, x, y, radius = 8) => meanFieldAroundBase(field, x, y, radius, sea); const icDemandCache = new Map(); function icDemandAt(p) { diff --git a/mapTransportUtils.js b/mapTransportUtils.js index 870beaf..b2cb7e3 100644 --- a/mapTransportUtils.js +++ b/mapTransportUtils.js @@ -52,6 +52,81 @@ export function pathAverageField(path, field) { return n ? sum / n : 0; } +export function markPathInfluence(field, path, radius = 5, strength = 1, sea = null) { + 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; + } + } + } +} + +export function sampledNetworkCells(paths, step = 2, sea = null) { + 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; +} + +export 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; +} + +export 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 }; +} + +export function meanFieldAround(field, x, y, radius = 8, sea = null) { + let sum = 0; + let 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; + const 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; +} + export function routeQualityStats(path, fields = {}) { if (!path?.length) return { length: 0, compactness: Infinity, highElevationShare: 1, steepShare: 1, waterShare: 1, avgPotential: 0, avgPenalty: 0 }; const length = pathLengthCells(path); diff --git a/names.js b/names.js index 2d8967e..3582bed 100644 --- a/names.js +++ b/names.js @@ -15,13 +15,13 @@ export const NAME_KANJI_POOLS = { "霞", "朝", "日", "天", "土", "砂", "石", "岩", "卯", "辰", - "串","駒", "来", "武", "箱", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄" + "串","駒", "来", "武", "箱", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄" ], inlandTerrain: [ "山", "野", "野", "沢", "森", "林", "岡", "丘", "坂", - "峰", "峠", "嶺", "尾", "平", "坪", "延", + "峰", "嶺", "尾", "平", "坪", "延", "窪", "久", "迫", "久保", "玖保", "佐古", "作古", "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生", "郷", "里", @@ -85,7 +85,7 @@ export const NAME_KANJI_POOLS = { ], archaicSuffixes: [ - "井", "羽", "江", "恵", "尾", + "伊", "衣", "井", "羽", "江", "恵", "尾", "賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", "佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇", "総", "多", "太", "知", "津", "豆", "土", "登", @@ -107,7 +107,7 @@ export const NAME_KANJI_POOLS = { settlementWords: [ "里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣", - "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", + "庄", "ノ庄", "之庄", "宮", "ノ宮", "之宮", "寺", "社", "堂", "城", "館", "屋", "家", "所", "市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋", ] diff --git a/renderer.js b/renderer.js index 276fcae..bf72d40 100644 --- a/renderer.js +++ b/renderer.js @@ -655,17 +655,19 @@ function drawBridgeOverlay(ctx, map, path, width, mode = "road") { } function drawTunnelOverlay(ctx, map, path, width, mode = "road") { - if (mode !== "expressway") return; + if (mode !== "expressway" && mode !== "national") return; + const vectorMode = mode === "expressway" ? "expressway" : "default"; + const limit = mode === "expressway" ? 10 : 10; const tunnelChunks = specialTransportSubpaths( map, path, (i) => !map.sea?.[i] && (((map.elevation?.[i] || 0) >= 0.74 && (map.ridgeField?.[i] || 0) >= 0.46) || (map.naturalBarrierScore?.[i] || 0) >= 0.82), 2, true, - 10 + limit ); for (const chunk of tunnelChunks) { - drawDottedOutlinePath(ctx, chunk, "rgba(42, 42, 42, 0.96)", 1.15, Math.max(1.9, width * 0.82), "expressway"); + drawDottedOutlinePath(ctx, chunk, "rgba(42, 42, 42, 0.96)", mode === "expressway" ? 1.15 : 1.0, Math.max(1.75, width * 0.82), vectorMode); } } @@ -1135,8 +1137,8 @@ export function drawMap(canvas, map, options) { } if (showRoads) { for (const path of generalRoadPaths) { drawBridgeOverlay(ctx, map, path, 1.55, "road"); } - for (const path of map.nationalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); } - for (const path of map.externalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); } + for (const path of map.nationalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); drawTunnelOverlay(ctx, map, path, 1.9, "national"); } + for (const path of map.externalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); drawTunnelOverlay(ctx, map, path, 1.9, "national"); } for (const path of map.expressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); } for (const path of map.externalExpressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); } } From 860471f8055a6c71f92d21c66ce14c08a4476982 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 28 May 2026 15:48:42 +0900 Subject: [PATCH 2/7] stable --- adminRegionsCore.js | 95 ++++++++ mapAdminStage.js | 9 +- mapFeatureLanduse.js | 1 - mapFeatures.js | 450 ++++++++++++++++++++++++++++--------- mapGeneratorHelpers.js | 77 ++++++- mapOutput.js | 363 +++++++++++++++++++++++++++++- mapPostAdminTransport.js | 464 ++++++++++++++++++++++++++++++++++++--- mapPrefectureStage.js | 68 ++++++ mapTransport.js | 125 ++++++----- mapTransportGraph.js | 145 ++++++++++++ mapTransportOD.js | 22 +- mapTransportUtils.js | 13 ++ renderer.js | 167 +++----------- 13 files changed, 1643 insertions(+), 356 deletions(-) create mode 100644 mapTransportGraph.js diff --git a/adminRegionsCore.js b/adminRegionsCore.js index 04f902c..2dd8ebc 100644 --- a/adminRegionsCore.js +++ b/adminRegionsCore.js @@ -302,6 +302,101 @@ export function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCente } } + +export function enforceMunicipalityConnectivityStrict(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxPasses = 6) { + // Final cell-level invariant: each municipality should be one contiguous land + // component. Earlier stages are allowed to leave sizeable satellite pieces + // while boundaries are still being snapped; this pass removes the remaining + // visual exclaves by attaching every non-primary component to the neighboring + // municipality with the largest shared boundary. A component that contains a + // protected point may become the primary component, but it no longer protects + // additional detached pieces. + const protectedByAdmin = new Map(); + for (const p of [...(adminCenters || []), ...(protectedPoints || [])]) { + if (!p || !inside(p.x, p.y)) continue; + const i = indexOf(Math.round(p.x), Math.round(p.y)); + const id = adminId[i]; + if (id < 0) continue; + if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set()); + protectedByAdmin.get(id).add(i); + } + + let changed = 0; + const queue = []; + for (let pass = 0; pass < maxPasses; pass++) { + let passChanged = 0; + const ids = new Set(); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]); + for (const id of ids) { + const seen = new Uint8Array(SIZE); + const components = []; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue; + const comp = []; + let protectedHits = 0; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(cur); + if (protectedByAdmin.get(id)?.has(cur)) protectedHits++; + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (seen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + components.push({ cells: comp, protectedHits }); + } + if (components.length <= 1) continue; + components.sort((a, b) => + (b.protectedHits ? 1_000_000 : 0) + b.cells.length - + ((a.protectedHits ? 1_000_000 : 0) + a.cells.length) + ); + const primary = components[0]; + for (const component of components.slice(1)) { + const counts = new Map(); + for (const ci of component.cells) { + const [x, y] = xyOf(ci); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const other = adminId[ni]; + if (other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1); + } + } + let target = -1; + let best = -1; + for (const [other, count] of counts) { + const bonus = protectedByAdmin.get(other)?.size ? 0.25 : 0; + const score = count + bonus; + if (score > best || (score === best && other < target)) { best = score; target = other; } + } + if (target < 0) { + // Very rare: a detached island component has no labeled neighbor. + // Keep the largest/protected primary and merge the component into it + // only if it is directly adjacent after previous changes; otherwise + // leave it for the next pass rather than inventing over-sea ownership. + target = id; + } + if (target >= 0 && target !== id) { + for (const ci of component.cells) adminId[ci] = target; + passChanged += component.cells.length; + } else if (component !== primary) { + // If no external target exists, still mark it as handled by keeping it; + // another pass may expose a target after surrounding cells change. + } + } + } + changed += passChanged; + if (!passChanged) break; + } + return changed; +} + export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) { const urbanPenalty = urbanBoundaryPenalty(i, populationDensity, landuse); const majorRiver = clamp(Math.max(river[i] - 0.32, 0) * 1.9 + Math.max(flowAccum[i] - 0.38, 0) * 0.75); diff --git a/mapAdminStage.js b/mapAdminStage.js index 49a9cb2..fe94308 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -4,6 +4,7 @@ import { lockSmallUrbanComponentsToMunicipality, mergeTinyMunicipalities, removeMunicipalExclaves, + enforceMunicipalityConnectivityStrict, smoothAdminRegionsTerrainAware, snapAdminBoundariesToTerrain, } from "./adminRegions.js"; @@ -162,8 +163,10 @@ function generateAdminLayoutForMask({ }); const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea); const compacted = compactWholeCompartmentMunicipalities(adminId, adminCentersRaw, prefectureMask, sea, { populationDensity, plain, slope }); + const strictConnectivityChangedCells = enforceMunicipalityConnectivityStrict(compacted.adminId, prefectureMask, sea, compacted.adminCentersRaw, [...modernCities, ...(compacted.adminCentersRaw || [])], 8); + const strictEnclaveRepairChangedCells = repairAdminSingleOwnerEnclaves(compacted.adminId, prefectureMask, sea, 4); const adminBorders = extractAdminBorderSegments(compacted.adminId, prefectureMask); - const actualMunicipalityCount = compacted.activeMunicipalityCount; + const actualMunicipalityCount = new Set([...compacted.adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size; const naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length; const adminDebug = { ...compartmentAssignment.debug, @@ -179,6 +182,8 @@ function generateAdminLayoutForMask({ targetMunicipalityCount, actualMunicipalityCount, finalMunicipalityCount: actualMunicipalityCount, + changedAfterStrictMunicipalityConnectivity: strictConnectivityChangedCells, + changedAfterStrictMunicipalityEnclaveRepair: strictEnclaveRepairChangedCells, candidateSeedCount: adminCentersRaw.length, municipalOfficePointCount: compacted.adminCentersRaw.length, seedCellRevivalCount: 0, @@ -436,6 +441,8 @@ function generateAdminLayoutForMask({ adminDebug.changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980); adminDebug.changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6); adminDebug.changedAfterFinalCompartmentOwnership += enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea); + adminDebug.changedAfterStrictMunicipalityConnectivity = enforceMunicipalityConnectivityStrict(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 8); + adminDebug.changedAfterStrictMunicipalityEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 4); const areaById = municipalityAreaById(adminId, prefectureMask, sea); const satelliteAreas = []; diff --git a/mapFeatureLanduse.js b/mapFeatureLanduse.js index aaccc11..42b346d 100644 --- a/mapFeatureLanduse.js +++ b/mapFeatureLanduse.js @@ -124,7 +124,6 @@ export function buildFeatureLanduse(ctx) { const i = indexOf(x, y); if (sea[i] || baseLanduse[i] === LANDUSE.FOREST) continue; const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); - const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85); let urbanNeighbors = 0; let cbdNeighbors = 0; for (let dy = -1; dy <= 1; dy++) { diff --git a/mapFeatures.js b/mapFeatures.js index c5e4d86..75e009a 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -1,11 +1,12 @@ import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js"; -import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js"; +import { createPointSpatialIndex, distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js"; import { buildDensityFlowRoadTransportSystem, createPathInfluenceCache, packDebugField, pathAverageField, pathLengthCells, routeQualityAcceptable } from "./mapTransport.js"; import { buildUnifiedRailODNetwork } from "./mapTransportOD.js"; import { buildFeatureContext } from "./mapFeatureContext.js"; import { buildFeatureLanduse } from "./mapFeatureLanduse.js"; import { buildSettlementDemandFields } from "./mapFeatureSettlements.js"; import { buildFeatureTransportCostFields } from "./mapFeatureTransportTools.js"; +import { buildCoarseCostGraph, refineCoarsePath, routeCoarsePath } from "./mapTransportGraph.js"; // Lightweight Human Geography V2 // -------------------------------- @@ -17,6 +18,16 @@ import { buildFeatureTransportCostFields } from "./mapFeatureTransportTools.js"; // 4. synthesize population and land-use fields in one raster pass export function generateMapFeatures(seed, terrain) { + const SPEED_TOLERANCE = 0.90; + const featureTimings = []; + const nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); + let timingMark = nowMs(); + function markFeatureTiming(key) { + const t = nowMs(); + featureTimings.push({ key, ms: Math.round((t - timingMark) * 10) / 10 }); + timingMark = t; + } + const { elevation, slope, @@ -65,6 +76,7 @@ export function generateMapFeatures(seed, terrain) { pickRegionalPoints, pickGlobalPoints, } = featureContext; + markFeatureTiming("context"); // --- 2. Sparse points ---------------------------------------------------- let ports = pickGlobalPoints(portSuitability || coastalSettlement, { threshold: 0.30 + rand(seed, 1001) * 0.08, @@ -84,6 +96,8 @@ export function generateMapFeatures(seed, terrain) { ports[0].kind = "Major Port"; } const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional"); + const portIndex = createPointSpatialIndex(ports, 12); + const commercialPortIndex = createPointSpatialIndex(commercialPorts, 12); const crossings = pickGlobalPoints(crossingSuitability || confluenceField, { threshold: 0.30 + rand(seed, 1011) * 0.06, @@ -92,6 +106,7 @@ export function generateMapFeatures(seed, terrain) { seedOffset: 1010, predicate: (x, y, i) => river[i] > 0.12 || confluenceField[i] > 0.09, }).map((p) => ({ ...p, kind: "River Crossing" })); + const crossingIndex = createPointSpatialIndex(crossings, 8); const passes = pickGlobalPoints(passSuitability || valleySettlement, { threshold: 0.18 + rand(seed, 1021) * 0.06, @@ -199,6 +214,7 @@ export function generateMapFeatures(seed, terrain) { const open = Math.max(0, plain[i] * 0.92 + agriculture[i] * 0.72 + basinField[i] * 0.26 + (depositionalLowland?.[i] || 0) * 0.20 - river[i] * 0.22 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.14); openPlainVillageScore[i] = clamp(open * 0.82 + settlementCluster[i] * 0.14 + ruralSuitability[i] * 0.16 + fieldValue(geoHabitability, i, developable[i]) * 0.16 + fieldValue(geoLowlandCapacity, i, plain[i]) * 0.12 - geographicAnchorInfluence[i] * 0.05); } + const initialVillageIndex = createPointSpatialIndex(villages, 8); const supplementalPlainVillages = pickRegionalPoints(openPlainVillageScore, { stride: 2, threshold: 0.235 + rand(seed, 1036) * 0.020, @@ -216,7 +232,7 @@ export function generateMapFeatures(seed, terrain) { return Math.round(clamp(raw + rand(seed, 1037 + regionId * 29) * 1.4, min, max)); }, extraScore: (x, y, i) => Math.max(0, plain[i] * 0.34 + agriculture[i] * 0.26 - river[i] * 0.20 - valleyField[i] * 0.12), - }).filter((p) => distanceToNearest(villages, p.x, p.y) >= 6.5) + }).filter((p) => !initialVillageIndex.hasWithin(p.x, p.y, 6.5)) .map((p, n) => { const i = indexOf(p.x, p.y); const population = Math.round((1100 + Math.pow(rand(seed, 18220 + n * 31 + p.x * 7 + p.y), 1.12) * 7600 + agriculture[i] * 3900 + plain[i] * 2200) / 100) * 100; @@ -233,8 +249,8 @@ export function generateMapFeatures(seed, terrain) { if (sea[i]) continue; const openPlainMarket = Math.max(0, plain[i] * 0.68 + agriculture[i] * 0.52 + basinField[i] * 0.24 + (depositionalLowland?.[i] || 0) * 0.18 - river[i] * 0.16 - valleyField[i] * 0.06 - slope[i] * 0.18); const featurePull = Math.max( - distanceToNearest(ports, x, y) < 10 ? 0.16 : 0, - distanceToNearest(crossings, x, y) < 6 ? 0.035 : 0, + portIndex.hasWithin(x, y, 10) ? 0.16 : 0, + crossingIndex.hasWithin(x, y, 6) ? 0.035 : 0, confluenceField[i] * 0.08 ); const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0; @@ -278,10 +294,10 @@ export function generateMapFeatures(seed, terrain) { const max = st.area > 3600 ? 20 : st.area > 2200 ? 14 : st.area > 800 ? 7 : 3; return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max)); }, - extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 10 ? 0.12 : 0) + coastalSettlement[i] * 0.08 + Math.max(0, plain[i] * 0.32 + agriculture[i] * 0.20 - river[i] * 0.16) * 0.09 + confluenceField[i] * 0.035, + extraScore: (x, y, i) => (commercialPortIndex.hasWithin(x, y, 10) ? 0.12 : 0) + coastalSettlement[i] * 0.08 + Math.max(0, plain[i] * 0.32 + agriculture[i] * 0.20 - river[i] * 0.16) * 0.09 + confluenceField[i] * 0.035, }).map((p, n) => { const i = indexOf(p.x, p.y); - const kind = coastalSettlement[i] > 0.38 && distanceToNearest(ports, p.x, p.y) < 11 ? "Port Town" : valleySettlement[i] > 0.48 ? "Valley Market Town" : "Market Town"; + const kind = coastalSettlement[i] > 0.38 && portIndex.hasWithin(p.x, p.y, 11) ? "Port Town" : valleySettlement[i] > 0.48 ? "Valley Market Town" : "Market Town"; const population = Math.round((9000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.02) * 70000 + marketScore[i] * 40000 + villageInfluence[i] * 7800 + Math.max(0, plain[i] * 0.48 + agriculture[i] * 0.30 + basinField[i] * 0.18 - river[i] * 0.14) * 22000 + coastalSettlement[i] * 12000) / 1000) * 1000; return { ...p, kind, population }; }); @@ -292,6 +308,8 @@ export function generateMapFeatures(seed, terrain) { const open = Math.max(0, plain[i] * 0.86 + agriculture[i] * 0.64 + basinField[i] * 0.28 + (depositionalLowland?.[i] || 0) * 0.22 - river[i] * 0.20 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.16); openPlainMarketScore[i] = clamp(open * 0.78 + townSuitability[i] * 0.16 + fieldValue(geoNaturalCentrality, i, townSuitability[i]) * 0.18 + fieldValue(geoHabitability, i, developable[i]) * 0.12 + villageInfluence[i] * 0.16 + settlementCluster[i] * 0.10 + geographicAnchorInfluence[i] * 0.05); } + const preSupplementalMarketIndex = createPointSpatialIndex(markets, 12); + const villageIndexForMarketSpacing = createPointSpatialIndex(villages, 8); const supplementalPlainMarkets = pickRegionalPoints(openPlainMarketScore, { stride: 2, threshold: 0.335 + rand(seed, 1046) * 0.025, @@ -309,13 +327,15 @@ export function generateMapFeatures(seed, terrain) { return Math.round(clamp(raw + rand(seed, 1047 + regionId * 31) * 0.9, min, max)); }, extraScore: (x, y, i) => Math.max(0, plain[i] * 0.24 + agriculture[i] * 0.18 - river[i] * 0.14 - valleyField[i] * 0.08), - }).filter((p) => distanceToNearest(markets, p.x, p.y) >= 10.5 && distanceToNearest(villages, p.x, p.y) >= 4.5) + }).filter((p) => !preSupplementalMarketIndex.hasWithin(p.x, p.y, 10.5) && !villageIndexForMarketSpacing.hasWithin(p.x, p.y, 4.5)) .map((p, n) => { const i = indexOf(p.x, p.y); const population = Math.round((10000 + Math.pow(rand(seed, 18340 + n * 37 + p.x * 11 + p.y), 1.02) * 52000 + openPlainMarketScore[i] * 26000 + agriculture[i] * 9000 + plain[i] * 8000) / 1000) * 1000; return { ...p, kind: "Plain Market Town", population }; }); markets = [...markets, ...supplementalPlainMarkets]; + let marketIndex = createPointSpatialIndex(markets, 12); + let villageIndex = createPointSpatialIndex(villages, 8); // Sparse-area towns: when a developable basin/plain/coast has few nearby towns, // add a small market town candidate. This avoids large inhabited regions being @@ -330,6 +350,10 @@ export function generateMapFeatures(seed, terrain) { const livable = clamp(townSuitability[i] * 0.34 + fieldValue(geoNaturalCentrality, i, townSuitability[i]) * 0.22 + fieldValue(geoHabitability, i, developable[i]) * 0.20 + fieldValue(geoAccessibility, i, 0) * 0.12 + developable[i] * 0.22 + plain[i] * 0.24 + agriculture[i] * 0.18 + basinField[i] * 0.16 + coastalSettlement[i] * 0.14 + valleySettlement[i] * 0.12 - fieldValue(geoBarrier, i, 0) * 0.12 - slope[i] * 0.22 - ridgeField[i] * 0.10); sparseTownScore[i] = clamp(livable * (0.42 + remoteness * 0.88) + settlementGap * 0.16); } + const preSparseMarketIndex = marketIndex; + const preSparseVillageIndex = villageIndex; + const marketCountByRegion = new Map(); + for (const m of markets) marketCountByRegion.set(m.regionId, (marketCountByRegion.get(m.regionId) || 0) + 1); const sparseMarkets = pickRegionalPoints(sparseTownScore, { stride: 2, threshold: 0.315 + rand(seed, 1049) * 0.025, @@ -341,19 +365,20 @@ export function generateMapFeatures(seed, terrain) { quotaForRegion: (regionId, st) => { if (!st || st.developableCells < 90) return 0; const vf = visibilityFactor(regionId, st); - const underServed = clamp(1.0 - ((markets.filter((m) => m.regionId === regionId).length || 0) / Math.max(1, st.area / 850))); + const underServed = clamp(1.0 - ((marketCountByRegion.get(regionId) || 0) / Math.max(1, st.area / 850))); const raw = (st.developableCells / 900 + st.plainCells / 720 + st.coastCells / 560 + 0.55) * vf * (0.55 + underServed * 0.75); return Math.round(clamp(raw + rand(seed, 1050 + regionId * 37) * 0.45, 0, st.area > 2000 ? 2 : 1)); }, extraScore: (x, y, i) => clamp((0.34 - existingTownInfluenceForSparseFill[i]) * 0.38 + plain[i] * 0.10 + agriculture[i] * 0.08 + coastalSettlement[i] * 0.06), }) - .filter((p) => distanceToNearest(markets, p.x, p.y) >= 12 && distanceToNearest(villages, p.x, p.y) >= 4.5) + .filter((p) => !preSparseMarketIndex.hasWithin(p.x, p.y, 12) && !preSparseVillageIndex.hasWithin(p.x, p.y, 4.5)) .map((p, n) => { const i = indexOf(p.x, p.y); const population = Math.round((8000 + Math.pow(rand(seed, 18480 + n * 41 + p.x * 13 + p.y), 1.08) * 36000 + sparseTownScore[i] * 26000) / 1000) * 1000; return { ...p, kind: "Sparse Market Town", population }; }); markets = [...markets, ...sparseMarkets]; + marketIndex = createPointSpatialIndex(markets, 12); const defenseScore = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) { @@ -477,7 +502,7 @@ export function generateMapFeatures(seed, terrain) { // regional pass. modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score); - const regionalCapitalSlots = Math.max(1, Math.min(4, Math.round(Math.sqrt(Math.max(1, modernCities.length))))); + const regionalCapitalSlots = Math.max(1, Math.min(2, Math.floor(Math.sqrt(Math.max(1, modernCities.length)) / 2))); for (const [rank, city] of modernCities.entries()) { const i = indexOf(city.x, city.y); const st = regionStats.get(city.regionId); @@ -489,11 +514,35 @@ export function generateMapFeatures(seed, terrain) { fieldValue(geoAccessibility, i, 0) * 0.18 + Math.log10((city.capacity || 26000) + 1) / 7 * 0.26 ); - const isTopCenter = rank < regionalCapitalSlots || geoTierScore > 0.58; + const isTopCenter = rank < regionalCapitalSlots || geoTierScore > 0.8; const isRegionalCapital = isFirstInRegion && (isTopCenter || (city.capacity || 0) > 210000 || (st?.highCentralityCells || 0) > 220); - const rawPop = isRegionalCapital - ? 180000 + rand(seed, 12201 + city.regionId * 17) * (isTopCenter ? 760000 : 360000) - : 52000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.68) * 360000; +const u = rand(st.seed, city.x, city.y, 9101); +const v = rand(st.seed, city.x, city.y, 9102); +const w = rand(st.seed, city.x, city.y, 9103); + +let rawPop; + +if (isRegionalCapital) { + if (isTopCenter) { + // largest 3M - 11M + rawPop = + 3000000 + + Math.pow(u, 0.42) * 5200000 + + Math.pow(v, 3.2) * 2800000; + } else { + // larger 0.25M - 2.5M + rawPop = + 250000 + + Math.pow(u, 0.55) * 1450000 + + Math.pow(v, 2.4) * 900000; + } +} else { + // normal 5k - 0.75k + rawPop = + 52000 + + Math.pow(u, 0.72) * 520000 + + Math.pow(v, 3.0) * 320000; +} const capMultiplier = isRegionalCapital ? (isTopCenter ? 1.66 : 1.42) : 1.20; const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000; const floor = isRegionalCapital ? (isTopCenter ? 210000 : 120000) : 42000; @@ -524,14 +573,18 @@ export function generateMapFeatures(seed, terrain) { if (!nearestCity.city) return true; return nearestCity.d >= urbanSettlementExclusionRadius(nearestCity.city, "market"); }); + marketIndex = createPointSpatialIndex(markets, 12); villages = villages.filter((v) => { const nearestCity = modernCities.reduce((best, city) => { const d = Math.hypot(v.x - city.x, v.y - city.y); return d < best.d ? { city, d } : best; }, { city: null, d: Infinity }); if (nearestCity.city && nearestCity.d < urbanSettlementExclusionRadius(nearestCity.city, "village")) return false; - return distanceToNearest(markets, v.x, v.y) >= 3.4; + return !marketIndex.hasWithin(v.x, v.y, 3.4); }); + marketIndex = createPointSpatialIndex(markets, 12); + villageIndex = createPointSpatialIndex(villages, 8); + const cityIndex = createPointSpatialIndex(modernCities, 12); villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2)); const settlementHierarchyDebug = { version: "phase2-unified-settlement-hierarchy", @@ -542,6 +595,7 @@ export function generateMapFeatures(seed, terrain) { villagesAfterHierarchyFilter: villages.length, regionalCapitalSlots, }; + markFeatureTiming("settlement-placement"); function cityPopulationCap(city) { const radius = city?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18; @@ -561,6 +615,7 @@ export function generateMapFeatures(seed, terrain) { sea, agriculture, plain, basinField, coastalLowland, slope, ridgeField, modernCities, markets, commercialPorts, villages, }); + markFeatureTiming("settlement-demand"); const transportFields = buildFeatureTransportCostFields({ seed, sea, elevation, slope, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, @@ -569,7 +624,26 @@ export function generateMapFeatures(seed, terrain) { preliminaryTownInfluence, preliminaryVillageInfluence, valleySettlement, coastalSettlement, developable, }); + markFeatureTiming("transport-cost-fields"); const cachedInfluenceFromPaths = createPathInfluenceCache(influenceFromPaths); + const coarseRouteStats = { graphBuilds: 0, attempted: 0, routed: 0, refined: 0, fallback: 0, skipped: 0 }; + const coarseGraphCache = new WeakMap(); + function coarseGraphFor(costField, mode = "national") { + const scale = mode === "local" ? 5 : mode === "rail" ? 4 : mode === "expressway" ? 4 : 4; + let byMode = coarseGraphCache.get(costField); + if (!byMode) { + byMode = new Map(); + coarseGraphCache.set(costField, byMode); + } + const key = `${mode}:${scale}`; + let graph = byMode.get(key); + if (!graph) { + graph = buildCoarseCostGraph({ sea, costField }, { scale }); + byMode.set(key, graph); + coarseRouteStats.graphBuilds++; + } + return graph; + } const componentCityInfluence = influenceFromPoints([...modernCities, ...markets], 11, (p) => clamp((p.population || 8000) / 50000, 0.18, 8.0)); const componentCapitalInfluence = influenceFromPoints(modernCities.filter((p) => p.isPrefecturalCapital), 16, () => 5.0); @@ -637,17 +711,30 @@ export function generateMapFeatures(seed, terrain) { return support > 0.14 || transportFields.nationalPotential[i] > 0.37; } + const routeScratch = { + score: new Float32Array(SIZE), + cameFrom: new Int32Array(SIZE), + seen: new Int32Array(SIZE), + closed: new Int32Array(SIZE), + epoch: 0, + }; + function traceCorridorByCost(start, goalRegionPredicate, costField, penaltyField, options = {}) { if (!start || !inside(start.x, start.y)) return []; const startIndex = indexOf(start.x, start.y); if (sea[startIndex] || costField[startIndex] >= INF) return []; - const score = new Float32Array(SIZE); - const cameFrom = new Int32Array(SIZE); - const closed = new Uint8Array(SIZE); - score.fill(INF); - cameFrom.fill(-1); + const { score, cameFrom, seen, closed } = routeScratch; + let epoch = ++routeScratch.epoch; + if (routeScratch.epoch > 2000000000) { + routeScratch.seen.fill(0); + routeScratch.closed.fill(0); + routeScratch.epoch = 1; + epoch = 1; + } const heap = new MinHeap(); + seen[startIndex] = epoch; score[startIndex] = 0; + cameFrom[startIndex] = -1; heap.push({ i: startIndex, f: 0 }); const curvePenalty = options.curvePenalty ?? 0.12; const penaltyStrength = options.penaltyStrength ?? 1.0; @@ -662,9 +749,10 @@ export function generateMapFeatures(seed, terrain) { while (heap.length && expanded++ < maxExpanded) { const current = heap.pop(); - if (!current || closed[current.i]) continue; - closed[current.i] = 1; - const [cx, cy] = xyOf(current.i); + if (!current || closed[current.i] === epoch) continue; + closed[current.i] = epoch; + const cx = current.i % MAP_W; + const cy = Math.floor(current.i / MAP_W); if (current.i !== startIndex && Math.hypot(cx - start.x, cy - start.y) >= minGoalDistance && goalRegionPredicate(cx, cy, current.i)) { goalIndex = current.i; break; @@ -677,12 +765,13 @@ export function generateMapFeatures(seed, terrain) { if (!inside(nx, ny)) continue; if (bounds && (nx < bounds.minX || nx > bounds.maxX || ny < bounds.minY || ny > bounds.maxY)) continue; const ni = indexOf(nx, ny); - if (closed[ni] || sea[ni] || costField[ni] >= INF) continue; + if (closed[ni] === epoch || sea[ni] || costField[ni] >= INF) continue; if (sameRegion >= 0 && options.keepRegion !== false && regionIdAt(nx, ny) !== sameRegion) continue; const prev = cameFrom[current.i]; let turn = 0; if (prev >= 0) { - const [px, py] = xyOf(prev); + const px = prev % MAP_W; + const py = Math.floor(prev / MAP_W); const ax = cx - px; const ay = cy - py; turn = Math.abs(ax * dy - ay * dx) > 0 ? curvePenalty : 0; @@ -699,7 +788,8 @@ export function generateMapFeatures(seed, terrain) { ); const surfaceGrain = (options.surfaceGrain ?? 0) * valueNoise(nx, ny, seed + 13941, 18); const nd = score[current.i] + Math.max(0.08, costField[ni] + antiConcentration + turn - terrainFlowBias + surfaceGrain) * Math.hypot(dx, dy); - if (nd < score[ni]) { + if (seen[ni] !== epoch || nd < score[ni]) { + seen[ni] = epoch; score[ni] = nd; cameFrom[ni] = current.i; const h = goalHint ? Math.hypot(nx - goalHint.x, ny - goalHint.y) * heuristicWeight : 0; @@ -711,7 +801,7 @@ export function generateMapFeatures(seed, terrain) { if (goalIndex < 0) return []; const path = []; for (let p = goalIndex; p >= 0; p = cameFrom[p]) { - path.push(xyOf(p)); + path.push([p % MAP_W, Math.floor(p / MAP_W)]); if (p === startIndex) break; } return path.reverse(); @@ -1407,7 +1497,7 @@ export function generateMapFeatures(seed, terrain) { ...markets.filter(inRegion).map((p) => ({ ...p, nodeWeight: 3.2 + (p.population || 0) / 25000 })), ...commercialPorts.filter(inRegion).map((p) => ({ ...p, nodeWeight: p.portClass === "major" ? 6.5 : 4.6 })), ...passes.filter(inRegion).map((p) => ({ ...p, nodeWeight: 2.2 })), - ].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 16 : 10); + ].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 13 : 8); } function dedupePointCandidates(points, minDistance = 5) { @@ -1474,13 +1564,13 @@ export function generateMapFeatures(seed, terrain) { } function transportCandidatePoints(mode, potentialField, options = {}) { - const maxCells = options.maxCells ?? (mode === "expressway" ? 18 : mode === "rail" ? 26 : 40); - const maxSettlements = options.maxSettlements ?? (mode === "expressway" ? 20 : mode === "rail" ? 34 : 56); + const maxCells = options.maxCells ?? (mode === "expressway" ? 14 : mode === "rail" ? 20 : 30); + const maxSettlements = options.maxSettlements ?? (mode === "expressway" ? 16 : mode === "rail" ? 27 : 44); const minDistance = options.minDistance ?? (mode === "expressway" ? 13 : mode === "rail" ? 10 : 8); const cells = preferenceCellAnchors(mode, potentialField, maxCells, minDistance); const settlements = modeSettlementAnchors(mode, potentialField, maxSettlements); return dedupePointCandidates([...settlements, ...cells].sort((a, b) => b.score - a.score), Math.max(4, minDistance * 0.55)) - .slice(0, options.maxTotal ?? (mode === "expressway" ? 32 : mode === "rail" ? 48 : 74)); + .slice(0, options.maxTotal ?? (mode === "expressway" ? 26 : mode === "rail" ? 38 : 58)); } function routeBetweenTrafficCandidates(a, b, mode, costField, penaltyField, options = {}) { @@ -1491,14 +1581,38 @@ export function generateMapFeatures(seed, terrain) { if (sea[indexOf(start.x, start.y)] || sea[indexOf(target.x, target.y)]) return []; const d = Math.hypot(start.x - target.x, start.y - target.y); const snap = options.snapRadius ?? (mode === "expressway" ? 3 : mode === "rail" ? 2.5 : 2.25); - const searchPad = options.searchPad ?? Math.ceil(Math.max(18, Math.min(58, d * (mode === "expressway" ? 0.46 : mode === "rail" ? 0.42 : 0.36)))); + const searchPad = options.searchPad ?? Math.ceil(Math.max(16, Math.min(48, d * (mode === "expressway" ? 0.40 : mode === "rail" ? 0.37 : 0.31)))); const bounds = options.bounds || { minX: Math.max(0, Math.min(start.x, target.x) - searchPad), maxX: Math.min(MAP_W - 1, Math.max(start.x, target.x) + searchPad), minY: Math.max(0, Math.min(start.y, target.y) - searchPad), maxY: Math.min(MAP_H - 1, Math.max(start.y, target.y) + searchPad), }; - const path = traceCorridorByCost( + const coarseThreshold = options.coarseThreshold ?? (mode === "local" ? 18 : mode === "rail" ? 22 : mode === "expressway" ? 30 : 20); + let path = []; + if (!options.forceFullResolution && d >= coarseThreshold) { + coarseRouteStats.attempted++; + const graph = coarseGraphFor(costField, mode); + const coarse = routeCoarsePath(start, target, graph, { heuristicWeight: mode === "rail" ? 0.72 : 0.86 }); + if (coarse.length >= 2) { + const refined = refineCoarsePath([[start.x, start.y], ...coarse.slice(1, -1), [target.x, target.y]], costField, { + sea, + snapRadius: mode === "local" ? 2 : 1, + }); + if (refined.length >= 4) { + path = refined; + coarseRouteStats.routed++; + coarseRouteStats.refined++; + } else { + coarseRouteStats.skipped++; + } + } else { + coarseRouteStats.skipped++; + } + } + if (!path.length) { + coarseRouteStats.fallback++; + path = traceCorridorByCost( start, (x, y) => Math.hypot(x - target.x, y - target.y) <= snap, costField, @@ -1508,17 +1622,19 @@ export function generateMapFeatures(seed, terrain) { penaltyStrength: options.penaltyStrength ?? (mode === "expressway" ? 1.9 : mode === "rail" ? 1.35 : mode === "national" ? 1.05 : 0.78), minGoalDistance: Math.min(8, Math.max(2, d * 0.08)), keepRegion: false, - maxExpanded: Math.min(SIZE, Math.max(1800, Math.floor(d * d * (mode === "expressway" ? 3.8 : mode === "rail" ? 4.6 : 5.2)))), + maxExpanded: Math.min(Math.floor(SIZE * SPEED_TOLERANCE), Math.max(1300, Math.floor(d * d * (mode === "expressway" ? 2.9 : mode === "rail" ? 3.5 : 3.9)))), terrainFlowBias: options.terrainFlowBias ?? (mode === "expressway" ? 0.10 : mode === "rail" ? 0.12 : mode === "national" ? 0.24 : 0.30), surfaceGrain: options.surfaceGrain ?? (mode === "national" ? 0.030 : mode === "local" ? 0.042 : 0.010), bounds, goalHint: options.goalHint || target, heuristicWeight: options.heuristicWeight ?? (mode === "expressway" ? 0.66 : mode === "rail" ? 0.54 : mode === "national" ? 0.46 : 0.30), } - ); + ); + } if (path.length < 4) return []; if (options.maxPathLength && pathLengthCells(path) > options.maxPathLength) return []; - const relaxed = relaxRouteToTerrain(path, costField, { + const skipRelax = options.skipRelax ?? (mode === "local" && path.length > 42); + const relaxed = skipRelax ? path : relaxRouteToTerrain(path, costField, { radius: options.relaxRadius ?? (mode === "national" ? 2 : 1), lineWeight: options.relaxLineWeight ?? (mode === "national" ? 0.30 : mode === "expressway" ? 0.24 : 0.48), grain: options.surfaceGrain ?? 0.020, @@ -1695,12 +1811,77 @@ const premodernRoads = []; const interchanges = []; const externalGateways = []; - // Premodern roads connect castles/markets/ports sparsely. - for (const c of castles) { - const near = [...markets, ...ports, ...crossings].sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y)).slice(0, 2); - for (const n of near) { - const path = routeLight(c, n, 2); - if (path.length > 2) premodernRoads.push(path); + function pathEntirelyLand(path) { + if (!path || path.length < 2) return false; + for (let k = 1; k < path.length; k++) { + const a = path[k - 1]; + const b = path[k]; + if (!a || !b) return false; + const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]))); + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a[0] + (b[0] - a[0]) * t); + const y = Math.round(a[1] + (b[1] - a[1]) * t); + if (!inside(x, y)) return false; + const i = indexOf(x, y); + if (sea[i] || transportFields.local[i] >= INF) return false; + } + } + return true; + } + + function addPremodernRoad(a, b, edgeSet) { + if (!a || !b) return false; + const keyA = `${Math.round(a.x)},${Math.round(a.y)}`; + const keyB = `${Math.round(b.x)},${Math.round(b.y)}`; + const key = keyA < keyB ? `${keyA}|${keyB}` : `${keyB}|${keyA}`; + if (edgeSet.has(key)) return false; + edgeSet.add(key); + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d < 5 || d > 96) return false; + const path = routeLight(a, b, 2.4, transportFields.local); + if (path.length <= 2 || !pathEntirelyLand(path)) return false; + if (!routePhysicalAcceptable(path, 'local', { maxSeaRun: 0, maxSeaShare: 0, maxTunnelRun: 0, maxTunnelShare: 0 })) return false; + premodernRoads.push(path); + return true; + } + + // Premodern roads are a loose land-only network around the labelled historical + // places (castles, market/village nodes, ports, crossings), not sea chords. + { + const edgeSet = new Set(); + const labelledPremodernNodes = dedupePointCandidates([ + ...castles.map((p) => ({ ...p, premodernRole: "castle", premodernWeight: 1.35 })), + ...castleTowns.map((p) => ({ ...p, premodernRole: "castle-town", premodernWeight: 1.25 })), + ...markets.filter((p) => (p.population || 0) >= 2500).map((p) => ({ ...p, premodernRole: "market", premodernWeight: 1.0 + Math.min(0.45, (p.population || 0) / 60000) })), + ...villages.filter((p) => (p.population || 0) >= 4200).map((p) => ({ ...p, premodernRole: "village", premodernWeight: 0.74 })), + ...commercialPorts.map((p) => ({ ...p, premodernRole: "port", premodernWeight: p.portClass === "major" ? 1.22 : 1.0 })), + ...crossings.map((p) => ({ ...p, premodernRole: "crossing", premodernWeight: 0.78 })), + ].filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]), 5.0) + .sort((a, b) => (b.premodernWeight || 0) - (a.premodernWeight || 0)) + .slice(0, 54); + + for (const c of castles) { + const near = labelledPremodernNodes + .filter((n) => n !== c && Math.hypot(n.x - c.x, n.y - c.y) <= 88) + .sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y)) + .slice(0, 2); + for (const n of near) addPremodernRoad(c, n, edgeSet); + } + + for (const node of labelledPremodernNodes) { + if (premodernRoads.length >= 68) break; + const quota = node.premodernRole === "castle" || node.premodernRole === "castle-town" ? 3 : node.premodernRole === "market" ? 2 : 1; + const near = labelledPremodernNodes + .filter((n) => n !== node) + .map((n) => ({ n, d: Math.hypot(n.x - node.x, n.y - node.y), cost: Math.hypot(n.x - node.x, n.y - node.y) / Math.max(0.45, n.premodernWeight || 0.8) })) + .filter((e) => e.d >= 7 && e.d <= 82) + .sort((a, b) => a.cost - b.cost) + .slice(0, quota); + for (const { n } of near) { + if (premodernRoads.length >= 68) break; + addPremodernRoad(node, n, edgeSet); + } } } @@ -1750,10 +1931,12 @@ const premodernRoads = []; transportFields, settlementDemand, preliminaryUrbanInfluence, preliminaryTownInfluence, preliminaryVillageInfluence, modernCities, markets, ports, commercialPorts, externalGateways, geographicUrbanAnchors, regionIdAt, routeBetweenTrafficCandidates, addCorridorInfluencePenalty, transportRouteAcceptable, pruneParallelSameMode, cachedInfluenceFromPaths, + speedTolerance: SPEED_TOLERANCE, }); railways.push(...railOD.railways); branchRailways.push(...railOD.branchRailways); railODDebug = railOD.debug; + markFeatureTiming("rail-od"); const { transportDebugLayers, runLocalAccessPass, stitchRasterNearContacts, stitchLongLocalBranches, sanitizeLocalRoads, downgradeShortNationalRoads, connectAllRoadNetworksFinal } = buildDensityFlowRoadTransportSystem({ @@ -1771,6 +1954,7 @@ const premodernRoads = []; relaxRouteToTerrain, transportRouteAcceptable, repairTransportConnectivity, repairDanglingTransportEndpoints, pruneDanglingTerminalSegments, pruneParallelSameMode, }); + markFeatureTiming("road-system"); // Land-use road influence intentionally excludes expressways. Expressways // are through-corridors here, not automatic suburbanization generators. @@ -1798,9 +1982,9 @@ const premodernRoads = []; return urbanDensity > 0.50 ? 6 : ruralDensity > 0.24 ? 12 : 20; } function shouldPlaceRailStation(x, y, i) { - const nearCity = (preliminaryUrbanInfluence[i] || 0) > 0.12 || distanceToNearest(modernCities, x, y) < 4.8; - const nearTown = (preliminaryTownInfluence[i] || 0) > 0.13 || distanceToNearest(markets, x, y) < 4.2; - const nearVillage = (preliminaryVillageInfluence[i] || 0) > 0.20 || distanceToNearest(villages, x, y) < 3.4; + const nearCity = (preliminaryUrbanInfluence[i] || 0) > 0.12 || cityIndex.hasWithin(x, y, 4.8); + const nearTown = (preliminaryTownInfluence[i] || 0) > 0.13 || marketIndex.hasWithin(x, y, 4.2); + const nearVillage = (preliminaryVillageInfluence[i] || 0) > 0.20 || villageIndex.hasWithin(x, y, 3.4); const lowland = plain[i] > 0.16 || coastalLowland[i] > 0.16 || basinField[i] > 0.20 || valleyField[i] > 0.22; const terrainOk = slope[i] < 0.42 && ridgeField[i] < 0.60 && elevation[i] < 0.76; return terrainOk && lowland && (nearCity || nearTown || nearVillage); @@ -1822,6 +2006,7 @@ const premodernRoads = []; } const stationInfluence = influenceFromPoints(stations, 7, (s) => s.kind === "Major Station" ? 1.35 : 0.85); const stationDensityInfluence = influenceFromPoints(stations, 10, (s) => s.kind === "Major Station" ? 1.85 : 1.05); + markFeatureTiming("transport-influence-stations"); // --- 5. Approximate city/town influence and land-use --------------------- const cityInfluence = new Float32Array(SIZE); @@ -1921,7 +2106,7 @@ const premodernRoads = []; slope[i] * 0.44 - ridgeField[i] * 0.32 ); - const nearMajorCity = modernCities.some((c) => Math.hypot(c.x - x, c.y - y) < 4); + const nearMajorCity = cityIndex.hasWithin(x, y, 4); logisticsScore[i] = nearMajorCity ? 0 : flatAgriculturalCorridor; } } @@ -1950,9 +2135,9 @@ const premodernRoads = []; return { ...p, score: (p.population || 1200) / 16000 + remoteness * 3.2 + ruralValue + (p.portClass ? 0.8 : 0) + (p.kind === "Logistics Park" ? 1.0 : 0) + transportFields.localPotential[i] }; }) .sort((a, b) => b.score - a.score) - .slice(0, 130); + .slice(0, 62); const localPenalty = cachedInfluenceFromPaths(minorRoads, 4, "final-local:minor"); - runLocalAccessPass({ candidates, accessInfluence, localPenalty, maxAdded: 90, maxLength: 92, debugMode: "local-access", from: "unserved", to: "network" }); + runLocalAccessPass({ candidates, accessInfluence, localPenalty, maxAdded: 42, maxLength: 78, debugMode: "local-access", from: "unserved", to: "network" }); } function addRuralRoadMeshConnectors() { @@ -1967,14 +2152,14 @@ const premodernRoads = []; }) .filter((p) => p.score > 0.30) .sort((a, b) => b.score - a.score) - .slice(0, 170); + .slice(0, 78); runLocalAccessPass({ candidates, accessInfluence: roadInfluenceNow, localPenalty, - maxAdded: 70, + maxAdded: 32, minSpacing: 3.0, - maxLength: 76, + maxLength: 64, debugMode: "rural-mesh", from: "rural-settlement", to: "local-network", @@ -1990,7 +2175,8 @@ const premodernRoads = []; transportDebugLayers.contactStitchesAfterConnectivity = stitchRasterNearContacts(); // Keep this as the final road topology operation. Later sanitization can cut // the short connectors that intentionally merge isolated components. - transportDebugLayers.finalRoadNetworkConnectivity = connectAllRoadNetworksFinal(96); + transportDebugLayers.finalRoadNetworkConnectivity = connectAllRoadNetworksFinal(46); + markFeatureTiming("local-road-cleanup"); function dedupeTransportPathSet(paths, options = {}) { const before = paths.length; @@ -2143,10 +2329,12 @@ const premodernRoads = []; let added = 0; for (const path of [...expressways, ...externalExpressways]) { if (!path || path.length < 2) continue; - const endpoints = [path[0], path[path.length - 1]]; - for (const [x, y] of endpoints) if (ensureInterchangePoint(x, y)) added++; + const first = path[0]; + const last = path[path.length - 1]; + if (ensureInterchangePoint(first[0], first[1], 'Terminal IC', 'expressway-terminal-endpoint')) added++; + if (ensureInterchangePoint(last[0], last[1], 'Terminal IC', 'expressway-terminal-endpoint')) added++; } - return { added, total: interchanges.length }; + return { added, total: interchanges.length, strategy: "terminal ICs restored" }; } function ensureNationalRoadCoverageForTowns(minPopulation = 5000) { @@ -2167,7 +2355,7 @@ const premodernRoads = []; .filter((row) => row.d >= 10 && row.d <= 90) .sort((a, b) => a.d - b.d); let addedPath = null; - for (const cand of candidates.slice(0, 8)) { + for (const cand of candidates.slice(0, 5)) { const path = routeBetweenTrafficCandidates(town, cand.q, 'national', transportFields.national, nationalPenalty, { curvePenalty: 0.055, penaltyStrength: 0.60, @@ -2192,26 +2380,73 @@ const premodernRoads = []; return debug; } + function expresswayFringeAnchorForCity(city) { + if (!city || !inside(city.x, city.y)) return null; + const inner = Math.max(10, Math.round((city.coreRadius || 4) + 7)); + const outer = Math.max(inner + 8, Math.round(Math.min(34, (city.urbanRadius || 13) * 2.0))); + let best = null; + for (let dy = -outer; dy <= outer; dy++) { + for (let dx = -outer; dx <= outer; dx++) { + const x = Math.round(city.x + dx); + const y = Math.round(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] || elevation[i] >= 0.70 || transportFields.expressway[i] >= INF) continue; + const radialBand = clamp(1 - Math.abs(d - (inner + outer) * 0.54) / Math.max(2, (outer - inner) * 0.55)); + const corePenalty = clamp(settlementDemand[i] * 0.78 + preliminaryTownInfluence[i] * 0.58 + preliminaryVillageInfluence[i] * 0.42); + const score = + (transportFields.expresswayPotential[i] || 0) * 1.18 + + (urbanEdge[i] || 0) * 0.54 + + (logisticsPreSuitability[i] || 0) * 0.40 + + (plain[i] || 0) * 0.20 + + (basinField[i] || 0) * 0.12 + + radialBand * 0.32 - + corePenalty * 1.18 - + (slope[i] || 0) * 1.05 - + (ridgeField[i] || 0) * 0.82 + + hash2(x, y, seed + 24891 + city.x * 11 + city.y * 17) * 0.04; + if (!best || score > best.score) best = { x, y, score, city, population: city.population || 0, regionId: regionIdAt(x, y), role: 'expressway-fringe-anchor' }; + } + } + return best; + } + + function nearPath(path, p, radius) { + return (path || []).some(([x, y]) => Math.hypot(x - p.x, y - p.y) <= radius); + } + function ensureMajorCityExpresswayConnections(minPopulation = 100000) { const majorCities = modernCities .filter((c) => (c.population || 0) >= minPopulation) .sort((a, b) => (b.population || 0) - (a.population || 0)); - const debug = { minPopulation, cityCount: majorCities.length, added: 0, pairs: [] }; + const debug = { minPopulation, cityCount: majorCities.length, added: 0, pairs: [], strategy: 'fringe-anchor-only' }; if (majorCities.length < 2) return debug; + const anchors = majorCities + .map((city) => expresswayFringeAnchorForCity(city)) + .filter(Boolean) + .sort((a, b) => (b.population || 0) - (a.population || 0)); + debug.anchorCount = anchors.length; + if (anchors.length < 2) return debug; + const permissiveExpresswayCost = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) { + if (sea[i] || elevation[i] >= 0.70) { + permissiveExpresswayCost[i] = INF; + continue; + } + const urbanCore = clamp(settlementDemand[i] * 0.78 + preliminaryTownInfluence[i] * 0.62 + preliminaryVillageInfluence[i] * 0.46); const terrainBase = Number.isFinite(transportFields.expressway[i]) && transportFields.expressway[i] < INF ? transportFields.expressway[i] - : 0.28 + Math.max(0, slope[i] - 0.18) * 0.9 + Math.max(0, elevation[i] - 0.58) * 1.6 + ridgeField[i] * 0.45; - permissiveExpresswayCost[i] = sea[i] - ? 0.72 + (naturalBarrierScore?.[i] || 0) * 0.16 - : terrainBase + Math.max(0, elevation[i] - 0.70) * 1.7; + : 0.40 + Math.max(0, slope[i] - 0.18) * 1.25 + Math.max(0, elevation[i] - 0.58) * 2.1 + ridgeField[i] * 0.80; + permissiveExpresswayCost[i] = terrainBase + urbanCore * 2.6 + Math.max(0, elevation[i] - 0.62) * 3.0; } - const componentOfCities = () => { + const componentOfAnchors = () => { const parent = new Map(); - const keyOf = (city) => city.name || `${city.x},${city.y}`; + const keyOf = (anchor) => anchor.city?.name || `${anchor.x},${anchor.y}`; function find(k) { const p = parent.get(k); if (p === k) return k; @@ -2223,74 +2458,70 @@ const premodernRoads = []; const ra = find(a); const rb = find(b); if (ra !== rb) parent.set(ra, rb); } - for (const city of majorCities) parent.set(keyOf(city), keyOf(city)); - const paths = [...expressways, ...externalExpressways]; - for (const path of paths) { - const near = majorCities.filter((city) => path.some(([x, y]) => Math.hypot(city.x - x, city.y - y) <= 7.5)); + for (const anchor of anchors) parent.set(keyOf(anchor), keyOf(anchor)); + for (const path of [...expressways, ...externalExpressways]) { + const near = anchors.filter((anchor) => nearPath(path, anchor, 8.5)); if (near.length >= 2) { const k0 = keyOf(near[0]); for (let i = 1; i < near.length; i++) union(k0, keyOf(near[i])); } } - return new Map(majorCities.map((city) => [keyOf(city), find(keyOf(city))])); + return new Map(anchors.map((anchor) => [keyOf(anchor), find(keyOf(anchor))])); }; - const pairPriority = (a, b) => { - const d = Math.hypot(a.x - b.x, a.y - b.y); - const pop = Math.sqrt((a.population || minPopulation) * (b.population || minPopulation)); - return d / Math.max(1, Math.log2(pop)); - }; - - for (let iter = 0; iter < majorCities.length * 2; iter++) { - const comps = componentOfCities(); + const keyOf = (anchor) => anchor.city?.name || `${anchor.x},${anchor.y}`; + for (let iter = 0; iter < anchors.length * 2; iter++) { + const comps = componentOfAnchors(); const reps = new Set(comps.values()); if (reps.size <= 1) break; let best = null; - for (const a of majorCities) { - for (const b of majorCities) { - if (a === b) continue; - const ka = a.name || `${a.x},${a.y}`; - const kb = b.name || `${b.x},${b.y}`; - if (comps.get(ka) === comps.get(kb)) continue; - const score = pairPriority(a, b); - if (!best || score < best.score) best = { a, b, score, d: Math.hypot(a.x - b.x, a.y - b.y) }; + for (const a of anchors) { + for (const b of anchors) { + if (a === b || comps.get(keyOf(a)) === comps.get(keyOf(b))) continue; + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d < 32 || d > 220) continue; + const pop = Math.sqrt((a.population || minPopulation) * (b.population || minPopulation)); + const score = d / Math.max(1, Math.log2(pop)) - ((a.score || 0) + (b.score || 0)) * 0.18; + if (!best || score < best.score) best = { a, b, score, d }; } } if (!best) break; let path = routeBetweenTrafficCandidates(best.a, best.b, 'expressway', permissiveExpresswayCost, null, { - curvePenalty: 0.045, - penaltyStrength: 0.18, - terrainFlowBias: 0.03, - surfaceGrain: 0.001, - relaxRadius: 2, - relaxLineWeight: 0.15, - maxPathLength: best.d * 3.5 + 110, - snapRadius: 4.0, - searchPad: Math.ceil(Math.max(20, Math.min(96, best.d * 0.55 + 16))), - heuristicWeight: 0.78, - maxSeaRun: 20, - maxTunnelRun: 20, - maxSeaShare: 0.45, - maxTunnelShare: 0.45, + curvePenalty: 0.125, + penaltyStrength: 0.95, + terrainFlowBias: 0.09, + surfaceGrain: 0.006, + relaxRadius: 1, + relaxLineWeight: 0.20, + maxPathLength: best.d * 2.70 + 84, + snapRadius: 2.0, + searchPad: Math.ceil(Math.max(42, Math.min(112, best.d * 0.58 + 12))), + heuristicWeight: 0.72, + maxSeaRun: 0, + maxTunnelRun: 10, + maxSeaShare: 0, + maxTunnelShare: 0.12, }); - if (!path.length) path = directBridgeTunnelConnector(best.a, best.b, 20); - if (path.length >= 2) { - path = smoothRasterPath(path, 2); - expressways.push(path); - debug.added++; - debug.pairs.push({ from: best.a.name, to: best.b.name, distance: Math.round(best.d), length: Math.round(pathLengthCells(path)) }); - } else { - break; + if (path.length >= 4 && transportRouteAcceptable(path, 'expressway', transportFields.expresswayPotential, null, { minLength: 16, maxLength: best.d * 2.85 + 96, maxSeaRun: 0, maxTunnelRun: 10, maxSeaShare: 0, maxTunnelShare: 0.12 })) { + path = smoothRasterPath(path, 1); + if (transportRouteAcceptable(path, 'expressway', transportFields.expresswayPotential, null, { minLength: 16, maxLength: best.d * 2.95 + 104, maxSeaRun: 0, maxTunnelRun: 10, maxSeaShare: 0, maxTunnelShare: 0.12 })) { + expressways.push(path); + debug.added++; + debug.pairs.push({ from: best.a.city?.name, to: best.b.city?.name, distance: Math.round(best.d), length: Math.round(pathLengthCells(path)) }); + continue; + } } + break; } debug.finalExpresswayCount = expressways.length; return debug; } - transportDebugLayers.postConnectivityNationalCoverage = ensureNationalRoadCoverageForTowns(5000); - transportDebugLayers.postConnectivityMajorCityExpresswayGuarantee = ensureMajorCityExpresswayConnections(100000); + transportDebugLayers.postConnectivityNationalCoverage = ensureNationalRoadCoverageForTowns(6000); + transportDebugLayers.postConnectivityMajorCityExpresswayGuarantee = ensureMajorCityExpresswayConnections(110000); transportDebugLayers.postConnectivityExpresswayDedup = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 }); transportDebugLayers.postConnectivityExpresswayEndpointICs = ensureExpresswayEndpointsHaveICs(); + markFeatureTiming("post-connectivity-guarantees"); const { landuse, populationDensity } = buildFeatureLanduse({ seed, elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, @@ -2299,8 +2530,11 @@ const premodernRoads = []; roadLanduseInfluence, roadInfluence, roadDensityInfluence, railInfluence2, stationInfluence, stationDensityInfluence, villageInfluence, cityInfluence, coreInfluence, oldTownInfluence, townInfluence, industrialInfluence, logisticsInfluence, }); + markFeatureTiming("landuse"); const transportDebug = { humanStageVersion: "v2-sparse-raster", + featureTimings, + coarseRouting: coarseRouteStats, aStarRoutes: 0, regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0), fieldCorridorTransport: false, diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js index c7885a5..911b05c 100644 --- a/mapGeneratorHelpers.js +++ b/mapGeneratorHelpers.js @@ -31,6 +31,61 @@ export function distanceToNearest(points, x, y, fallback = 999) { return best; } +export function createPointSpatialIndex(points, cellSize = 12) { + const buckets = new Map(); + const normalized = (points || []) + .filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)) + .map((p) => ({ ...p, x: Math.round(p.x), y: Math.round(p.y) })); + const keyOf = (cx, cy) => `${cx},${cy}`; + for (const p of normalized) { + const cx = Math.floor(p.x / cellSize); + const cy = Math.floor(p.y / cellSize); + const key = keyOf(cx, cy); + let bucket = buckets.get(key); + if (!bucket) { + bucket = []; + buckets.set(key, bucket); + } + bucket.push(p); + } + + function nearestDistanceSq(x, y, maxDistance = Math.max(MAP_W, MAP_H)) { + if (!normalized.length) return maxDistance * maxDistance; + const cx = Math.floor(x / cellSize); + const cy = Math.floor(y / cellSize); + const maxRing = Number.isFinite(maxDistance) ? Math.ceil(maxDistance / cellSize) : Math.ceil(Math.max(MAP_W, MAP_H) / cellSize); + let best = maxDistance * maxDistance; + for (let ring = 0; ring <= maxRing; ring++) { + for (let by = cy - ring; by <= cy + ring; by++) { + for (let bx = cx - ring; bx <= cx + ring; bx++) { + if (ring > 0 && bx > cx - ring && bx < cx + ring && by > cy - ring && by < cy + ring) continue; + const bucket = buckets.get(keyOf(bx, by)); + if (!bucket) continue; + for (const p of bucket) { + const dx = p.x - x; + const dy = p.y - y; + const d2 = dx * dx + dy * dy; + if (d2 < best) best = d2; + } + } + } + } + return best; + } + + return { + points: normalized, + hasWithin(x, y, radius) { + return nearestDistanceSq(x, y, radius) < radius * radius; + }, + distance(x, y, fallback = 999) { + const d2 = nearestDistanceSq(x, y, fallback); + return d2 < fallback * fallback ? Math.sqrt(d2) : fallback; + }, + nearestDistanceSq, + }; +} + export function aStar(start, goal, costAt) { const startIndex = indexOf(start.x, start.y); const goalIndex = indexOf(goal.x, goal.y); @@ -83,15 +138,18 @@ export function aStar(start, goal, costAt) { export function influenceFromPaths(paths, radius) { const grid = new Float32Array(SIZE); + const r = Math.ceil(radius); + const r2 = radius * radius; for (const path of paths) { for (const [x, y] of path) { - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { const nx = x + dx; const ny = y + dy; if (!inside(nx, ny)) continue; - const d = Math.hypot(dx, dy); - if (d > radius) continue; + const d2 = dx * dx + dy * dy; + if (d2 > r2) continue; + const d = Math.sqrt(d2); const i = indexOf(nx, ny); grid[i] = Math.max(grid[i], 1 / (1 + d)); } @@ -239,15 +297,18 @@ export function averagePathField(path, field) { export function influenceFromPoints(points, radius, weightFn = () => 1) { const grid = new Float32Array(SIZE); + const r = Math.ceil(radius); + const r2 = radius * radius; for (const p of points) { const weight = weightFn(p); - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { const nx = p.x + dx; const ny = p.y + dy; if (!inside(nx, ny)) continue; - const d = Math.hypot(dx, dy); - if (d > radius) continue; + const d2 = dx * dx + dy * dy; + if (d2 > r2) continue; + const d = Math.sqrt(d2); const i = indexOf(nx, ny); grid[i] = Math.max(grid[i], weight / (1 + d)); } diff --git a/mapOutput.js b/mapOutput.js index 5cf4f3a..d83ebd3 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -177,8 +177,9 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti } target.isPrefecturalCapital = true; target.isRegionalCapital = true; - target.rank = target.rank || "Regional Capital"; - target.kind = target.kind === "Market Town" || target.kind === "Port Town" || target.kind === "Valley Market Town" ? "Regional Capital" : (target.kind || "Regional Capital"); + target.rank = "Prefectural Capital"; + target.kind = "Prefectural Capital"; + target.labelPriorityBase = Math.max(target.labelPriorityBase || 0, 1150); target.urbanRadius = clamp(6.2 + Math.sqrt(target.population) / 80, 9, 30); target.coreRadius = clamp(2.0 + Math.sqrt(target.population) / 390, 2.4, 6.4); target.sprawlRadius = clamp(target.urbanRadius * 1.34, target.urbanRadius + 2, 38); @@ -210,8 +211,9 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames const prefId = prefectureRegionId[indexOf(city.x, city.y)]; if (prefId < 0) continue; const current = capitalNameByPref.get(prefId); - const score = (city.isPrefecturalCapital ? 2_000_000 : 0) + (city.isRegionalCapital ? 500_000 : 0) + (city.population || 0); - if (!current || score > current.score) capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(city.name), score, x: city.x, y: city.y, population: city.population || 0, source: "city" }); + const tier = city.isPrefecturalCapital || city.rank === "Prefectural Capital" || city.kind === "Prefectural Capital" ? 3 : city.isRegionalCapital || city.rank === "Regional Capital" || city.kind === "Regional Capital" ? 2 : 1; + const score = tier * 50_000_000 + (city.population || 0); + if (!current || score > current.score) capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(city.name), score, x: city.x, y: city.y, population: city.population || 0, source: tier === 3 ? "prefecture-capital" : "city" }); } for (const center of adminCenters || []) { if (!center || !inside(center.x, center.y) || !center.name) continue; @@ -265,7 +267,7 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames x = labelI % MAP_W; y = Math.floor(labelI / MAP_W); } - regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 900 + Math.sqrt(row.area), capitalX: capitalInfo?.x, capitalY: capitalInfo?.y }); + regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 950 + Math.sqrt(row.area), forceLabel: true, capitalX: capitalInfo?.x, capitalY: capitalInfo?.y }); } const named = attachIdsAndNames(regions, "prefecture", seed + 41000, null, fields, usedNames, nameDebug) .map((region, index) => ({ ...region, featureId: region.id, id: regions[index].id, labelName: region.name })); @@ -720,6 +722,24 @@ export function finishMapOutput({ return false; } + function pathNearRequiredExpresswayCity(path) { + if (!path || path.length < 2) return false; + for (const city of modernCities || []) { + if (!city || (city.population || 0) < 100000 || !inside(city.x, city.y)) continue; + const inner = Math.max(7, (city.coreRadius || 4) + 4.5); + const outer = Math.max(inner + 7, (city.urbanRadius || 12) * 2.15); + let inBand = false; + let exits = false; + for (const [x, y] of path) { + const d = Math.hypot(city.x - x, city.y - y); + if (d >= inner && d <= outer) inBand = true; + if (d >= Math.max(22, (city.urbanRadius || 12) * 1.45)) exits = true; + if (inBand && exits) return true; + } + } + return false; + } + function components() { const occ = new Uint8Array(MAP_W * MAP_H); for (const [, paths] of groups) for (const path of paths || []) rasterize(path, (x, y) => { @@ -751,8 +771,165 @@ export function finishMapOutput({ } return out.sort((a, b) => b.size - a.size); } + function buildLandComponentIds() { + const ids = new Int32Array(MAP_W * MAP_H); + ids.fill(-1); + let id = 0; + const q = []; + for (let i = 0; i < ids.length; i++) { + if (ids[i] >= 0 || sea[i]) continue; + ids[i] = id; + q.length = 0; + q.push(i); + for (let h = 0; h < q.length; h++) { + const cur = q[h]; + const [x, y] = xyOf(cur); + 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)) continue; + const ni = indexOf(nx, ny); + if (sea[ni] || ids[ni] >= 0) continue; + ids[ni] = id; + q.push(ni); + } + } + id++; + } + return ids; + } + + function majorityLandId(cells, landIds) { + const counts = new Map(); + for (const i of cells || []) { + const id = landIds[i]; + if (id < 0) continue; + counts.set(id, (counts.get(id) || 0) + 1); + } + let best = -1, bestN = 0; + for (const [id, n] of counts) if (n > bestN) { best = id; bestN = n; } + return best; + } + + function componentNearAdminCenter(comp, radius = 3.2) { + if (!comp?.cells?.length) return false; + const mask = new Uint8Array(MAP_W * MAP_H); + for (const ci of comp.cells) mask[ci] = 1; + const r = Math.ceil(radius); + for (const center of adminCenters || []) { + if (!center || !inside(center.x, center.y)) continue; + const cx = Math.round(center.x), cy = Math.round(center.y); + for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { + if (dx * dx + dy * dy > radius * radius) continue; + const x = cx + dx, y = cy + dy; + if (inside(x, y) && mask[indexOf(x, y)]) return true; + } + } + return false; + } + + function routeIsolatedComponentToMain(comp, mainMask, mainCentroid, landIds, landIdValue) { + if (!comp?.cells?.length || landIdValue < 0) return []; + const dist = new Float64Array(MAP_W * MAP_H); + dist.fill(INF); + const prev = new Int32Array(MAP_W * MAP_H); + prev.fill(-1); + const heap = new MinHeap(); + let seeded = 0; + const stride = Math.max(1, Math.floor(comp.cells.length / 96)); + for (let k = 0; k < comp.cells.length; k += stride) { + const i = comp.cells[k]; + if (sea[i] || landIds[i] !== landIdValue) continue; + dist[i] = 0; + prev[i] = i; + const [x, y] = xyOf(i); + heap.push({ i, f: Math.hypot(x - mainCentroid.x, y - mainCentroid.y) * 0.22 }); + seeded++; + } + if (!seeded) return []; + let goal = -1; + let expanded = 0; + const maxExpanded = 22000; + while (heap.length && expanded < maxExpanded) { + const current = heap.pop(); + if (!current) break; + const cur = current.i; + expanded++; + if (mainMask[cur] && dist[cur] > 2) { goal = cur; break; } + const [x, y] = xyOf(cur); + 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)) continue; + const ni = indexOf(nx, ny); + if (sea[ni] || landIds[ni] !== landIdValue) continue; + const step = Math.hypot(dx, dy); + const terrainCost = 1.0 + (slope?.[ni] || 0) * 1.28 + (ridgeField?.[ni] || 0) * 0.66 + Math.max(0, (elevation?.[ni] || 0) - 0.66) * 1.75 - (valleyField?.[ni] || 0) * 0.42 - (plain?.[ni] || 0) * 0.18 - (coastalLowland?.[ni] || 0) * 0.08; + const nd = dist[cur] + step * Math.max(0.38, terrainCost); + if (nd >= dist[ni]) continue; + dist[ni] = nd; + prev[ni] = cur; + const h = Math.hypot(nx - mainCentroid.x, ny - mainCentroid.y) * 0.22; + heap.push({ i: ni, f: nd + h }); + } + } + if (goal < 0) return []; + const path = []; + let cur = goal; + for (let guard = 0; guard < 240 && cur >= 0; guard++) { + const [x, y] = xyOf(cur); + path.push([x, y]); + if (prev[cur] === cur) break; + cur = prev[cur]; + } + path.reverse(); + if (path.length < 4 || path.length > 150) return []; + return routeQualityAcceptable(path, { sea, elevation, slope, potential: populationDensity, highElevationThreshold: 0.80, steepThreshold: 0.55 }, { + minLength: 4, + maxLength: 150, + maxCompactness: 5.4, + maxHighElevationShare: 0.42, + maxSteepShare: 0.66, + }) ? path : []; + } + + function attemptConnectSameLandmassAdminRoadComponents(comps) { + const result = { attempted: 0, added: 0, skippedIsland: 0, failed: 0 }; + if (!comps || comps.length <= 1) return result; + const landIds = buildLandComponentIds(); + const mainLand = majorityLandId(comps[0].cells, landIds); + const mainMask = new Uint8Array(MAP_W * MAP_H); + let sx = 0, sy = 0, sn = 0; + for (const ci of comps[0].cells) { + const [cx, cy] = xyOf(ci); + sx += cx; sy += cy; sn++; + 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; + } + } + const centroid = { x: sx / Math.max(1, sn), y: sy / Math.max(1, sn) }; + for (const comp of comps.slice(1, 24)) { + if (!componentNearAdminCenter(comp)) continue; + const land = majorityLandId(comp.cells, landIds); + if (land !== mainLand) { result.skippedIsland++; continue; } + result.attempted++; + const path = routeIsolatedComponentToMain(comp, mainMask, centroid, landIds, land); + if (path.length >= 4) { + minorRoads.push(path); + result.added++; + } else { + result.failed++; + } + } + return result; + } + let comps = components(); const before = comps.length; + const mountainConnect = attemptConnectSameLandmassAdminRoadComponents(comps); + if (mountainConnect.added > 0) comps = components(); const pruned = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 }; for (let pass = 0; pass < 4 && comps.length > 1; pass++) { const mainMask = new Uint8Array(MAP_W * MAP_H); @@ -776,7 +953,7 @@ export function finishMapOutput({ for (const [key, paths] of groups) { const kept = []; for (const path of paths || []) { - if (touchesMain(path) || pathNearAdminCenter(path)) kept.push(path); + if (touchesMain(path) || pathNearAdminCenter(path) || ((key === "expressway" || key === "externalExpressway") && pathNearRequiredExpresswayCity(path))) kept.push(path); else pruned[key]++; } paths.length = 0; @@ -784,7 +961,7 @@ export function finishMapOutput({ } comps = components(); } - debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned }; + debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned, mountainAdminConnections: mountainConnect }; } pruneIsolatedFinalRoadComponents(); @@ -815,6 +992,178 @@ export function finishMapOutput({ } ensureAdminCenterCellsAfterOutputPrune(); + function connectNearbyRoadEndpoints() { + const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = {})) : {}; + const ordinaryGroups = [ + { key: "minor", paths: minorRoads || [] }, + { key: "national", paths: nationalRoads || [] }, + { key: "external", paths: externalRoads || [] }, + { key: "ring", paths: ringRoads || [] }, + ]; + const occ = new Uint8Array(MAP_W * MAP_H); + function rasterize(path, fn) { + for (let k = 1; k < (path?.length || 0); k++) { + const [x0, y0] = path[k - 1]; + const [x1, y1] = path[k]; + 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)]) fn(x, y); + } + } + } + for (const group of ordinaryGroups) for (const path of group.paths || []) rasterize(path, (x, y) => { occ[indexOf(x, y)] = 1; }); + const comp = new Int32Array(MAP_W * MAP_H); + comp.fill(-1); + let compId = 0; + const queue = []; + for (let i = 0; i < occ.length; i++) { + if (!occ[i] || comp[i] >= 0) continue; + comp[i] = compId; + queue.length = 0; + queue.push(i); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const [x, y] = xyOf(cur); + 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)) continue; + const ni = indexOf(nx, ny); + if (!occ[ni] || comp[ni] >= 0) continue; + comp[ni] = compId; + queue.push(ni); + } + } + compId++; + } + function endpointComponent(x, y) { + if (!inside(x, y) || sea[indexOf(x, y)]) return -1; + const here = comp[indexOf(x, y)]; + if (here >= 0) return here; + for (let r = 1; r <= 2; r++) { + for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny) || sea[indexOf(nx, ny)]) continue; + const id = comp[indexOf(nx, ny)]; + if (id >= 0) return id; + } + } + return -1; + } + function directConnector(a, b) { + const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y))); + 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) || sea[indexOf(x, y)]) return []; + if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]); + } + return path.length >= 2 ? path : []; + } + const endpoints = []; + for (const group of ordinaryGroups) { + for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) { + const path = group.paths[pathIdx]; + if (!path || path.length < 2) continue; + for (const end of [0, 1]) { + const raw = end === 0 ? path[0] : path[path.length - 1]; + const x = Math.round(raw[0]), y = Math.round(raw[1]); + if (!inside(x, y) || sea[indexOf(x, y)]) continue; + const ci = indexOf(x, y); + const ruralBias = Math.max(0, 0.42 - (populationDensity?.[ci] || 0)); + endpoints.push({ group: group.key, pathIdx, end, x, y, comp: endpointComponent(x, y), ruralBias }); + } + } + } + const pairs = []; + for (let i = 0; i < endpoints.length; i++) { + const a = endpoints[i]; + if (a.comp < 0) continue; + for (let j = i + 1; j < endpoints.length; j++) { + const b = endpoints[j]; + if (b.comp < 0 || a.comp === b.comp) continue; + if (a.group === b.group && a.pathIdx === b.pathIdx) continue; + const d = Math.hypot(a.x - b.x, a.y - b.y); + const limit = (a.ruralBias + b.ruralBias) > 0.38 ? 6.5 : 4.4; + if (d < 1.1 || d > limit) continue; + const path = directConnector(a, b); + if (path.length < 2 || path.length > 9) continue; + pairs.push({ a, b, d, path, score: d - (a.ruralBias + b.ruralBias) * 1.25 + (a.group === "minor" && b.group === "minor" ? 0.25 : 0) }); + } + } + pairs.sort((a, b) => a.score - b.score || a.d - b.d); + const used = new Set(); + let added = 0; + for (const pair of pairs) { + if (added >= 180) break; + const ak = `${pair.a.group}:${pair.a.pathIdx}:${pair.a.end}`; + const bk = `${pair.b.group}:${pair.b.pathIdx}:${pair.b.end}`; + if (used.has(ak) || used.has(bk)) continue; + minorRoads.push(pair.path); + used.add(ak); used.add(bk); + added++; + } + debugLayers.nearbyRoadEndpointConnectorsAdded = added; + return added; + } + + connectNearbyRoadEndpoints(); + + function renameInterchangesFromMunicipalities() { + if (!interchanges?.length || !adminCenters?.length || !adminId) return 0; + const centerByAdmin = new Map(); + for (const center of adminCenters || []) { + if (!center || !inside(center.x, center.y)) continue; + const id = adminId[indexOf(center.x, center.y)]; + if (id >= 0 && !centerByAdmin.has(id)) centerByAdmin.set(id, center); + } + const allCenters = [...centerByAdmin.values()].filter((c) => c?.name); + const used = new Set(); + const directionNames = ["北", "東", "南", "西", "中央", "上", "下", "新"]; + let renamed = 0; + function cleanBase(name) { + return String(name || "").replace(/[ICインターチェンジ\s]+$/u, "").replace(/[市町村区]$/u, ""); + } + for (const [idx, ic] of interchanges.entries()) { + if (!ic || !inside(ic.x, ic.y)) continue; + const cell = indexOf(ic.x, ic.y); + const admin = adminId[cell]; + const primary = centerByAdmin.get(admin) || allCenters.slice().sort((a, b) => Math.hypot(a.x - ic.x, a.y - ic.y) - Math.hypot(b.x - ic.x, b.y - ic.y))[0]; + const nearbyCenters = allCenters + .slice() + .sort((a, b) => Math.hypot(a.x - ic.x, a.y - ic.y) - Math.hypot(b.x - ic.x, b.y - ic.y)); + const candidates = []; + if (primary?.name) candidates.push(`${cleanBase(primary.municipalityName || primary.name)}IC`); + for (const center of nearbyCenters.slice(0, 12)) { + const base = cleanBase(center.municipalityName || center.name); + if (base) candidates.push(`${base}IC`); + } + if (primary?.name) { + const base = cleanBase(primary.municipalityName || primary.name); + for (const dir of directionNames) candidates.push(`${base}${dir}IC`); + } + candidates.push(`自治${idx + 1}IC`); + let name = candidates.find((candidate) => candidate && !used.has(candidate)); + if (!name) name = `自治${idx + 1}IC`; + ic.name = name; + ic.labelName = name; + ic.municipalityNameBased = true; + used.add(name); + renamed++; + } + if (transportDebug) { + transportDebug.layers ||= {}; + transportDebug.layers.municipalityBasedInterchangeNames = renamed; + } + return renamed; + } + renameInterchangesFromMunicipalities(); + nameDebug.maxDerivedPerBase = 0; const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters }); const regionalPrefectureBorders = adminRegionalPrefectureBorders || []; diff --git a/mapPostAdminTransport.js b/mapPostAdminTransport.js index 67d1166..070947a 100644 --- a/mapPostAdminTransport.js +++ b/mapPostAdminTransport.js @@ -1,4 +1,4 @@ -import { MAP_W, MAP_H, SIZE, indexOf, inside } from "./mapUtils.js"; +import { INF, MAP_W, MAP_H, SIZE, MinHeap, indexOf, inside, xyOf } from "./mapUtils.js"; import { pathLengthCells } from "./mapTransport.js"; function cellKey(x, y) { return `${Math.round(x)},${Math.round(y)}`; } @@ -19,15 +19,38 @@ function pathTerrainRuns(path, terrain = null) { const ridgeField = terrain?.ridgeField; const naturalBarrierScore = terrain?.naturalBarrierScore; let seaRun = 0, maxSeaRun = 0, tunnelRun = 0, maxTunnelRun = 0; - for (const [x, y] of path || []) { - if (!inside(x, y)) continue; + let sampled = 0; + const visit = (x, y) => { + if (!inside(x, y)) { + seaRun++; + maxSeaRun = Math.max(maxSeaRun, seaRun); + tunnelRun = 0; + sampled++; + return; + } const i = indexOf(x, y); const isSea = Boolean(sea?.[i]); - const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.74 && (ridgeField?.[i] || 0) >= 0.46) || (naturalBarrierScore?.[i] || 0) >= 0.82); + // Use the same sensitive tunnel proxy as the main transport validator. + // Sampling every raster cell along each segment prevents smoothed or direct + // paths from hiding over-limit tunnel runs between sparse vertices. + const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.72 && (ridgeField?.[i] || 0) >= 0.34) || (naturalBarrierScore?.[i] || 0) >= 0.72); if (isSea) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); } else seaRun = 0; if (isTunnel) { tunnelRun++; maxTunnelRun = Math.max(maxTunnelRun, tunnelRun); } else tunnelRun = 0; + sampled++; + }; + for (let k = 1; k < (path?.length || 0); k++) { + const a = path[k - 1]; + const b = path[k]; + if (!a || !b) continue; + 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 > 1 && s === 0) continue; + const t = s / steps; + visit(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t)); + } } - return { maxSeaRun, maxTunnelRun }; + if ((path?.length || 0) === 1) visit(path[0][0], path[0][1]); + return { maxSeaRun, maxTunnelRun, sampled }; } function directPath(a, b, options = {}) { @@ -50,6 +73,70 @@ function directPath(a, b, options = {}) { return out; } +function routeTerrainPath(a, b, terrain = null, options = {}) { + if (!a || !b || !inside(a.x, a.y) || !inside(b.x, b.y)) return []; + const sea = terrain?.sea; + const elevation = terrain?.elevation; + const slope = terrain?.slope; + const ridgeField = terrain?.ridgeField; + const start = indexOf(Math.round(a.x), Math.round(a.y)); + const goal = indexOf(Math.round(b.x), Math.round(b.y)); + if (sea?.[start] || sea?.[goal]) return []; + const straight = Math.hypot(a.x - b.x, a.y - b.y); + const maxLength = options.maxLength ?? straight * 2.8 + 60; + const maxExpanded = Math.min(SIZE, options.maxExpanded ?? Math.max(9000, Math.floor(straight * straight * 5.5))); + const dist = new Float64Array(SIZE); + dist.fill(INF); + const prev = new Int32Array(SIZE); + prev.fill(-1); + const closed = new Uint8Array(SIZE); + const heap = new MinHeap(); + dist[start] = 0; + prev[start] = start; + heap.push({ i: start, f: straight * 0.42 }); + let hit = -1; + let expanded = 0; + while (heap.length && expanded++ < maxExpanded) { + const current = heap.pop(); + if (!current || closed[current.i]) continue; + const cur = current.i; + closed[cur] = 1; + const [x, y] = xyOf(cur); + if (Math.hypot(x - b.x, y - b.y) <= (options.snapRadius ?? 2.0)) { hit = cur; break; } + if (Math.hypot(x - a.x, y - a.y) > maxLength) continue; + 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)) continue; + const ni = indexOf(nx, ny); + if (closed[ni] || sea?.[ni]) continue; + const step = Math.hypot(dx, dy); + const terrainCost = 1.0 + (slope?.[ni] || 0) * 1.55 + (ridgeField?.[ni] || 0) * 0.82 + Math.max(0, (elevation?.[ni] || 0) - 0.66) * 2.05; + const nd = dist[cur] + step * Math.max(0.42, terrainCost); + if (nd >= dist[ni]) continue; + dist[ni] = nd; + prev[ni] = cur; + const h = Math.hypot(nx - b.x, ny - b.y) * 0.42; + heap.push({ i: ni, f: nd + h }); + } + } + if (hit < 0) return []; + const path = []; + let cur = hit; + for (let guard = 0; guard < Math.max(80, maxLength * 3) && cur >= 0; guard++) { + const [x, y] = xyOf(cur); + path.push([x, y]); + if (prev[cur] === cur) break; + cur = prev[cur]; + } + path.reverse(); + if (path.length < 2 || pathLengthCells(path) > maxLength) return []; + const runs = pathTerrainRuns(path, terrain); + if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) return []; + if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) return []; + return path; +} + function nearestPointOnPaths(paths, p, maxDistance = Infinity) { let best = null; for (const path of paths || []) { @@ -194,6 +281,44 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge function townWeight(p) { return (p.population || 0) + (p.isPrefecturalCapital ? 800000 : 0) + (p.isRegionalCapital ? 350000 : 0) + (p.portClass === "major" ? 220000 : p.portClass === "regional" ? 120000 : p.portClass === "fishing" ? 45000 : 0); } + + function relayGeometryAcceptable(points, options = {}) { + const pts = (points || []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)); + if (pts.length < 3) return true; + const first = pts[0]; + const last = pts[pts.length - 1]; + const vx = last.x - first.x; + const vy = last.y - first.y; + const direct = Math.hypot(vx, vy); + if (direct < 0.001) return false; + let via = 0; + for (let i = 1; i < pts.length; i++) via += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); + const maxDetour = options.maxDetour ?? 1.68; + if (via > direct * maxDetour + (options.detourSlack ?? 16)) return false; + const maxOffset = Math.max(options.minOffset ?? 14, Math.min(options.maxOffset ?? 30, direct * (options.offsetRatio ?? 0.32))); + for (let i = 1; i < pts.length - 1; i++) { + const p = pts[i]; + const wx = p.x - first.x; + const wy = p.y - first.y; + const t = (wx * vx + wy * vy) / Math.max(0.0001, direct * direct); + const projX = first.x + vx * t; + const projY = first.y + vy * t; + const offset = Math.hypot(p.x - projX, p.y - projY); + if (t < (options.minProjection ?? -0.10) || t > (options.maxProjection ?? 1.10)) return false; + if (offset > maxOffset) return false; + } + return true; + } + + function pathGeometryAcceptable(path, options = {}) { + if (!path || path.length < 3) return true; + const step = Math.max(1, Math.floor(path.length / 10)); + const pts = []; + for (let k = 0; k < path.length; k += step) pts.push({ x: path[k][0], y: path[k][1] }); + const last = path[path.length - 1]; + pts.push({ x: last[0], y: last[1] }); + return relayGeometryAcceptable(pts, options); + } function nearestTrunkOrHub(p, maxDistance = 85) { const trunk = nearestPointOnPaths([...nationalRoads, ...externalRoads], p, maxDistance); if (trunk) return trunk; @@ -225,20 +350,31 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge let townsCovered = 0; while (uncovered.length) { const start = uncovered.shift(); - const chain = buildTownChain(start, uncovered, 7); + let chain = buildTownChain(start, uncovered, 7); uncovered = uncovered.filter((town) => !chain.includes(town)); const parts = []; - const before = nearestTrunkOrHub(chain[0], 80); + let before = nearestTrunkOrHub(chain[0], 80); + let after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null; + const relayPoints = [before || chain[0], ...chain, after || chain[chain.length - 1]]; + if (!relayGeometryAcceptable(relayPoints, { maxDetour: 1.62, minOffset: 12, maxOffset: 26, offsetRatio: 0.30 })) { + // The town-chain pass is a coverage fallback, not a mandate to drag a + // road through a remote off-axis waypoint. Collapse to a single spur + // when the waypoint chain would create a hooked or S-shaped route. + chain = [chain[0]]; + before = nearestTrunkOrHub(chain[0], 80); + after = null; + } if (before) { const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); } for (let i = 1; i < chain.length; i++) { const d = Math.hypot(chain[i - 1].x - chain[i].x, chain[i - 1].y - chain[i].y); + const segmentPoints = [chain[i - 1], chain[i]]; + if (!relayGeometryAcceptable(segmentPoints, { maxDetour: 1.25, minOffset: 10, maxOffset: 18 })) continue; const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); } - const after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null; if (after) { const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); @@ -248,7 +384,7 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge const target = nearestTrunkOrHub(chain[0], 90); path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 10 }) : []; } - if (path.length >= 2 && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) { + if (path.length >= 2 && pathGeometryAcceptable(path, { maxDetour: 1.78, minOffset: 14, maxOffset: 32, offsetRatio: 0.34 }) && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) { nationalRoads.push(path); chainsAdded++; townsCovered += chain.filter((town) => pathTouchesCell(path, town.x, town.y, 0.65)).length; @@ -286,30 +422,123 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge return new Map(cities.map((city) => [majorCityKey(city), find(majorCityKey(city))])); } + function suburbanExpresswayAnchorForCity(city, target = null) { + if (!city || !inside(city.x, city.y)) return null; + const sea = terrain?.sea; + const elevation = terrain?.elevation; + const slope = terrain?.slope; + const ridgeField = terrain?.ridgeField; + const inner = Math.max(8, Math.round((city.coreRadius || 4) + 6)); + const outer = Math.max(inner + 7, Math.round((city.urbanRadius || 12) * 1.9)); + 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]) continue; + const radial = Math.abs(d - (inner + outer) * 0.52); + const targetBias = target ? Math.hypot(x - target.x, y - target.y) * 0.038 : 0; + const score = -radial * 0.26 - targetBias - (slope?.[i] || 0) * 0.70 - (ridgeField?.[i] || 0) * 0.55 - Math.max(0, (elevation?.[i] || 0) - 0.70) * 0.75; + if (!best || score > best.score) best = { x, y, score }; + } + } + return best; + } + + function suburbanExpresswayStubForCity(city, preferredAnchor = null) { + if (!city || !inside(city.x, city.y)) return []; + const angles = []; + if (preferredAnchor) angles.push(Math.atan2(preferredAnchor.y - city.y, preferredAnchor.x - city.x)); + for (let k = 0; k < 8; k++) angles.push((Math.PI * 2 * k) / 8 + (k % 2 ? 0.18 : 0)); + const seenAngles = new Set(); + for (const angle of angles) { + const bucket = Math.round(angle * 100) / 100; + if (seenAngles.has(bucket)) continue; + seenAngles.add(bucket); + const hint = { x: Math.round(city.x + Math.cos(angle) * 120), y: Math.round(city.y + Math.sin(angle) * 120) }; + const anchor = suburbanExpresswayAnchorForCity(city, hint); + if (!anchor) continue; + const minD = Math.max(20, (city.urbanRadius || 12) * 1.35); + const maxD = Math.max(minD + 10, (city.urbanRadius || 12) * 2.65); + let bestEnd = null; + for (let d = minD; d <= maxD; d += 2) { + const x = Math.round(city.x + Math.cos(angle) * d); + const y = Math.round(city.y + Math.sin(angle) * d); + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (terrain?.sea?.[i]) continue; + bestEnd = { x, y }; + } + if (!bestEnd || Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y) < 8) continue; + const d = Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y); + let path = directPath(anchor, bestEnd, { maxLength: d * 1.8 + 12, terrain, maxSeaRun: 0, maxTunnelRun: 10 }); + if (!path.length) path = routeTerrainPath(anchor, bestEnd, terrain, { maxLength: d * 2.6 + 20, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.6 }); + if (path.length >= 4) return path; + } + return []; + } + + function expresswayServesCityFringe(city) { + const inner = Math.max(7.0, (city.coreRadius || 4) + 4.5); + const outer = Math.max(inner + 7, (city.urbanRadius || 12) * 2.0); + for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) { + let inBand = false; + let exits = false; + for (const [x, y] of path || []) { + const d = Math.hypot(x - city.x, y - city.y); + if (d >= inner && d <= outer) inBand = true; + if (d >= Math.max(24, (city.urbanRadius || 12) * 1.55)) exits = true; + if (inBand && exits) return true; + } + } + return false; + } + function ensureMajorCityExpresswayLinks(minPopulation = 100000) { const cities = (features.modernCities || []) .filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y)) .sort((a, b) => (b.population || 0) - (a.population || 0)); - const result = { minPopulation, checked: cities.length, added: 0 }; - if (cities.length < 2) return result; - for (let iter = 0; iter < cities.length * 2; iter++) { - const comps = expresswayCityComponents(cities); - if (new Set(comps.values()).size <= 1) break; - let best = null; - for (const a of cities) { - for (const b of cities) { - if (a === b || comps.get(majorCityKey(a)) === comps.get(majorCityKey(b))) continue; - const d = Math.hypot(a.x - b.x, a.y - b.y); - const score = d / Math.max(1, Math.log2(Math.sqrt((a.population || 1) * (b.population || 1)))); - if (!best || score < best.score) best = { a, b, d, score }; + const result = { minPopulation, checked: cities.length, covered: 0, added: 0, noTarget: 0, noPath: 0 }; + features.expressways ||= []; + if (!cities.length) return result; + for (const city of cities) { + if (expresswayServesCityFringe(city)) { result.covered++; continue; } + const existing = [...(features.expressways || []), ...(features.externalExpressways || [])]; + let target = nearestPointOnPaths(existing, city, 145); + if (!target) { + const other = cities.find((c) => c !== city && expresswayServesCityFringe(c)); + target = other ? suburbanExpresswayAnchorForCity(other, city) : null; + } + if (!target) { result.noTarget++; continue; } + const anchor = suburbanExpresswayAnchorForCity(city, target); + if (!anchor) { result.noTarget++; continue; } + const d = Math.hypot(anchor.x - target.x, anchor.y - target.y); + if (d < 4) { result.covered++; continue; } + let path = directPath(anchor, target, { maxLength: d * 1.35 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 }); + if (!path.length) path = directPath(anchor, target, { maxLength: d * 1.75 + 34, terrain, maxSeaRun: 20, maxTunnelRun: 10 }); + if (!path.length) path = routeTerrainPath(anchor, target, terrain, { maxLength: d * 2.9 + 64, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 }); + if (!path.length) { + const cityTargets = cities + .filter((other) => other !== city) + .map((other) => { + const otherAnchor = suburbanExpresswayAnchorForCity(other, anchor); + return otherAnchor ? { other, otherAnchor, d: Math.hypot(otherAnchor.x - anchor.x, otherAnchor.y - anchor.y) } : null; + }) + .filter(Boolean) + .filter((row) => row.d >= 16 && row.d <= 185) + .sort((a, b) => a.d - b.d); + for (const row of cityTargets.slice(0, 6)) { + let candidate = directPath(anchor, row.otherAnchor, { maxLength: row.d * 1.6 + 26, terrain, maxSeaRun: 20, maxTunnelRun: 10 }); + if (!candidate.length) candidate = routeTerrainPath(anchor, row.otherAnchor, terrain, { maxLength: row.d * 2.9 + 70, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 }); + if (candidate.length >= 4) { path = candidate; break; } } } - if (!best) break; - let path = directPath(best.a, best.b, { maxLength: best.d * 1.18 + 12, terrain, maxSeaRun: 20 }); - if (!path.length) path = directPath(best.a, best.b, { maxLength: best.d * 1.38 + 28, terrain }); - if (!path.length) break; - path = smoothPath(path, 2); - expressways.push(path); + if (!path.length) path = suburbanExpresswayStubForCity(city, anchor); + if (!path.length || pathLengthCells(path) < 4) { result.noPath++; continue; } + features.expressways.push(smoothPath(path, 1)); result.added++; } return result; @@ -341,7 +570,7 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge const a = chain[i - 1], b = chain[i]; const d = Math.hypot(a.x - b.x, a.y - b.y); let p = directPath(a, b, { maxLength: d * 1.55 + 20, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); - if (!p.length) p = directPath(a, b, { maxLength: d * 1.85 + 40, terrain }); + if (!p.length) p = directPath(a, b, { maxLength: d * 1.85 + 40, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); } const path = concatPaths(parts); @@ -360,22 +589,185 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge const expressDebug = ensureMajorCityExpresswayLinks(100000); debug.expresswayMajorCityLinksAdded = expressDebug.added; + debug.expresswayMajorCityLinksCovered = expressDebug.covered; + debug.expresswayMajorCityLinksNoTarget = expressDebug.noTarget; + debug.expresswayMajorCityLinksNoPath = expressDebug.noPath; // Expressway finalization after administration: smooth and ensure both endpoints are ICs. for (let i = 0; i < expressways.length; i++) { const smoothed = smoothPath(expressways[i], 2); if (smoothed.length >= 2) { - expressways[i] = smoothed; - debug.expresswaysSmoothed++; + const runs = pathTerrainRuns(smoothed, terrain); + if (runs.maxTunnelRun <= 10 && runs.maxSeaRun <= 20) { + expressways[i] = smoothed; + debug.expresswaysSmoothed++; + } } } - for (const path of [...expressways, ...externalExpressways]) { - if (!path || path.length < 2) continue; - const a = path[0]; - const b = path[path.length - 1]; - if (addInterchange(interchanges, a[0], a[1])) debug.expresswayEndpointInterchangesAdded++; - if (addInterchange(interchanges, b[0], b[1])) debug.expresswayEndpointInterchangesAdded++; + const expresswayBeforeTerrainPrune = expressways.length; + for (let i = expressways.length - 1; i >= 0; i--) { + const runs = pathTerrainRuns(expressways[i], terrain); + if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) expressways.splice(i, 1); } + debug.expresswaysPrunedForBridgeTunnelLimits = expresswayBeforeTerrainPrune - expressways.length; + + function pointInsideCityNodeBuffer(x, y) { + for (const city of features.modernCities || []) { + if (!city || (city.population || 0) < 25000) continue; + const r = Math.max(4.2, (city.coreRadius || 3) + 1.6); + if (Math.hypot(x - city.x, y - city.y) <= r) return true; + } + return false; + } + function splitExpresswayAwayFromCityNodes(path) { + const chunks = []; + let cur = []; + for (const [x, y] of path || []) { + if (pointInsideCityNodeBuffer(x, y)) { + if (cur.length >= 2) chunks.push(cur); + cur = []; + continue; + } + if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.push([x, y]); + } + if (cur.length >= 2) chunks.push(cur); + return chunks.filter((chunk) => pathLengthCells(chunk) >= 12); + } + const expresswayBeforeCityNodePrune = expressways.length; + const separatedExpressways = []; + for (const path of expressways) separatedExpressways.push(...splitExpresswayAwayFromCityNodes(path)); + expressways.length = 0; + expressways.push(...dedupePaths(separatedExpressways, 2)); + debug.expresswaysPrunedForCityNodeSeparation = expresswayBeforeCityNodePrune - expressways.length; + + function connectNearbyExpresswayTermini() { + const result = { candidates: 0, added: 0, failed: 0 }; + const expressGroups = [ + { key: "expressway", paths: expressways }, + { key: "externalExpressway", paths: externalExpressways }, + ]; + const endpoints = []; + for (const group of expressGroups) { + for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) { + const path = group.paths[pathIdx]; + if (!path || path.length < 2) continue; + for (const end of [0, 1]) { + const raw = end === 0 ? path[0] : path[path.length - 1]; + const x = Math.round(raw[0]), y = Math.round(raw[1]); + if (!inside(x, y) || terrain?.sea?.[indexOf(x, y)] || pointInsideCityNodeBuffer(x, y)) continue; + endpoints.push({ group: group.key, pathIdx, end, x, y }); + } + } + } + const pairs = []; + for (let i = 0; i < endpoints.length; i++) { + const a = endpoints[i]; + for (let j = i + 1; j < endpoints.length; j++) { + const b = endpoints[j]; + if (a.group === b.group && a.pathIdx === b.pathIdx) continue; + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d < 3.0 || d > 24.0) continue; + pairs.push({ a, b, d, kind: "terminus-terminus" }); + } + } + // Also snap a dead-end to the side of a nearby expressway if no terminal is + // close enough. This removes visible half-built expressway stubs without + // requiring every segment to be merged into a single polyline. + for (const a of endpoints) { + let best = null; + for (const group of expressGroups) { + for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) { + if (a.group === group.key && a.pathIdx === pathIdx) continue; + const path = group.paths[pathIdx]; + for (let k = 1; k < (path?.length || 0) - 1; k += 2) { + const [x, y] = path[k]; + const d = Math.hypot(a.x - x, a.y - y); + if (d < 3.0 || d > 14.0) continue; + if (!best || d < best.d) best = { a, b: { group: group.key, pathIdx, end: -1, x, y }, d, kind: "terminus-side" }; + } + } + } + if (best) pairs.push(best); + } + pairs.sort((a, b) => a.d - b.d || (a.kind === "terminus-terminus" ? -1 : 1)); + const used = new Set(); + for (const pair of pairs) { + if (result.added >= 10) break; + const ak = `${pair.a.group}:${pair.a.pathIdx}:${pair.a.end}`; + const bk = `${pair.b.group}:${pair.b.pathIdx}:${pair.b.end}`; + if (used.has(ak) || (pair.b.end >= 0 && used.has(bk))) continue; + result.candidates++; + let path = directPath(pair.a, pair.b, { maxLength: pair.d * 1.65 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 }); + if (!path.length) path = routeTerrainPath(pair.a, pair.b, terrain, { maxLength: pair.d * 2.6 + 44, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.8 }); + if (!path.length || pathLengthCells(path) < 3) { result.failed++; continue; } + const runs = pathTerrainRuns(path, terrain); + if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) { result.failed++; continue; } + expressways.push(smoothPath(path, 1)); + used.add(ak); + if (pair.b.end >= 0) used.add(bk); + result.added++; + } + return result; + } + + const expresswayTerminusConnectDebug = connectNearbyExpresswayTermini(); + debug.expresswayTerminusConnectionsAdded = expresswayTerminusConnectDebug.added; + debug.expresswayTerminusConnectionCandidates = expresswayTerminusConnectDebug.candidates; + debug.expresswayTerminusConnectionFailures = expresswayTerminusConnectDebug.failed; + + function pointOnExpressway(p, radius = 1.5) { + return (expressways || []).some((path) => pathTouchesCell(path, p.x, p.y, radius)); + } + const icBeforePrune = interchanges.length; + const pairedInterchanges = []; + const pairedAccessRoads = []; + for (let i = 0; i < interchanges.length; i++) { + const ic = interchanges[i]; + const access = (features.icAccessRoads || [])[i]; + if (ic && pointOnExpressway(ic, 1.8) && access && access.length >= 2) { + pairedInterchanges.push(ic); + pairedAccessRoads.push(access); + } + } + interchanges.length = 0; + interchanges.push(...pairedInterchanges); + features.icAccessRoads = pairedAccessRoads; + debug.interchangesPrunedWithoutExpresswayOrAccess = icBeforePrune - interchanges.length; + + function ensureTerminalInterchangesWithAccess() { + const result = { endpointsChecked: 0, added: 0, accessAdded: 0, withoutAccess: 0 }; + features.icAccessRoads ||= []; + const ordinaryRoads = [...(features.nationalRoads || []), ...(features.externalRoads || []), ...(features.minorRoads || [])]; + for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) { + if (!path || path.length < 2) continue; + for (const raw of [path[0], path[path.length - 1]]) { + const p = { x: Math.round(raw[0]), y: Math.round(raw[1]) }; + result.endpointsChecked++; + if (!inside(p.x, p.y) || terrain?.sea?.[indexOf(p.x, p.y)]) continue; + if ((interchanges || []).some((ic) => Math.hypot(ic.x - p.x, ic.y - p.y) <= 2.8)) continue; + const hit = nearestPointOnPaths(ordinaryRoads, p, 58); + let access = []; + if (hit) { + const d = Math.hypot(p.x - hit.x, p.y - hit.y); + access = directPath(p, hit, { maxLength: d * 1.75 + 18, terrain, maxSeaRun: 0, maxTunnelRun: 8 }); + if (access.length >= 2) { + features.icAccessRoads.push(access); + features.minorRoads ||= []; + features.minorRoads.push(access); + result.accessAdded++; + } + } + addInterchange(interchanges, p.x, p.y, access.length >= 2 ? "post-admin-terminal-ic" : "post-admin-terminal-ic-no-access"); + result.added++; + if (access.length < 2) result.withoutAccess++; + } + } + return result; + } + const terminalIcDebug = ensureTerminalInterchangesWithAccess(); + debug.expresswayTerminalInterchangesAdded = terminalIcDebug.added; + debug.expresswayTerminalInterchangeAccessAdded = terminalIcDebug.accessAdded; + debug.expresswayTerminalInterchangesWithoutAccess = terminalIcDebug.withoutAccess; features.minorRoads = dedupePaths(minorRoads, 2); features.nationalRoads = dedupePaths(nationalRoads, 1); diff --git a/mapPrefectureStage.js b/mapPrefectureStage.js index 5a14081..986e887 100644 --- a/mapPrefectureStage.js +++ b/mapPrefectureStage.js @@ -748,6 +748,69 @@ export function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCo return changed; } + +export function relaxHighBoundaryShareMunicipalities(nodes, owner, options = {}) { + const threshold = options.threshold ?? 0.50; + const maxPasses = options.maxPasses ?? 5; + const minSharedToTarget = options.minSharedToTarget ?? 2; + let changed = 0; + for (let pass = 0; pass < maxPasses; pass++) { + let passChanged = 0; + const counts = prefectureMunicipalityCounts(owner); + const candidates = []; + for (const [id, pref] of owner) { + if (pref === undefined || pref < 0) continue; + const node = nodes.get(id); + if (!node || !node.adjacent?.size) continue; + if ((node.cityPopulation || 0) >= 180000 || (node.majorCityCount || 0) > 0) continue; + let totalBoundary = 0; + let sameBoundary = 0; + const byPref = new Map(); + for (const [nextId, edge] of node.adjacent) { + const nPref = owner.get(nextId); + if (nPref === undefined || nPref < 0) continue; + const w = Math.max(1, edge.count || 1); + totalBoundary += w; + if (nPref === pref) sameBoundary += w; + else { + const row = byPref.get(nPref) || { pref: nPref, shared: 0, score: 0, minCrossing: INF }; + row.shared += w; + row.score += w * 2.8 - (edge.crossingCost ?? (1 + (edge.barrier || 0) * 8.0)) * 0.55; + row.minCrossing = Math.min(row.minCrossing, edge.crossingCost ?? 1); + byPref.set(nPref, row); + } + } + if (totalBoundary <= 0) continue; + const borderBoundary = totalBoundary - sameBoundary; + const borderShare = borderBoundary / totalBoundary; + if (borderShare < threshold || !byPref.size) continue; + if ((counts.get(pref) || 0) <= Math.max(5, options.minSourceCount ?? 8)) continue; + if (!wouldRemainConnectedAfterRemoval(nodes, owner, id, pref)) continue; + const best = [...byPref.values()] + .filter((row) => row.shared >= minSharedToTarget) + .sort((a, b) => b.score - a.score || b.shared - a.shared || a.pref - b.pref)[0]; + if (!best) continue; + const compactnessGain = best.shared - sameBoundary * 0.72 + borderShare * 6.0; + if (compactnessGain < 1.2 && best.score < 1.0) continue; + candidates.push({ id, from: pref, to: best.pref, borderShare, score: compactnessGain + best.score * 0.08 }); + } + candidates.sort((a, b) => b.borderShare - a.borderShare || b.score - a.score || a.id - b.id); + const touched = new Set(); + for (const cand of candidates) { + if (touched.has(cand.id) || owner.get(cand.id) !== cand.from) continue; + if (!wouldRemainConnectedAfterRemoval(nodes, owner, cand.id, cand.from)) continue; + owner.set(cand.id, cand.to); + touched.add(cand.id); + passChanged++; + } + if (!passChanged) break; + changed += passChanged; + repairPrefectureMunicipalityConnectivity(nodes, owner); + repairPrefectureMunicipalityEnclaves(nodes, owner, 6); + } + return changed; +} + export function lockPrefectureCapitalNeighborMunicipalities(owner, nodes, seeds = [], maxNeighbors = 6) { let changed = 0; const seedIds = new Set((seeds || []).map((node) => node?.id).filter((id) => id !== undefined)); @@ -836,6 +899,10 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) { changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12); changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); + const changedForBoundaryShareRelaxation = relaxHighBoundaryShareMunicipalities(graph.nodes, owner, { threshold: 0.50, maxPasses: 6 }); + changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); + changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12); + changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]); const municipalityToPrefectureId = new Int16Array(maxAdminId + 1); municipalityToPrefectureId.fill(-1); @@ -880,6 +947,7 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) { prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0, prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0, prefectureCapitalNeighborLockChangedMunicipalities: changedForCapitalNeighborLock || 0, + prefectureBoundaryShareRelaxationChangedMunicipalities: changedForBoundaryShareRelaxation || 0, finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()), finalRegionalMunicipalityCountCap: 88, finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()), diff --git a/mapTransport.js b/mapTransport.js index b1da100..6c3f147 100644 --- a/mapTransport.js +++ b/mapTransport.js @@ -4,6 +4,7 @@ import { assessExpresswayRoute, assessMountainRoute, countReason, + createIncrementalPathInfluence, fieldBackbonePolicy, markPathInfluence as markPathInfluenceBase, makeSpatialIndex, @@ -523,17 +524,17 @@ export function buildDensityFlowRoadTransportSystem(ctx) { 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) + // Expressways are intercity corridors, but cities over 100k should still + // have a suburban motorway contact point. The portal search stays outside + // the dense core, so the rendered line does not snap to the city dot. + .filter((c) => (c.population || 0) >= 100000 || 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 fieldPortals = densityFieldAnchors("expressway", 11); + return dedupeAnchors([...urbanPortals, ...portPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 11).slice(0, 30); } const cityPortals = modernCities.flatMap((c) => cityPortalAnchors(c, "national")); @@ -546,13 +547,13 @@ export function buildDensityFlowRoadTransportSystem(ctx) { 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); + const fieldPortals = densityFieldAnchors("national", 40); + return dedupeAnchors([...cityPortals, ...marketPortals, ...villagePortals, ...portPortals, ...passPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 5.5).slice(0, 68); } function buildTrafficFlowField(mode, anchors, baseCost, maxRoutes = 34) { const flow = new Float32Array(SIZE); - const nodes = anchors.slice(0, mode === "expressway" ? 24 : 56); + const nodes = anchors.slice(0, mode === "expressway" ? 18 : 42); const pairs = []; for (let a = 0; a < nodes.length; a++) { for (let b = a + 1; b < nodes.length; b++) { @@ -788,7 +789,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { const policy = fieldBackbonePolicy(config.fieldPolicyMode || mode); let connectedAdds = 0; let extraAdds = 0; - const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(10, Math.max(4, Math.ceil((options.nodeCount || 1) / 4))) : Math.min(34, Math.max(16, Math.ceil((options.nodeCount || 1) * 0.46)))); + const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(7, Math.max(3, Math.ceil((options.nodeCount || 1) / 5))) : Math.min(26, Math.max(12, Math.ceil((options.nodeCount || 1) * 0.36)))); const maxExtra = options.maxExtra ?? config.backbone?.maxExtra ?? 0; const maxDegree = options.maxDegree ?? config.backbone?.maxDegree ?? 3; for (const pair of pairs) { @@ -800,7 +801,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { if ((degree.get(ak) || 0) >= maxDegree || (degree.get(bk) || 0) >= maxDegree) { skip.degree++; continue; } const routeOptions = { ...config.routeOptions, - maxPathLength: pair.d * (mode === "expressway" ? 2.75 : 2.55) + (mode === "expressway" ? 84 : 42), + maxPathLength: pair.d * (mode === "expressway" ? 2.45 : 2.28) + (mode === "expressway" ? 66 : 32), ...options.routeOptions, }; const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, costField, penalty, routeOptions); @@ -859,7 +860,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { function buildFieldBackbone(debug, mode, outPaths, anchors, costField, potentialField, options = {}) { const config = roadMode(mode); - const nodes = anchors.slice(0, options.maxNodes ?? (mode === "expressway" ? 38 : 76)); + const nodes = anchors.slice(0, options.maxNodes ?? (mode === "expressway" ? 30 : 58)); if (nodes.length < 2) return; const penalty = new Float32Array(SIZE); const accepted = new Float32Array(SIZE); @@ -952,6 +953,25 @@ export function buildDensityFlowRoadTransportSystem(ctx) { return false; } + function pathServesSuburbanAnchor(path, city, anchor, radius = 7.0) { + if (!path?.length || !city || !anchor) return false; + const coreAvoid = Math.max(4.5, (city.coreRadius || 3.5) + 1.5); + const fringeRadius = Math.max(radius, (city.urbanRadius || 12) * 0.42); + const exitRadius = Math.max(24, (city.urbanRadius || 12) * 1.55); + let nearAnchor = false; + let outsideCore = false; + let exitsEnvelope = false; + for (const [x, y] of path) { + const da = Math.hypot(x - anchor.x, y - anchor.y); + const dc = Math.hypot(x - city.x, y - city.y); + if (da <= fringeRadius) nearAnchor = true; + if (dc >= coreAvoid) outsideCore = true; + if (dc >= exitRadius) exitsEnvelope = true; + if (nearAnchor && outsideCore && exitsEnvelope) return true; + } + return false; + } + function ensureCityOutboundConnections({ debug, mode, @@ -962,7 +982,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { penalty, paths, acceptedPaths = [], - maxTargets = 8, + maxTargets = 5, minDistance = 14, maxDistance = 126, routeOptions = {}, @@ -978,7 +998,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { for (const city of cities) { const start = startForCity(city); if (!start) continue; - if (paths.some((path) => pathServesCityCenter(path, city, mode))) continue; + if (paths.some((path) => mode === "expressway" ? pathServesSuburbanAnchor(path, city, start, 7.0) : pathServesCityCenter(path, city, mode))) 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, costField) })) @@ -1042,7 +1062,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { return best; } const eligibleCities = modernCities - .filter((c) => (c.population || 0) >= 150000 || c.isRegionalCapital || c.isPrefecturalCapital) + .filter((c) => (c.population || 0) >= 100000 || c.isRegionalCapital || c.isPrefecturalCapital) .sort((a, b) => (b.population || 0) - (a.population || 0)); const cityAnchors = eligibleCities .map((city) => { @@ -1066,7 +1086,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { 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; } + if (expressways.some((path) => pathServesSuburbanAnchor(path, city, anchor, 7.0))) { stats.covered++; continue; } const candidates = otherTargets .filter((q) => q !== anchor && q.city !== city) .map((q) => { @@ -1082,7 +1102,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { return (a.d * a.c + aCity) - (b.d * b.c + bCity); }); if (!candidates.length) stats.noCandidates++; - for (const opt of candidates.slice(0, 10)) { + for (const opt of candidates.slice(0, 6)) { stats.tried++; const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", forceCost, penalty, { curvePenalty: 0.13, @@ -1186,24 +1206,24 @@ export function buildDensityFlowRoadTransportSystem(ctx) { .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 expressFlow = buildTrafficFlowField("expressway", expressAnchors, expresswayCorridorCost, 10); const expressCost = fieldAdjustedCost(expresswayCorridorCost, "expressway", expressFlow); buildFieldBackbone(debug, "expressway", expressways, expressAnchors, expressCost, transportFields.expresswayPotential, { ...roadMode("expressway").backbone, - maxNodes: 48, - maxAdded: Math.min(4, Math.max(2, Math.ceil(expressAnchors.length / 12))), + maxNodes: 34, + maxAdded: Math.min(3, Math.max(2, Math.ceil(expressAnchors.length / 14))), }); 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 nationalFlow = buildTrafficFlowField("national", nationalAnchors, transportFields.national, 34); const nationalCost = fieldAdjustedCost(transportFields.national, "national", nationalFlow); buildFieldBackbone(debug, "national", nationalRoads, nationalAnchors, nationalCost, transportFields.nationalPotential, { ...roadMode("national").backbone, - maxNodes: 78, - maxAdded: Math.min(38, Math.max(18, Math.ceil(nationalAnchors.length * 0.45))), + maxNodes: 58, + maxAdded: Math.min(27, Math.max(13, Math.ceil(nationalAnchors.length * 0.34))), }); // Guarantee light national access to large urban areas whose portals were @@ -1485,11 +1505,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) { repairTransportConnectivity(expressways, "expressway", expresswayCorridorCost, transportFields.expresswayPotential, { minImportance: 5.5, minComponentCells: 10, - maxComponents: 8, - maxRepairs: 3, - maxRepairDistance: 145, + maxComponents: 6, + maxRepairs: 2, + maxRepairDistance: 125, minRepairDistance: 18, - searchPad: 48, + searchPad: 38, penaltyRadius: 18, penaltyStrength: 4.2, curvePenalty: 0.13, @@ -1502,11 +1522,11 @@ export function buildDensityFlowRoadTransportSystem(ctx) { repairTransportConnectivity(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, { minImportance: 4.0, minComponentCells: 7, - maxComponents: 14, - maxRepairs: 12, - maxRepairDistance: 96, + maxComponents: 10, + maxRepairs: 7, + maxRepairDistance: 82, minRepairDistance: 8, - searchPad: 36, + searchPad: 28, penaltyRadius: 6, penaltyStrength: 1.15, curvePenalty: 0.060, @@ -1519,9 +1539,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) { repairTransportConnectivity(railways, "rail", transportFields.rail, transportFields.railPotential, { minImportance: 10.5, minComponentCells: 18, - maxComponents: 6, - maxRepairs: 3, - maxRepairDistance: 105, + maxComponents: 5, + maxRepairs: 2, + maxRepairDistance: 92, penaltyRadius: 7, penaltyStrength: 2.0, curvePenalty: 0.16, @@ -1550,9 +1570,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) { } const nationalEndpointRepair = repairDanglingTransportEndpoints(nationalRoads, "national", transportFields.national, [...externalRoads, ...expressways, ...railways], transportFields.nationalPotential, { - maxAdded: 18, - maxTargetDistance: 58, - maxPathLength: 86, + maxAdded: 11, + maxTargetDistance: 50, + maxPathLength: 74, curvePenalty: 0.060, terrainFlowBias: 0.24, surfaceGrain: 0.030, @@ -1566,9 +1586,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) { transportDebugLayers.repairedSegments.push(...nationalEndpointRepair.added); const expressEndpointRepair = repairDanglingTransportEndpoints(expressways, "expressway", expresswayCorridorCost, [...externalExpressways, ...nationalRoads], transportFields.expresswayPotential, { - maxAdded: 3, - maxTargetDistance: 78, - maxPathLength: 128, + maxAdded: 2, + maxTargetDistance: 68, + maxPathLength: 110, curvePenalty: 0.13, terrainFlowBias: 0.09, surfaceGrain: 0.006, @@ -1633,26 +1653,27 @@ export function buildDensityFlowRoadTransportSystem(ctx) { }) .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); + .slice(0, 92); + const localPenaltyAccumulator = createIncrementalPathInfluence([], 4, { sea }); + const localPenalty = localPenaltyAccumulator.field; const paths = []; const served = []; for (const start of candidates) { - if (paths.length >= 115) break; + if (paths.length >= 72) 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 } + { curvePenalty: 0.08, penaltyStrength: 1.00, minGoalDistance: 5, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.56) } ); 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); + localPenaltyAccumulator.add(path, 0.22, 4); } return paths; } @@ -1679,9 +1700,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) { 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 } + { curvePenalty: 0.020, penaltyStrength: 0.90, minGoalDistance: 3, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.50), terrainFlowBias: 0.26, surfaceGrain: 0.042 } ); - path = relaxRouteToTerrain(path, transportFields.local, { radius: 2, lineWeight: 0.25, grain: 0.048, iterations: 1 }); + if (path.length <= 48) 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 }); @@ -1691,15 +1712,15 @@ export function buildDensityFlowRoadTransportSystem(ctx) { added++; transportDebugLayers.repairedSegments.push({ mode: "local", path, from, to }); addCorridorInfluencePenalty(localPenalty, path, 4, 0.18); - if (accessInfluence) addCorridorInfluencePenalty(accessInfluence, path, 5, 0.22); + if (accessInfluence) markPathInfluence(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, + maxAdded: 22, + maxTargetDistance: 30, curvePenalty: 0.055, terrainFlowBias: 0.26, surfaceGrain: 0.048, @@ -1858,6 +1879,10 @@ export function buildDensityFlowRoadTransportSystem(ctx) { const cum = pathCumulativeLengths(path); const total = cum[cum.length - 1] || 0; if (total < absoluteMinGap * 1.6) continue; + const firstEndpoint = { x: path[0][0], y: path[0][1], s: 0, terminal: true }; + const lastEndpoint = { x: path[path.length - 1][0], y: path[path.length - 1][1], s: total, terminal: true }; + addInterchange(firstEndpoint, nearestRoadForIc(firstEndpoint, 38.0 + icDemandAt(firstEndpoint) * 14.0, true)); + addInterchange(lastEndpoint, nearestRoadForIc(lastEndpoint, 38.0 + icDemandAt(lastEndpoint) * 14.0, true)); 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] }; diff --git a/mapTransportGraph.js b/mapTransportGraph.js new file mode 100644 index 0000000..900ef10 --- /dev/null +++ b/mapTransportGraph.js @@ -0,0 +1,145 @@ +import { INF, MAP_H, MAP_W, MinHeap, indexOf, inside } from "./mapUtils.js"; + +export function buildCoarseCostGraph({ sea, costField }, options = {}) { + const scale = options.scale ?? 4; + const cw = Math.ceil(MAP_W / scale); + const ch = Math.ceil(MAP_H / scale); + const size = cw * ch; + const cost = new Float32Array(size); + const passable = new Uint8Array(size); + cost.fill(INF); + + for (let cy = 0; cy < ch; cy++) { + for (let cx = 0; cx < cw; cx++) { + let best = INF; + let sum = 0; + let n = 0; + for (let dy = 0; dy < scale; dy++) { + for (let dx = 0; dx < scale; dx++) { + const x = cx * scale + dx; + const y = cy * scale + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea?.[i] || costField?.[i] >= INF) continue; + const v = costField[i]; + best = Math.min(best, v); + sum += v; + n++; + } + } + const ci = cy * cw + cx; + if (n > 0) { + passable[ci] = 1; + cost[ci] = best * 0.55 + (sum / n) * 0.45; + } + } + } + + return { scale, cw, ch, size, cost, passable }; +} + +export function routeCoarsePath(start, goal, graph, options = {}) { + if (!start || !goal || !graph) return []; + const sx = Math.floor(start.x / graph.scale); + const sy = Math.floor(start.y / graph.scale); + const gx = Math.floor(goal.x / graph.scale); + const gy = Math.floor(goal.y / graph.scale); + if (sx < 0 || sy < 0 || sx >= graph.cw || sy >= graph.ch || gx < 0 || gy < 0 || gx >= graph.cw || gy >= graph.ch) return []; + const startCi = sy * graph.cw + sx; + const goalCi = gy * graph.cw + gx; + if (!graph.passable[startCi] || !graph.passable[goalCi]) return []; + + const score = new Float32Array(graph.size); + const cameFrom = new Int32Array(graph.size); + const closed = new Uint8Array(graph.size); + score.fill(INF); + cameFrom.fill(-1); + score[startCi] = 0; + const heap = new MinHeap(); + heap.push({ i: startCi, f: Math.hypot(sx - gx, sy - gy) }); + const maxExpanded = options.maxExpanded ?? graph.size; + let expanded = 0; + let found = -1; + + while (heap.length && expanded++ < maxExpanded) { + const cur = heap.pop(); + if (!cur || closed[cur.i]) continue; + closed[cur.i] = 1; + if (cur.i === goalCi) { + found = cur.i; + break; + } + const cx = cur.i % graph.cw; + const cy = Math.floor(cur.i / graph.cw); + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const nx = cx + dx; + const ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= graph.cw || ny >= graph.ch) continue; + const ni = ny * graph.cw + nx; + if (closed[ni] || !graph.passable[ni]) continue; + const nd = score[cur.i] + graph.cost[ni] * Math.hypot(dx, dy); + if (nd < score[ni]) { + score[ni] = nd; + cameFrom[ni] = cur.i; + heap.push({ i: ni, f: nd + Math.hypot(nx - gx, ny - gy) * (options.heuristicWeight ?? 0.85) }); + } + } + } + } + if (found < 0) return []; + + const path = []; + for (let p = found; p >= 0; p = cameFrom[p]) { + const cx = p % graph.cw; + const cy = Math.floor(p / graph.cw); + path.push([ + Math.min(MAP_W - 1, Math.round(cx * graph.scale + graph.scale * 0.5)), + Math.min(MAP_H - 1, Math.round(cy * graph.scale + graph.scale * 0.5)), + ]); + if (p === startCi) break; + } + return path.reverse(); +} + +export function refineCoarsePath(path, costField, options = {}) { + if (!path || path.length < 2) return []; + const sea = options.sea || null; + const radius = options.snapRadius ?? 1; + const out = []; + let lastKey = ""; + for (let k = 0; k < path.length - 1; k++) { + const a = path[k]; + const b = path[k + 1]; + const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]))); + for (let s = k === 0 ? 0 : 1; s <= steps; s++) { + const t = s / steps; + const tx = Math.round(a[0] + (b[0] - a[0]) * t); + const ty = Math.round(a[1] + (b[1] - a[1]) * t); + let best = null; + let bestScore = INF; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const x = tx + dx; + const y = ty + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea?.[i] || costField?.[i] >= INF) continue; + const score = costField[i] + Math.hypot(dx, dy) * 0.22; + if (score < bestScore) { + bestScore = score; + best = [x, y]; + } + } + } + if (!best) return []; + const key = `${best[0]},${best[1]}`; + if (key !== lastKey) { + out.push(best); + lastKey = key; + } + } + } + return out.length >= 2 ? out : []; +} diff --git a/mapTransportOD.js b/mapTransportOD.js index 30d1002..0094fe0 100644 --- a/mapTransportOD.js +++ b/mapTransportOD.js @@ -40,7 +40,9 @@ export function buildUnifiedRailODNetwork(ctx) { transportRouteAcceptable, pruneParallelSameMode, cachedInfluenceFromPaths, + speedTolerance = 1, } = ctx; + const speedScale = clamp(speedTolerance, 0.75, 1); const railways = []; const branchRailways = []; @@ -220,8 +222,8 @@ export function buildUnifiedRailODNetwork(ctx) { relaxRadius: 1, relaxLineWeight: branch ? 0.44 : 0.50, snapRadius: branch ? 2.4 : 2.8, - searchPad: Math.ceil(Math.max(22, Math.min(68, pair.d * 0.48))), - maxPathLength: pair.d * (branch ? 2.28 : 2.48) + (branch ? 18 : 36), + searchPad: Math.ceil(Math.max(18, Math.min(56, pair.d * 0.40))), + maxPathLength: pair.d * (branch ? 2.12 : 2.30) + (branch ? 16 : 30), maxSeaRun: 1, maxSeaShare: 0.006, }); @@ -260,18 +262,18 @@ export function buildUnifiedRailODNetwork(ctx) { .filter(Boolean); const anchorNodes = geographicUrbanAnchors .filter((a) => (a.score || 0) > 0.76) - .slice(0, 8) + .slice(0, Math.max(5, Math.round(8 * speedScale))) .map((a) => nearbyRailAnchor({ ...a, population: a.population || 52000 }, "geo-anchor-rail", { outer: 6 })) .filter(Boolean); let trunkNodes = dedupeNodes([...regionalCityNodes, ...secondaryCityNodes, ...portNodes, ...externalNodes, ...anchorNodes], 7.5) .sort((a, b) => (b.population || 0) - (a.population || 0)) - .slice(0, 34); + .slice(0, Math.max(24, Math.round(34 * speedScale))); if (trunkNodes.length < 2) { trunkNodes = dedupeNodes([ ...modernCities.map((c) => nearbyRailAnchor(c, "fallback-city-rail", { outer: 8 })), ...markets.filter((m) => (m.population || 0) >= 14000).map((m) => nearbyRailAnchor(m, "fallback-market-rail", { outer: 5 })), - ], 7).slice(0, 18); + ], 7).slice(0, Math.max(14, Math.round(18 * speedScale))); } debug.nodeCounts = { regionalCityNodes: regionalCityNodes.length, @@ -300,7 +302,7 @@ export function buildUnifiedRailODNetwork(ctx) { const uf = makeUnionFind(trunkNodes, keyOf); const penalty = new Float32Array(SIZE); - const maxTrunk = Math.min(18, Math.max(4, trunkNodes.length - 1)); + const maxTrunk = Math.min(Math.max(14, Math.round(18 * speedScale)), Math.max(4, trunkNodes.length - 1)); let connectedEdges = 0; for (const pair of trunkPairs) { if (connectedEdges >= maxTrunk) break; @@ -322,7 +324,7 @@ export function buildUnifiedRailODNetwork(ctx) { let loopAdded = 0; const trunkInfluenceBeforeLoops = cachedInfluenceFromPaths(railways, 5, "rail-od:trunk-before-loops"); for (const pair of trunkPairs) { - if (loopAdded >= Math.min(7, Math.max(2, Math.ceil(trunkNodes.length / 5)))) break; + if (loopAdded >= Math.min(Math.max(4, Math.round(7 * speedScale)), Math.max(2, Math.ceil(trunkNodes.length / 6)))) break; const ai = indexOf(pair.a.x, pair.a.y); const bi = indexOf(pair.b.x, pair.b.y); if ((trunkInfluenceBeforeLoops[ai] || 0) > 0.62 && (trunkInfluenceBeforeLoops[bi] || 0) > 0.62 && pair.demand < 0.88) continue; @@ -347,7 +349,7 @@ export function buildUnifiedRailODNetwork(ctx) { ...ports .filter((p) => p.portClass === "regional" || (p.population || 0) >= 10000) .map((p) => nearbyRailAnchor(p, "branch-port-rail", { outer: 7 })), - ], 6).sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 36); + ], 6).sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, Math.max(26, Math.round(36 * speedScale))); const trunkTargets = []; for (const path of railways) { @@ -361,7 +363,7 @@ export function buildUnifiedRailODNetwork(ctx) { let branchAdded = 0; for (const node of branchCandidates) { - if (branchAdded >= 14) break; + if (branchAdded >= Math.max(10, Math.round(14 * speedScale))) break; const ni = indexOf(node.x, node.y); if ((railInfluence[ni] || 0) > 0.34) continue; const options = trunkTargets @@ -374,7 +376,7 @@ export function buildUnifiedRailODNetwork(ctx) { }) .filter(Boolean) .sort((a, b) => a.score - b.score); - for (const pair of options.slice(0, 5)) { + for (const pair of options.slice(0, 3)) { if (odDemand(pair.a, pair.b, "branch") < 0.34 && pair.d > 32) { reject("branchWeakDemand"); continue; } const path = routeRailPair(pair, penalty, true); if (!path.length) continue; diff --git a/mapTransportUtils.js b/mapTransportUtils.js index b2cb7e3..749420c 100644 --- a/mapTransportUtils.js +++ b/mapTransportUtils.js @@ -70,6 +70,19 @@ export function markPathInfluence(field, path, radius = 5, strength = 1, sea = n } } +export function createIncrementalPathInfluence(initialPaths = [], radius = 5, options = {}) { + const field = new Float32Array(SIZE); + const sea = options.sea || null; + for (const path of initialPaths || []) markPathInfluence(field, path, radius, 1, sea); + return { + field, + add(path, strength = 1, addRadius = radius) { + markPathInfluence(field, path, addRadius, strength, sea); + return field; + }, + }; +} + export function sampledNetworkCells(paths, step = 2, sea = null) { const cells = []; for (let pathId = 0; pathId < (paths || []).length; pathId++) { diff --git a/renderer.js b/renderer.js index bf72d40..f16649e 100644 --- a/renderer.js +++ b/renderer.js @@ -218,14 +218,6 @@ function vectorPath(path) { return simplified; } -function vectorPathMode(path, mode = "default") { - if (mode !== "expressway") return vectorPath(path); - if (!path || path.length < 2) return []; - const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]); - const smoothIterations = path.length > 18 ? 3 : path.length > 8 ? 2 : 1; - return simplifyRdp(chaikin(points, smoothIterations, false), CELL_SIZE * 0.18); -} - function drawPolylinePoints(ctx, points) { if (!points || points.length < 2) return; ctx.moveTo(points[0][0], points[0][1]); @@ -545,8 +537,8 @@ function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) { ctx.restore(); } -function drawPath(ctx, path, color, width, dashed = false, mode = "default") { - const points = vectorPathMode(path, mode); +function drawPath(ctx, path, color, width, dashed = false) { + const points = vectorPath(path); if (points.length < 2) return; ctx.save(); ctx.lineCap = "round"; @@ -580,99 +572,8 @@ function landOnlySubpaths(map, path, minCells = 2) { return chunks; } -function drawLandPath(ctx, map, path, color, width, dashed = false, minCells = 2, mode = "default") { - for (const chunk of landOnlySubpaths(map, path, minCells)) drawPath(ctx, chunk, color, width, dashed, mode); -} - -function specialTransportSubpaths(map, path, predicate, minCells = 1, includeShoulders = true, maxCoreCells = Infinity) { - if (!path || path.length < 2) return []; - const chunks = []; - let cur = []; - let core = 0; - function flush(nextPoint = null) { - if (cur.length && nextPoint && includeShoulders) cur.push(nextPoint); - if (cur.length >= Math.max(2, minCells) && core <= maxCoreCells) chunks.push(cur); - cur = []; - core = 0; - } - for (let idx = 0; idx < path.length; idx++) { - const [x, y] = path[idx]; - const i = inside(x, y) ? indexOf(x, y) : -1; - const hit = i >= 0 && predicate(i, x, y); - if (hit) { - if (!cur.length && includeShoulders && idx > 0) cur.push(path[idx - 1]); - cur.push(path[idx]); - core++; - } else if (cur.length) { - flush(path[idx]); - } - } - flush(null); - return chunks; -} - -function drawOffsetPolyline(ctx, points, offsetPx) { - if (!points || points.length < 2) return; - ctx.beginPath(); - for (let i = 0; i < points.length; i++) { - const prev = points[Math.max(0, i - 1)]; - const cur = points[i]; - const next = points[Math.min(points.length - 1, i + 1)]; - const dx = next[0] - prev[0]; - const dy = next[1] - prev[1]; - const len = Math.hypot(dx, dy) || 1; - const ox = -dy / len * offsetPx; - const oy = dx / len * offsetPx; - if (i === 0) ctx.moveTo(cur[0] + ox, cur[1] + oy); - else ctx.lineTo(cur[0] + ox, cur[1] + oy); - } - ctx.stroke(); -} - -function drawDottedOutlinePath(ctx, path, color, width, offsetPx, mode = "default") { - const points = vectorPathMode(path, mode); - if (points.length < 2) return; - ctx.save(); - ctx.lineCap = "round"; - ctx.lineJoin = "round"; - ctx.strokeStyle = color; - ctx.lineWidth = width; - ctx.setLineDash([1.8, 3.2]); - drawOffsetPolyline(ctx, points, offsetPx); - drawOffsetPolyline(ctx, points, -offsetPx); - ctx.restore(); -} - -function drawBridgeOverlay(ctx, map, path, width, mode = "road") { - const limit = mode === "expressway" ? 20 : 10; - const bridgeChunks = specialTransportSubpaths(map, path, (i) => map.sea?.[i], 2, true, limit); - for (const chunk of bridgeChunks) { - const vectorMode = mode === "expressway" ? "expressway" : "default"; - drawPath(ctx, chunk, "rgba(255,255,255,0.98)", width + 2.0, false, vectorMode); - drawPath(ctx, chunk, mode === "expressway" ? "rgba(135, 160, 135, 0.95)" : "rgba(245, 225, 130, 1)", width + 0.2, false, vectorMode); - drawDottedOutlinePath(ctx, chunk, "rgba(55, 85, 130, 0.95)", 1.0, Math.max(1.8, width * 0.72), vectorMode); - } -} - -function drawTunnelOverlay(ctx, map, path, width, mode = "road") { - if (mode !== "expressway" && mode !== "national") return; - const vectorMode = mode === "expressway" ? "expressway" : "default"; - const limit = mode === "expressway" ? 10 : 10; - const tunnelChunks = specialTransportSubpaths( - map, - path, - (i) => !map.sea?.[i] && (((map.elevation?.[i] || 0) >= 0.74 && (map.ridgeField?.[i] || 0) >= 0.46) || (map.naturalBarrierScore?.[i] || 0) >= 0.82), - 2, - true, - limit - ); - for (const chunk of tunnelChunks) { - drawDottedOutlinePath(ctx, chunk, "rgba(42, 42, 42, 0.96)", mode === "expressway" ? 1.15 : 1.0, Math.max(1.75, width * 0.82), vectorMode); - } -} - -function drawExpresswayPath(ctx, map, path, color, width, dashed = false, minCells = 2) { - drawLandPath(ctx, map, path, color, width, dashed, minCells, "expressway"); +function drawLandPath(ctx, map, path, color, width, dashed = false, minCells = 2) { + for (const chunk of landOnlySubpaths(map, path, minCells)) drawPath(ctx, chunk, color, width, dashed); } // 魚の骨(私鉄記号)スタイルを描画するための専用関数 @@ -949,7 +850,7 @@ function drawLabels(ctx, points, limit = Infinity, occupied = null) { } function drawScaleBar(ctx) { - const kmPerCell = 1; + const kmPerCell = 0.5; const targetKm = 25; const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell)); const lengthPx = lengthCells * CELL_SIZE; @@ -1117,8 +1018,8 @@ export function drawMap(canvas, map, options) { for (const path of map.externalRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3); } if (showRoads) { - for (const path of map.expressways) drawExpresswayPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6); - for (const path of map.externalExpressways) drawExpresswayPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6); + for (const path of map.expressways) drawLandPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6); + for (const path of map.externalExpressways) drawLandPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6); } // 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads. @@ -1136,11 +1037,8 @@ export function drawMap(canvas, map, options) { for (const path of map.externalRailways) drawLandRailway(ctx, map, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0); } if (showRoads) { - for (const path of generalRoadPaths) { drawBridgeOverlay(ctx, map, path, 1.55, "road"); } - for (const path of map.nationalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); drawTunnelOverlay(ctx, map, path, 1.9, "national"); } - for (const path of map.externalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); drawTunnelOverlay(ctx, map, path, 1.9, "national"); } - for (const path of map.expressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); } - for (const path of map.externalExpressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); } + for (const path of map.expressways) drawLandPath(ctx, map, path, "rgba(135, 160, 135, 0.88)", 2.4); + for (const path of map.externalExpressways) drawLandPath(ctx, map, path, "rgba(135, 160, 135, 0.88)", 2.4); } // 6. Icons & Labels @@ -1148,15 +1046,22 @@ export function drawMap(canvas, map, options) { for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)"); } + const settlementIconLabelPoints = ["all", "modern", "history"].includes(mode) + ? [ + ...(map.markets || []).filter((p) => (p.population || 0) >= 3000), + ...(map.villages || []).filter((p) => (p.population || 0) >= 3000), + ...(mode === "history" ? (map.ports || []).filter((p) => p.portClass === "major" || p.portClass === "regional") : []), + ...(mode === "history" ? (map.castles || []) : []), + ].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y)) + .map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: p.labelPriorityBase || (p.kind === "Village" ? 58 : p.kind === "Castle" ? 88 : 66) })) + : []; + + if (["all", "modern", "history"].includes(mode)) { + for (const p of settlementIconLabelPoints) dot(ctx, p, p.kind === "Castle" ? 3.2 : 3.1, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.88)"); + } + if (showModern) { for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444"); - const allLayerTowns = mode === "all" - ? [ - ...(map.markets || []).filter((p) => (p.population || 0) >= 5000), - ...(map.villages || []).filter((p) => (p.population || 0) >= 5000), - ].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y)) - : []; - for (const p of allLayerTowns) dot(ctx, p, 3.1, "rgba(244, 164, 118, 0.92)", "rgba(255,255,255,0.88)"); for (const p of map.modernCities) { const popRadius = p.population ? Math.min(9.4, 3.8 + Math.sqrt(p.population) / 360) : 4.8; dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)"); @@ -1177,37 +1082,29 @@ export function drawMap(canvas, map, options) { } if (showLabels) { - const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, labelPriorityBase: p.labelPriorityBase || 1700 })); + const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 })); if (mode === "admin") { - drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity); + const municipalLabels = (map.adminCenters || []) + .filter((p) => p && p.name) + .map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 })); + drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity); drawScaleBar(ctx); return; } if (mode === "borders-debug") { - drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity); + drawLabels(ctx, prefectureLabels, Infinity); drawScaleBar(ctx); return; } - // In All mode, draw town/village dots above but suppress town/village labels. - // The Admin/Municipal Borders view still labels municipal centers normally. - const allLayerTowns = mode === "all" - ? [ - ...(map.markets || []).filter((p) => (p.population || 0) >= 5000), - ...(map.villages || []).filter((p) => (p.population || 0) >= 5000), - ].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y)) - : []; const important = [ ...prefectureLabels, ...map.modernCities, - ...(map.ports || []).map((p) => ({ - ...p, - labelPriorityBase: p.portClass === "major" ? 170 : p.portClass === "regional" ? 120 : p.portClass === "fishing" ? 95 : 85, - })), + ...map.ports, ...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })), ...(map.satelliteCities || []), - ...allLayerTowns.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: 80 })), + ...settlementIconLabelPoints, ].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000); - drawLabels(ctx, important, mode === "all" ? 95 : 60); + drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60); } drawScaleBar(ctx); } From c523bf4380ba5c9e091df4b005c367096b3ef656 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 28 May 2026 16:45:00 +0900 Subject: [PATCH 3/7] stable --- app.js | 7 ++++++- index.html | 9 +++++++++ mapOutput.js | 39 ++++++++++++++++++++++++++++++++++----- mapPipeline.js | 4 ++-- mapTerrain.js | 27 ++++++++++++++++----------- mapTransport.js | 5 +++-- renderer.js | 8 ++++---- styles.css | 3 ++- 8 files changed, 76 insertions(+), 26 deletions(-) diff --git a/app.js b/app.js index b9bdc54..a5dfe93 100644 --- a/app.js +++ b/app.js @@ -16,6 +16,7 @@ const modes = [ const state = { seedText: "114514", + generationType: "auto", mode: "all", showFeatures: true, showLabels: true, @@ -26,6 +27,7 @@ const state = { const canvas = document.getElementById("mapCanvas"); const canvasShell = document.querySelector(".canvas-shell"); const seedInput = document.getElementById("seed"); +const generationTypeInput = document.getElementById("generationType"); const randomSeedButton = document.getElementById("randomSeed"); const showFeaturesInput = document.getElementById("showFeatures"); const showLabelsInput = document.getElementById("showLabels"); @@ -336,10 +338,11 @@ function renderModeButtons() { async function regenerate() { state.seedText = seedInput.value; + state.generationType = generationTypeInput?.value || "auto"; setProgressVisible(true, "Preparing generation..."); await nextFrame(); try { - state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress }); + state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress, terrainType: state.generationType }); state.hoverEntities = buildHoverEntities(state.map); renderStats(state.map); redraw(); @@ -369,6 +372,8 @@ function init() { if (event.key === "Enter") regenerate(); }); + generationTypeInput?.addEventListener("change", regenerate); + randomSeedButton.addEventListener("click", () => { seedInput.value = String(Math.floor(Math.random() * 9999999)); regenerate(); diff --git a/index.html b/index.html index 1179b32..a60db97 100644 --- a/index.html +++ b/index.html @@ -32,6 +32,15 @@
+ +
diff --git a/mapOutput.js b/mapOutput.js index d83ebd3..f23c24f 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -96,7 +96,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement } -function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, fields, seed, minPopulation = 200000) { +function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, fields, seed, minPopulation = 200000, focusedPrefectureMask = null) { if (!prefectureRegionId) return 0; const prefIds = new Set(); for (let i = 0; i < prefectureRegionId.length; i++) { @@ -135,6 +135,24 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti if (!p || !inside(p.x, p.y)) return -1; return prefectureRegionId[indexOf(p.x, p.y)] ?? -1; } + const focusedPrefCounts = new Map(); + if (focusedPrefectureMask) { + for (let i = 0; i < focusedPrefectureMask.length; i++) { + if (!focusedPrefectureMask[i] || sea[i]) continue; + const prefId = prefectureRegionId[i] ?? -1; + if (prefId >= 0) focusedPrefCounts.set(prefId, (focusedPrefCounts.get(prefId) || 0) + 1); + } + } + const focusedPrefId = focusedPrefCounts.size + ? [...focusedPrefCounts.entries()].sort((a, b) => b[1] - a[1])[0][0] + : 0; + for (const city of modernCities || []) { + if (city.isPrefecturalCapital) { + city.isPrefecturalCapital = false; + city.rank = city.isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City"; + city.kind = city.rank; + } + } for (const prefId of [...prefIds].sort((a, b) => a - b)) { const cities = (modernCities || []).filter((p) => prefAt(p) === prefId); let target = cities.slice().sort((a, b) => @@ -175,10 +193,10 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti target.population = promotedPopulation; promoted++; } - target.isPrefecturalCapital = true; target.isRegionalCapital = true; - target.rank = "Prefectural Capital"; - target.kind = "Prefectural Capital"; + target.isPrefecturalCapital = prefId === focusedPrefId; + target.rank = target.isPrefecturalCapital ? "Prefectural Capital" : "Regional Capital"; + target.kind = target.rank; target.labelPriorityBase = Math.max(target.labelPriorityBase || 0, 1150); target.urbanRadius = clamp(6.2 + Math.sqrt(target.population) / 80, 9, 30); target.coreRadius = clamp(2.0 + Math.sqrt(target.population) / 390, 2.4, 6.4); @@ -480,6 +498,17 @@ export function finishMapOutput({ center.canonicalSettlementId = best.id; center.canonicalSettlementName = best.name; center.municipalityRootName = best.name; + const bestAdmin = adminId?.[indexOf(best.x, best.y)]; + const canSnapOffice = inside(best.x, best.y) && !sea[indexOf(best.x, best.y)] && ( + centerAdmin == null || centerAdmin < 0 || bestAdmin == null || bestAdmin < 0 || bestAdmin === centerAdmin + ); + if (canSnapOffice) { + center.generatedOfficeX = center.generatedOfficeX ?? center.x; + center.generatedOfficeY = center.generatedOfficeY ?? center.y; + center.x = best.x; + center.y = best.y; + center.officeSnappedToSettlement = true; + } } else { center.municipalityRootName = center.generatedMunicipalityName; } @@ -513,7 +542,7 @@ export function finishMapOutput({ center.municipalityName = candidate; usedAdminNames.add(center.name); } - const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000); + const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000, prefectureMask); assignMunicipalityPopulations(adminCenters, adminId, nameFields, [ ...modernCities, ...markets, diff --git a/mapPipeline.js b/mapPipeline.js index 45aa2b4..4aa046a 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -51,7 +51,7 @@ export function generateMap(seedInput = 114514, options = {}) { const generationTimings = []; const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn); - const terrain = stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed)); + const terrain = stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed, options)); const { elevation, slope, @@ -134,7 +134,7 @@ export async function generateMapAsync(seedInput = 114514, options = {}) { const generationTimings = []; const stage = (key, label, fn) => timedStageAsync(generationTimings, options, key, label, fn); - const terrain = await stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed)); + const terrain = await stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed, options)); const { elevation, slope, diff --git a/mapTerrain.js b/mapTerrain.js index e0e4db5..8452d33 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -168,13 +168,13 @@ const TERRAIN_TYPES = [ mountainOffsetRange: [0.47, 0.53], baseHeightRange: [0.56, 0.82], primaryLengthRange: [0.76, 0.96], - primaryWidthRange: [0.13, 0.22], - systemCountRange: [12, 16], - beltCountRange: [3, 4], - angleSpread: 0.14, - crossSpread: 0.38, + primaryWidthRange: [0.18, 0.30], + systemCountRange: [14, 18], + beltCountRange: [4, 5], + angleSpread: 0.18, + crossSpread: 0.58, lengthScale: 1.22, - widthScale: 0.92, + widthScale: 1.16, heightScale: 0.86, coastStrength: 0.90, plainBiasRange: [0.16, 0.34], @@ -283,7 +283,12 @@ const TERRAIN_TYPES = [ }, ]; -function pickTerrainType(seed) { +function pickTerrainType(seed, requestedType = "auto") { + if (requestedType && requestedType !== "auto") { + const normalizedType = requestedType === "touhoku_spine" ? "tohoku_spine" : requestedType; + const selected = TERRAIN_TYPES.find((type) => type.id === normalizedType); + if (selected) return selected; + } // Terrain type selection is intentionally uniform. Individual terrain // templates still contain their own parameter ranges, but there is no // terrain-type appearance weighting. @@ -299,8 +304,8 @@ function rangeInt(seed, salt, [lo, hi]) { return Math.round(lo + rand(seed, salt) * (hi - lo)); } -export function buildTerrainTemplate(seed) { - const terrainType = pickTerrainType(seed); +export function buildTerrainTemplate(seed, options = {}) { + const terrainType = pickTerrainType(seed, options.terrainType || options.generationType || "auto"); const mountainMode = terrainType.mountainMode === "mixed" ? (rand(seed, 12) < 0.42 ? "range" : rand(seed, 13) < 0.72 ? "mixed" : "massif") : terrainType.mountainMode; @@ -1132,7 +1137,7 @@ function enforceLandGradient(elevation, sea, seaLevel) { } } -export function generateTerrainAndRivers(seed) { +export function generateTerrainAndRivers(seed, options = {}) { const fields = createMapFields(); fields.visibleRavineField = new Float32Array(SIZE); fields.surfaceTextureField = new Float32Array(SIZE); @@ -1145,7 +1150,7 @@ export function generateTerrainAndRivers(seed) { passSuitability, } = fields; - const terrainTemplate = buildTerrainTemplate(seed); + const terrainTemplate = buildTerrainTemplate(seed, options); const systems = buildMountainSystems(terrainTemplate, seed); const allRidges = systems.flatMap((system, id) => buildScratchRidges(system, seed, id)); diff --git a/mapTransport.js b/mapTransport.js index 6c3f147..3f0bdb0 100644 --- a/mapTransport.js +++ b/mapTransport.js @@ -1367,11 +1367,12 @@ export function buildDensityFlowRoadTransportSystem(ctx) { } return best ? { target: best, d: bestD } : null; } - function stitchEndpoints(paths, mode, targets, maxAdds, radius) { + function stitchEndpoints(paths, mode, targets, maxAdds, radius, probability = 1) { let added = 0; const endpoints = endpointListWithIds(paths); for (const ep of endpoints) { if (added >= maxAdds) break; + if (probability < 1 && hash2(ep.x, ep.y, seed + 18331 + added * 17) > probability) continue; const near = nearestWithin(ep, targets, radius, true); if (near && addShortConnector(paths, ep, near.target, mode)) added++; } @@ -1380,7 +1381,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { 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.expressway += stitchEndpoints(expressways, "expressway", expresswayCells, 14, 46.0, 0.62); 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); diff --git a/renderer.js b/renderer.js index f16649e..501526f 100644 --- a/renderer.js +++ b/renderer.js @@ -1018,8 +1018,8 @@ export function drawMap(canvas, map, options) { for (const path of map.externalRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3); } if (showRoads) { - for (const path of map.expressways) drawLandPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6); - for (const path of map.externalExpressways) drawLandPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6); + for (const path of map.expressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6); + for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6); } // 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads. @@ -1037,8 +1037,8 @@ export function drawMap(canvas, map, options) { for (const path of map.externalRailways) drawLandRailway(ctx, map, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0); } if (showRoads) { - for (const path of map.expressways) drawLandPath(ctx, map, path, "rgba(135, 160, 135, 0.88)", 2.4); - for (const path of map.externalExpressways) drawLandPath(ctx, map, path, "rgba(135, 160, 135, 0.88)", 2.4); + for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); + for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); } // 6. Icons & Labels diff --git a/styles.css b/styles.css index e42808d..a09f2f7 100644 --- a/styles.css +++ b/styles.css @@ -1,6 +1,6 @@ *{box-sizing:border-box} body{margin:0;background:#f0f2f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} -button,input{font:inherit} +button,input,select{font:inherit} code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .app{min-height:100vh;padding:16px} .layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:16px;max-width:1360px;margin:0 auto} @@ -13,6 +13,7 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .sidebar{display:flex;flex-direction:column;gap:12px} .card{padding:14px} .label,.card-title{display:block;margin-bottom:12px;color:#1a1a1a;font-size:14px;font-weight:600} +.inline-label{margin-top:12px} .input{width:100%;border:1px solid rgba(0,0,0,0.15);background:#fff;color:#2c2c2c;border-radius:8px;padding:10px 12px;outline:none;transition:border-color 0.2s} .input:focus{border-color:#1a73e8;box-shadow:0 0 0 3px rgba(26,115,232,0.15)} .primary-button,.mode-button{border:0;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;transition:background 0.2s} From ea0067dc0a22a1929d258d07114795c090ea1a0b Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 28 May 2026 19:37:28 +0900 Subject: [PATCH 4/7] infinity expantion --- app.js | 380 +++++++++++-- index.html | 23 +- mapGeneratorHelpers.js | 12 + mapHumanPatch.js | 1177 ++++++++++++++++++++++++++++++++++++++++ mapPatch.js | 898 ++++++++++++++++++++++++++++++ mapTerrain.js | 48 +- renderer.js | 10 +- styles.css | 9 +- worldMap.js | 100 ++++ worldViewport.js | 205 +++++++ 10 files changed, 2792 insertions(+), 70 deletions(-) create mode 100644 mapHumanPatch.js create mode 100644 mapPatch.js create mode 100644 worldMap.js create mode 100644 worldViewport.js diff --git a/app.js b/app.js index a5dfe93..ad57d2d 100644 --- a/app.js +++ b/app.js @@ -1,6 +1,10 @@ import { generateMapAsync } from "./mapGenerator.js"; import { drawMap } from "./renderer.js"; import { landuseLabel } from "./landuseCodes.js"; +import { CELL_SIZE, MAP_H, MAP_W } from "./mapUtils.js"; +import { clampCameraToWorld, createInitialCamera, createWorldMap } from "./worldMap.js"; +import { getViewportMap } from "./worldViewport.js"; +import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildPatchRects, generatePatch, validatePatchRect } from "./mapPatch.js"; const modes = [ ["all", "All"], @@ -21,19 +25,28 @@ const state = { showFeatures: true, showLabels: true, map: null, + world: null, + camera: { x: 0, y: 0 }, + viewportMap: null, hoverEntities: [], + selectionRect: null, + lastPatchResult: null, }; const canvas = document.getElementById("mapCanvas"); const canvasShell = document.querySelector(".canvas-shell"); const seedInput = document.getElementById("seed"); const generationTypeInput = document.getElementById("generationType"); +const patchTerrainTypeInput = document.getElementById("patchTerrainType"); +const generatePatchButton = document.getElementById("generatePatch"); +const patchStatusEl = document.getElementById("patchStatus"); const randomSeedButton = document.getElementById("randomSeed"); const showFeaturesInput = document.getElementById("showFeatures"); const showLabelsInput = document.getElementById("showLabels"); const modeGrid = document.getElementById("modeGrid"); const statsEl = document.getElementById("stats"); const tooltipEl = document.getElementById("mapTooltip"); +const selectionEl = document.getElementById("mapSelection"); const progressEl = document.getElementById("generationProgress"); const progressStageEl = document.getElementById("generationProgressStage"); const progressTimingsEl = document.getElementById("generationProgressTimings"); @@ -41,66 +54,253 @@ let generationStartedAt = 0; let generationCurrentStage = ""; let generationTimer = null; -const panState = { keys: new Set(), raf: null, lastTime: 0, speedPxPerSecond: 520 }; +const dragState = { + mode: null, + pointerId: null, + startClientX: 0, + startClientY: 0, + startCameraX: 0, + startCameraY: 0, + selectStart: null, + selectEnd: null, + pendingCamera: null, + panRaf: null, +}; + +function activeMap() { + return state.viewportMap || state.map; +} function mapClientToCell(event) { - if (!state.map) return null; + const map = activeMap(); + if (!map) return null; const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return null; const relX = (event.clientX - rect.left) / rect.width; const relY = (event.clientY - rect.top) / rect.height; return { - x: Math.floor(relX * state.map.width), - y: Math.floor(relY * state.map.height), + x: Math.floor(relX * map.width), + y: Math.floor(relY * map.height), }; } -function isEditableTarget(target) { - if (!target) return false; - const tag = target.tagName?.toLowerCase?.(); - return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable; +function viewportCellToWorldCell(cell) { + if (!cell || !state.camera) return null; + return { + x: Math.round(state.camera.x || 0) + cell.x, + y: Math.round(state.camera.y || 0) + cell.y, + }; } -function panFrame(time) { - if (!canvasShell || panState.keys.size === 0) { - panState.raf = null; - panState.lastTime = 0; +function clampCanvasPoint(event) { + const rect = canvas.getBoundingClientRect(); + return { + x: Math.min(Math.max(event.clientX - rect.left, 0), rect.width), + y: Math.min(Math.max(event.clientY - rect.top, 0), rect.height), + }; +} + +function updateSelectionOverlay() { + if (!selectionEl || !dragState.selectStart || !dragState.selectEnd) return; + const x0 = Math.min(dragState.selectStart.x, dragState.selectEnd.x); + const y0 = Math.min(dragState.selectStart.y, dragState.selectEnd.y); + const x1 = Math.max(dragState.selectStart.x, dragState.selectEnd.x); + const y1 = Math.max(dragState.selectStart.y, dragState.selectEnd.y); + selectionEl.style.display = "block"; + selectionEl.style.left = `${canvas.offsetLeft + x0}px`; + selectionEl.style.top = `${canvas.offsetTop + y0}px`; + selectionEl.style.width = `${Math.max(1, x1 - x0)}px`; + selectionEl.style.height = `${Math.max(1, y1 - y0)}px`; + const liveRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd); + const validation = validatePatchRect(liveRect, state.world); + selectionEl.classList.toggle("invalid", !validation.ok); + if (generatePatchButton) generatePatchButton.disabled = true; + if (patchStatusEl) { + const current = validation.rect || liveRect; + patchStatusEl.textContent = validation.ok + ? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.` + : `${validation.reason} Current: ${formatRectSize(current)}.`; + patchStatusEl.classList.toggle("invalid", !validation.ok); + } +} + +function updateSelectionOverlayFromWorldRect() { + if (!selectionEl || !state.selectionRect || !state.camera || !activeMap()) return; + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + const map = activeMap(); + const cameraX = Math.round(state.camera.x || 0); + const cameraY = Math.round(state.camera.y || 0); + const vx0 = (state.selectionRect.x0 - cameraX) / map.width * rect.width; + const vy0 = (state.selectionRect.y0 - cameraY) / map.height * rect.height; + const vx1 = (state.selectionRect.x1 - cameraX) / map.width * rect.width; + const vy1 = (state.selectionRect.y1 - cameraY) / map.height * rect.height; + const x0 = Math.min(Math.max(Math.min(vx0, vx1), 0), rect.width); + const y0 = Math.min(Math.max(Math.min(vy0, vy1), 0), rect.height); + const x1 = Math.min(Math.max(Math.max(vx0, vx1), 0), rect.width); + const y1 = Math.min(Math.max(Math.max(vy0, vy1), 0), rect.height); + if (x1 - x0 < 1 || y1 - y0 < 1) { + selectionEl.style.display = "none"; return; } - const dt = panState.lastTime ? Math.min(0.05, (time - panState.lastTime) / 1000) : 0; - panState.lastTime = time; - let dx = 0; - let dy = 0; - if (panState.keys.has("a")) dx -= 1; - if (panState.keys.has("d")) dx += 1; - if (panState.keys.has("w")) dy -= 1; - if (panState.keys.has("s")) dy += 1; - if (dx || dy) { - const normalizer = dx && dy ? Math.SQRT1_2 : 1; - const amount = panState.speedPxPerSecond * dt; - canvasShell.scrollLeft += dx * normalizer * amount; - canvasShell.scrollTop += dy * normalizer * amount; - tooltipEl?.classList.remove("visible"); + selectionEl.style.display = "block"; + selectionEl.style.left = `${canvas.offsetLeft + x0}px`; + selectionEl.style.top = `${canvas.offsetTop + y0}px`; + selectionEl.style.width = `${Math.max(1, x1 - x0)}px`; + selectionEl.style.height = `${Math.max(1, y1 - y0)}px`; + const validation = validatePatchRect(state.selectionRect, state.world); + selectionEl.classList.toggle("invalid", !validation.ok); +} + +function formatRectSize(rect) { + if (!rect) return "-"; + const w = Math.max(0, rect.x1 - rect.x0); + const h = Math.max(0, rect.y1 - rect.y0); + return `${w} x ${h} cells / ${(w * h).toLocaleString()} cells`; +} + +function updatePatchControls() { + if (!patchStatusEl && !generatePatchButton) return; + const validation = validatePatchRect(state.selectionRect, state.world); + if (generatePatchButton) generatePatchButton.disabled = !validation.ok; + if (!patchStatusEl) return; + if (!state.selectionRect) { + patchStatusEl.textContent = `Right-drag an area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells.`; + patchStatusEl.classList.toggle("invalid", false); + return; } - panState.raf = requestAnimationFrame(panFrame); + if (!validation.ok) { + patchStatusEl.textContent = `${validation.reason} Current: ${formatRectSize(validation.rect || state.selectionRect)}.`; + patchStatusEl.classList.toggle("invalid", true); + return; + } + const rects = buildPatchRects(validation.rect, state.world); + const patchText = state.lastPatchResult + ? ` Last patch: ${state.lastPatchResult.label}, terrain ${state.lastPatchResult.updatedCells.toLocaleString()} cells, coast ${state.lastPatchResult.coastCellsChanged || 0}, natural ${state.lastPatchResult.naturalRegionsUpdated || 0}${state.lastPatchResult.humanGeography?.ok ? `, connectors ${(state.lastPatchResult.humanGeography.roadConnectorsCreated || 0) + (state.lastPatchResult.humanGeography.railwayConnectorsCreated || 0)}, admin ${state.lastPatchResult.humanGeography.adminCellsReassigned || 0}, invalid ports ${state.lastPatchResult.humanGeography.invalidPortsRemoved || 0}` : ""}.` + : ""; + patchStatusEl.textContent = `Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`; + patchStatusEl.classList.toggle("invalid", false); } -function startKeyboardPan() { - if (panState.raf == null) panState.raf = requestAnimationFrame(panFrame); +function clearDragMode() { + dragState.mode = null; + dragState.pointerId = null; + dragState.pendingCamera = null; + if (dragState.panRaf != null) { + cancelAnimationFrame(dragState.panRaf); + dragState.panRaf = null; + } + canvasShell?.classList.remove("panning", "selecting"); } -function handlePanKeyDown(event) { - const key = event.key?.toLowerCase?.(); - if (!key || !"wasd".includes(key) || isEditableTarget(event.target)) return; - panState.keys.add(key); - startKeyboardPan(); +function schedulePanRedraw(camera) { + dragState.pendingCamera = camera; + if (dragState.panRaf != null) return; + dragState.panRaf = requestAnimationFrame(() => { + dragState.panRaf = null; + if (!dragState.pendingCamera) return; + const next = dragState.pendingCamera; + dragState.pendingCamera = null; + if (next.x === state.camera.x && next.y === state.camera.y) return; + state.camera = next; + redraw({ fastTerrain: true }); + }); +} + +function hideSelectionOverlay() { + dragState.selectStart = null; + dragState.selectEnd = null; + state.selectionRect = null; + if (selectionEl) selectionEl.style.display = "none"; + updatePatchControls(); +} + +function selectionPixelsToCells(start, end) { + const map = activeMap(); + if (!map || !start || !end) return null; + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + const localX0 = Math.floor(Math.min(start.x, end.x) / rect.width * map.width); + const localY0 = Math.floor(Math.min(start.y, end.y) / rect.height * map.height); + const localX1 = Math.ceil(Math.max(start.x, end.x) / rect.width * map.width); + const localY1 = Math.ceil(Math.max(start.y, end.y) / rect.height * map.height); + const cameraX = Math.round(state.camera?.x || 0); + const cameraY = Math.round(state.camera?.y || 0); + return { + x0: cameraX + Math.min(Math.max(localX0, 0), map.width - 1), + y0: cameraY + Math.min(Math.max(localY0, 0), map.height - 1), + x1: cameraX + Math.min(Math.max(localX1, 1), map.width), + y1: cameraY + Math.min(Math.max(localY1, 1), map.height), + }; +} + +function handleMapPointerDown(event) { + if (!state.world || !canvasShell) return; + if (event.button !== 0 && event.button !== 2) return; + dragState.pointerId = event.pointerId; + dragState.startClientX = event.clientX; + dragState.startClientY = event.clientY; + dragState.startCameraX = state.camera.x; + dragState.startCameraY = state.camera.y; + tooltipEl?.classList.remove("visible"); + + if (event.button === 0) { + dragState.mode = "pan"; + canvasShell.classList.add("panning"); + } else { + dragState.mode = "select"; + dragState.selectStart = clampCanvasPoint(event); + dragState.selectEnd = dragState.selectStart; + canvasShell.classList.add("selecting"); + updateSelectionOverlay(); + } + + canvas.setPointerCapture?.(event.pointerId); event.preventDefault(); } -function handlePanKeyUp(event) { - const key = event.key?.toLowerCase?.(); - if (!key || !"wasd".includes(key)) return; - panState.keys.delete(key); +function handleMapPointerMove(event) { + if (!dragState.mode || dragState.pointerId !== event.pointerId || !canvasShell) return; + tooltipEl?.classList.remove("visible"); + + if (dragState.mode === "pan") { + const dxCells = Math.round((event.clientX - dragState.startClientX) / CELL_SIZE); + const dyCells = Math.round((event.clientY - dragState.startClientY) / CELL_SIZE); + const nextCamera = clampCameraToWorld({ + x: dragState.startCameraX - dxCells, + y: dragState.startCameraY - dyCells, + }, state.world, MAP_W, MAP_H); + schedulePanRedraw(nextCamera); + } else if (dragState.mode === "select") { + dragState.selectEnd = clampCanvasPoint(event); + updateSelectionOverlay(); + } + + event.preventDefault(); +} + +function handleMapPointerUp(event) { + if (dragState.pointerId !== event.pointerId) return; + const wasPanning = dragState.mode === "pan"; + if (dragState.mode === "select") { + dragState.selectEnd = clampCanvasPoint(event); + const width = Math.abs(dragState.selectEnd.x - dragState.selectStart.x); + const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y); + if (width >= 4 && height >= 4) { + state.selectionRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd); + updateSelectionOverlayFromWorldRect(); + updatePatchControls(); + } else { + hideSelectionOverlay(); + } + } + canvas.releasePointerCapture?.(event.pointerId); + if (wasPanning && dragState.pendingCamera) { + state.camera = dragState.pendingCamera; + dragState.pendingCamera = null; + } + clearDragMode(); + if (wasPanning) redraw({ fastTerrain: false }); event.preventDefault(); } @@ -283,29 +483,35 @@ function prefectureNameForCell(map, i) { } function updateTooltip(event) { - if (!state.map || !tooltipEl) return; + const map = activeMap(); + if (!map || !tooltipEl || dragState.mode) return; const rect = canvas.getBoundingClientRect(); const cell = mapClientToCell(event); if (!cell) return; const { x, y } = cell; - if (x < 0 || y < 0 || x >= state.map.width || y >= state.map.height) { + if (x < 0 || y < 0 || x >= map.width || y >= map.height) { tooltipEl.classList.remove("visible"); return; } - const i = y * state.map.width + x; + const i = y * map.width + x; + const worldCell = viewportCellToWorldCell({ x, y }); const entity = nearestEntity(state.hoverEntities, x, y); - const elevation = state.map.elevation?.[i] ?? 0; - const density = state.map.populationDensity?.[i] ?? 0; - const hoveredAdminId = state.map.adminId?.[i] ?? -1; - const hoveredAdminPopulation = adminPopulation(state.map, hoveredAdminId); + const elevation = map.elevation?.[i] ?? 0; + const density = map.populationDensity?.[i] ?? 0; + const hoveredAdminId = map.adminId?.[i] ?? -1; + const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId); + const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`; + const entityTitle = entity + ? `${entity.name || entity.facilityLabel || entity.kind || "Feature"} / ${entity.kind || "Feature"}` + : coordinateText; const lines = [ - `${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`}`, - `Prefecture: ${prefectureNameForCell(state.map, i)}`, - `Admin: ${adminName(state.map, hoveredAdminId)}`, + `${entityTitle}`, + `Prefecture: ${prefectureNameForCell(map, i)}`, + `Admin: ${adminName(map, hoveredAdminId)}`, `Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`, - `Land: ${state.map.sea?.[i] ? "Sea" : landuseName(state.map.landuse?.[i])}`, - `Elevation: ${elevation.toFixed(3)} / Slope: ${(state.map.slope?.[i] ?? 0).toFixed(3)}`, - `River: ${(state.map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`, + `Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`, + `Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`, + `River: ${(map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`, ]; if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`); tooltipEl.innerHTML = lines.join("
"); @@ -343,7 +549,10 @@ async function regenerate() { await nextFrame(); try { state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress, terrainType: state.generationType }); - state.hoverEntities = buildHoverEntities(state.map); + state.world = createWorldMap(state.map); + state.camera = createInitialCamera(state.world); + state.lastPatchResult = null; + hideSelectionOverlay(); renderStats(state.map); redraw(); if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`; @@ -355,13 +564,62 @@ async function regenerate() { } } -function redraw() { - if (!state.map) return; - drawMap(canvas, state.map, { + +function derivePatchSeed(rect, terrainType) { + let h = parseSeed(state.seedText) ^ 0x9e3779b9; + h = Math.imul(h ^ (rect.x0 | 0), 1664525) >>> 0; + h = Math.imul(h ^ (rect.y0 | 0), 1013904223) >>> 0; + h = Math.imul(h ^ (rect.x1 | 0), 2246822519) >>> 0; + h = Math.imul(h ^ (rect.y1 | 0), 3266489917) >>> 0; + for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0; + return h >>> 0; +} + +async function generateSelectedPatch() { + const validation = validatePatchRect(state.selectionRect, state.world); + if (!validation.ok) { + updatePatchControls(); + return; + } + const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto"; + const seed = derivePatchSeed(validation.rect, terrainType); + setProgressVisible(true, "Generating selected patch..."); + await nextFrame(); + try { + const result = generatePatch(state.world, validation.rect, { terrainType, seed }); + if (!result.ok) { + if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`; + updatePatchControls(); + window.setTimeout(() => setProgressVisible(false), 1200); + return; + } + state.lastPatchResult = result; + redraw(); + renderStats(state.map); + updatePatchControls(); + const human = result.humanGeography; + const humanText = human?.ok ? ` / human: ${human.modernCities || 0} cities, ${human.ports || 0} ports, ${human.villages || 0} villages, ${(human.roadConnectorsCreated || 0) + (human.railwayConnectorsCreated || 0)} connectors, admin ${human.adminCellsReassigned || 0}, invalid ports ${human.invalidPortsRemoved || 0}` : ""; + if (progressStageEl) progressStageEl.textContent = `Patch generated: ${result.label} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}`; + renderTimingRows([]); + window.setTimeout(() => setProgressVisible(false), 900); + } catch (error) { + if (progressStageEl) progressStageEl.textContent = `Patch failed: ${error?.message || error}`; + throw error; + } +} + +function redraw(options = {}) { + if (!state.world) return; + state.camera = clampCameraToWorld(state.camera, state.world, MAP_W, MAP_H); + state.viewportMap = getViewportMap(state.world, state.camera, MAP_W, MAP_H); + state.hoverEntities = buildHoverEntities(state.viewportMap); + drawMap(canvas, state.viewportMap, { mode: state.mode, showFeatures: state.showFeatures, - showLabels: state.showLabels, + showLabels: state.showLabels && !options.fastTerrain, + continuousTerrain: !options.fastTerrain, }); + if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect(); } function init() { @@ -373,6 +631,8 @@ function init() { }); generationTypeInput?.addEventListener("change", regenerate); + patchTerrainTypeInput?.addEventListener("change", updatePatchControls); + generatePatchButton?.addEventListener("click", generateSelectedPatch); randomSeedButton.addEventListener("click", () => { seedInput.value = String(Math.floor(Math.random() * 9999999)); @@ -390,13 +650,17 @@ function init() { }); canvasShell?.setAttribute("tabindex", "0"); - window.addEventListener("keydown", handlePanKeyDown); - window.addEventListener("keyup", handlePanKeyUp); + canvas.addEventListener("contextmenu", (event) => event.preventDefault()); + canvas.addEventListener("pointerdown", handleMapPointerDown); + canvas.addEventListener("pointermove", handleMapPointerMove); + canvas.addEventListener("pointerup", handleMapPointerUp); + canvas.addEventListener("pointercancel", handleMapPointerUp); canvas.addEventListener("mousemove", updateTooltip); canvas.addEventListener("mouseleave", () => { tooltipEl?.classList.remove("visible"); }); + updatePatchControls(); regenerate(); } diff --git a/index.html b/index.html index a60db97..3ace528 100644 --- a/index.html +++ b/index.html @@ -13,12 +13,13 @@

Prefecture Map Generator v17

-

Terrain, municipalities, transport, land use, and hover inspection in one generated map.

+

Terrain, municipalities, transport, viewport panning, patch terrain generation, and hover inspection in one generated map.

+