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); }