From a6e66f2838f36917975fe3e0c51e43b4d45228b8 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 26 May 2026 21:14:37 +0900 Subject: [PATCH] road update --- adminRegionsCore.js | 72 +++- app.js | 3 +- index.html | 2 +- mapAdminStage.js | 814 ++++++++++++++++++++++++++++++++++++++++++-- mapFeatures.js | 813 ++++++++++++++++++++++++++++++++++++++----- mapOutput.js | 280 +++++++++++++-- mapTerrain.js | 282 +++++++++++---- names.js | 60 ++-- renderer.js | 106 ++++-- 9 files changed, 2174 insertions(+), 258 deletions(-) diff --git a/adminRegionsCore.js b/adminRegionsCore.js index 706cdb5..5e964a7 100644 --- a/adminRegionsCore.js +++ b/adminRegionsCore.js @@ -703,7 +703,7 @@ function naturalGroupKey(unit) { if (unit.classId <= 3) return `urban:${Math.round(unit.x / 10)}:${Math.round(unit.y / 10)}`; if (unit.classId === 5) return `coast:${Math.round(unit.y / 8)}`; if (unit.classId === 6) return `basin:${Math.round(unit.x / 12)}:${Math.round(unit.y / 12)}`; - if (unit.classId === 7) return `valley:${Math.round((unit.x + unit.y) / 12)}`; + if (unit.classId === 7) return `valley:${Math.round(unit.x / 11)}:${Math.round(unit.y / 11)}`; if (unit.classId === 8 || unit.classId === 9) return `mountain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`; return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`; } @@ -1030,6 +1030,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); mergeTinyLandscapeUnits(compartmentId, compartments, 9); + mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(42, Math.round(landArea / Math.max(1, targetCount) * 2.15)), 5); progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`); splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); @@ -1067,6 +1068,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r } } + mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(48, Math.round(landArea / Math.max(1, targetCount) * 2.05)), 4); splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); refreshAllCompartmentStats(compartments, fields); @@ -1137,10 +1139,11 @@ function rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMas const b = unitId[ni]; if (b < 0 || b === a || !units[b] || units[b].area === 0) continue; const v = (targetScore[i] + targetScore[ni]) * 0.5; - const keyA = units[a].adjacent.get(b) || { count: 0, target: 0 }; - keyA.count++; keyA.target += v; units[a].adjacent.set(b, keyA); - const keyB = units[b].adjacent.get(a) || { count: 0, target: 0 }; - keyB.count++; keyB.target += v; units[b].adjacent.set(a, keyB); + const vertical = nx !== x ? 1 : 0; + const keyA = units[a].adjacent.get(b) || { count: 0, target: 0, vertical: 0, horizontal: 0 }; + keyA.count++; keyA.target += v; if (vertical) keyA.vertical++; else keyA.horizontal++; units[a].adjacent.set(b, keyA); + const keyB = units[b].adjacent.get(a) || { count: 0, target: 0, vertical: 0, horizontal: 0 }; + keyB.count++; keyB.target += v; if (vertical) keyB.vertical++; else keyB.horizontal++; units[b].adjacent.set(a, keyB); } } } @@ -1172,6 +1175,65 @@ function mergeTinyLandscapeUnits(unitId, units, minArea = 10) { } } + +function mergeUnitInto(unitId, units, fromId, toId, fields) { + const from = units[fromId]; + const to = units[toId]; + if (!from || !to || from.area === 0 || to.area === 0 || fromId === toId) return false; + for (const ci of from.cells) { unitId[ci] = toId; to.cells.push(ci); } + from.area = 0; + from.cells = []; + refreshCompartmentStats(to, fields.elevation, fields.slope, fields.ridgeField, fields.valleyField, fields.basinField, fields.coastalLowland, fields.plain, fields.agriculture, fields.populationDensity, fields.landuse); + return true; +} + +function mergeWeakArtificialLandscapeUnits(unitId, units, fields, prefectureMask, sea, maxMergedArea = 84, passes = 5) { + // Seeded graph growth can create diagonal stair-step borders in uniform plains + // and gentle hills. If the shared edge is weak, balanced H/V, and the two + // sides are the same natural group, merge it instead of preserving an + // artificial Voronoi-like cut. + for (let pass = 0; pass < passes; pass++) { + rebuildLandscapeUnitAdjacency(unitId, units, fields.naturalBarrierScore, prefectureMask, sea); + let best = null; + let bestScore = 0.0; + for (const unit of units) { + if (!unit || unit.area === 0) continue; + for (const [otherId, edge] of unit.adjacent) { + if (otherId <= unit.id) continue; + const other = units[otherId]; + if (!other || other.area === 0) continue; + const avgBarrier = edge.target / Math.max(1, edge.count); + const sameClass = unit.classId === other.classId; + const sameGroup = naturalGroupKey(unit) === naturalGroupKey(other); + const combinedArea = unit.area + other.area; + if (!sameClass && !sameGroup) continue; + if (combinedArea > maxMergedArea && unit.area > 18 && other.area > 18) continue; + const h = edge.horizontal || 0; + const v = edge.vertical || 0; + const balancedStair = Math.min(h, v) / Math.max(1, h + v); + const lowlandContinuity = Math.min(unit.lowlandFitness || 0, other.lowlandFitness || 0); + const mountainContinuity = Math.min(unit.mountainFitness || 0, other.mountainFitness || 0); + const urbanGuard = Math.max(unit.urbanWeight || 0, other.urbanWeight || 0); + const weakDivider = avgBarrier < (sameClass ? 0.46 : 0.36); + if (!weakDivider) continue; + const score = + (sameClass ? 1.7 : 0) + + (sameGroup ? 1.2 : 0) + + balancedStair * 1.4 + + edge.count * 0.018 + + lowlandContinuity * 0.85 + + mountainContinuity * 0.35 - + avgBarrier * 4.8 - + Math.max(0, combinedArea - maxMergedArea) * 0.020 - + urbanGuard * 0.20; + if (score > bestScore) best = { fromId: unit.area <= other.area ? unit.id : otherId, toId: unit.area <= other.area ? otherId : unit.id }, bestScore = score; + } + } + if (!best) break; + mergeUnitInto(unitId, units, best.fromId, best.toId, fields); + } +} + function naturalOwnershipAffinity(unit, neighbor, edge) { const boundaryTarget = edge.target / Math.max(1, edge.count); const sameClass = unit.classId === neighbor.classId ? 1.0 : 0; diff --git a/app.js b/app.js index 772e569..0190ccf 100644 --- a/app.js +++ b/app.js @@ -10,8 +10,8 @@ const modes = [ ["development", "Development"], ["landuse", "Land Use"], ["admin", "Municipal Borders"], - ["admin-debug", "Admin Debug"], ["borders-debug", "Borders Debug"], + ["transport-debug", "Transport Debug"], ]; const state = { @@ -148,7 +148,6 @@ function getStats(map) { ["Logistics Parks", countText(map.logisticsParks)], ["New Towns", countText(map.newTowns)], ["Municipalities", map.adminCenters.length], - ["Admin changed cells", map.adminDebug ? `${map.adminDebug.changedAfterLandscapePartition || 0} partition / ${map.adminDebug.changedAfterSnap || 0} snap` : "-"], ["Prefecture source", map.regionalDebug?.prefectureSource ?? "-"], ]; } diff --git a/index.html b/index.html index 7a0c4e4..1179b32 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Prefecture Map Generator v17 + Prefecture Map Generator diff --git a/mapAdminStage.js b/mapAdminStage.js index 284f792..1ae38bf 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -99,13 +99,440 @@ function enforceCompartmentMunicipalityOwnership(adminId, compartmentId, compart return changed; } -function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity) { + +function mergeSingleCompartmentMunicipalities(adminId, compartments, prefectureMask, sea, minCompartments = 2, maxPasses = 8) { + if (!compartments?.length) return { changedCells: 0, mergedMunicipalities: 0, remainingSingleCompartmentMunicipalities: 0 }; + let totalChangedCells = 0; + let mergedMunicipalities = 0; + let remainingSingles = 0; + for (let pass = 0; pass < maxPasses; pass++) { + const owner = dominantCompartmentOwners(compartments, adminId); + const byOwner = new Map(); + const areaByOwner = new Map(); + for (const unit of compartments) { + if (!unit || unit.area === 0) continue; + const id = owner[unit.id]; + if (id < 0) continue; + if (!byOwner.has(id)) byOwner.set(id, []); + byOwner.get(id).push(unit); + areaByOwner.set(id, (areaByOwner.get(id) || 0) + unit.area); + } + const singles = [...byOwner.entries()] + .filter(([, units]) => units.length > 0 && units.length < minCompartments) + .sort((a, b) => (areaByOwner.get(a[0]) || 0) - (areaByOwner.get(b[0]) || 0) || a[0] - b[0]); + remainingSingles = singles.length; + if (!singles.length) break; + let passChanged = 0; + for (const [id, units] of singles) { + // The old rule allowed one natural compartment to become one municipality. + // That produces many tiny office-only municipalities and makes the hierarchy + // hard to read. Merge such municipalities into the strongest adjacent owner. + const neighborScores = new Map(); + for (const unit of units) { + for (const [neighborId, edge] of unit.adjacent || []) { + const candidate = owner[neighborId]; + if (candidate < 0 || candidate === id) continue; + const neighborUnit = compartments[neighborId]; + const shared = edge.count || 1; + const barrier = edge.target ? edge.target / Math.max(1, shared) : 0; + const sameLandscape = neighborUnit?.classId === unit.classId ? 0.7 : 0; + const score = shared * (2.2 - Math.min(1.6, barrier) + sameLandscape) + Math.sqrt(areaByOwner.get(candidate) || 1) * 0.05; + neighborScores.set(candidate, (neighborScores.get(candidate) || 0) + score); + } + } + let best = -1, bestScore = -INF; + for (const [candidate, score] of neighborScores) { + if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; } + } + if (best < 0) { + // One-cell islets have no land adjacency. Attach them to the nearest + // existing municipality instead of leaving a one-compartment municipality. + const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); + const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); + let bestDist = INF; + for (const [candidate, candidateUnits] of byOwner) { + if (candidate === id || candidateUnits.length < minCompartments) continue; + for (const unit of candidateUnits) { + const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy); + if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; } + } + } + if (bestDist > 28) best = -1; + } + if (best < 0) continue; + for (const unit of units) { + for (const i of unit.cells || []) { + if (!prefectureMask[i] || sea[i]) continue; + if (adminId[i] !== best) { + adminId[i] = best; + passChanged++; + } + } + } + mergedMunicipalities++; + } + totalChangedCells += passChanged; + if (!passChanged) break; + } + const finalOwner = dominantCompartmentOwners(compartments, adminId); + const finalCounts = new Map(); + for (const unit of compartments) { + if (!unit || unit.area === 0) continue; + const id = finalOwner[unit.id]; + if (id >= 0) finalCounts.set(id, (finalCounts.get(id) || 0) + 1); + } + remainingSingles = [...finalCounts.values()].filter((count) => count > 0 && count < minCompartments).length; + return { changedCells: totalChangedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities: remainingSingles }; +} + +function ownerAreaByCompartment(owner, compartments) { + const area = new Map(); + const count = new Map(); + for (const unit of compartments || []) { + if (!unit || unit.area === 0) continue; + const id = owner[unit.id]; + if (id < 0) continue; + area.set(id, (area.get(id) || 0) + (unit.area || 0)); + count.set(id, (count.get(id) || 0) + 1); + } + return { area, count }; +} + +function compartmentTouchesOutside(unit, prefectureMask, sea) { + if (!unit?.cells?.length) return true; + for (const i of unit.cells) { + const [x, y] = xyOf(i); + if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) return true; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) return true; + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) return true; + } + } + return false; +} + +function bestNeighborOwnerForUnits(units, owner, compartments, allowNearestFallback = false) { + const { area } = ownerAreaByCompartment(owner, compartments); + const scores = new Map(); + const blocked = new Set(units.map((unit) => owner[unit.id])); + for (const unit of units) { + for (const [neighborId, edge] of unit.adjacent || []) { + const candidate = owner[neighborId]; + if (candidate < 0 || blocked.has(candidate)) continue; + const neighbor = compartments[neighborId]; + const shared = edge.count || 1; + const barrier = (edge.target || 0) / Math.max(1, shared); + const landscape = neighbor?.classId === unit.classId ? 0.75 : 0; + const lowland = Math.min(unit.lowlandFitness || 0, neighbor?.lowlandFitness || 0) * 0.5; + const score = shared * (2.4 + landscape + lowland - Math.min(1.8, barrier)) + Math.sqrt(area.get(candidate) || 1) * 0.04; + scores.set(candidate, (scores.get(candidate) || 0) + score); + } + } + let best = -1, bestScore = -INF; + for (const [candidate, score] of scores) { + if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; } + } + if (best >= 0 || !allowNearestFallback) return best; + const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); + const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0)); + let bestDist = INF; + for (const unit of compartments || []) { + if (!unit || unit.area === 0) continue; + const candidate = owner[unit.id]; + if (candidate < 0 || blocked.has(candidate)) continue; + const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy); + if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; } + } + return bestDist <= 32 ? best : -1; +} + +function mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, minCompartments = 2, maxPasses = 8) { + let changedCells = 0; + let mergedMunicipalities = 0; + let remainingSingleCompartmentMunicipalities = 0; + for (let pass = 0; pass < maxPasses; pass++) { + const byOwner = new Map(); + for (const unit of compartments || []) { + if (!unit || unit.area === 0) continue; + const id = owner[unit.id]; + if (id < 0) continue; + if (!byOwner.has(id)) byOwner.set(id, []); + byOwner.get(id).push(unit); + } + const small = [...byOwner.entries()] + .filter(([, units]) => units.length > 0 && units.length < minCompartments) + .sort((a, b) => a[1].length - b[1].length || a[0] - b[0]); + remainingSingleCompartmentMunicipalities = small.length; + if (!small.length) break; + let passChanged = 0; + for (const [id, units] of small) { + const target = bestNeighborOwnerForUnits(units, owner, compartments, true); + if (target < 0 || target === id) continue; + for (const unit of units) { + if (owner[unit.id] === target) continue; + owner[unit.id] = target; + changedCells += unit.area || 0; + passChanged += unit.area || 0; + } + mergedMunicipalities++; + } + if (!passChanged) break; + } + const counts = ownerAreaByCompartment(owner, compartments).count; + remainingSingleCompartmentMunicipalities = [...counts.values()].filter((count) => count > 0 && count < minCompartments).length; + return { changedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities }; +} + +function repairCompartmentOwnerConnectivity(owner, compartments, maxPasses = 8) { + let changedCells = 0; + let changedComponents = 0; + for (let pass = 0; pass < maxPasses; pass++) { + const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b); + let passChanged = 0; + for (const id of ownerIds) { + const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id); + if (members.length <= 1) continue; + const memberSet = new Set(members.map((unit) => unit.id)); + const seen = new Set(); + const components = []; + for (const unit of members) { + if (seen.has(unit.id)) continue; + const queue = [unit.id]; + const comp = []; + seen.add(unit.id); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(compartments[cur]); + for (const next of compartments[cur]?.adjacent?.keys?.() || []) { + if (!memberSet.has(next) || seen.has(next)) continue; + seen.add(next); + queue.push(next); + } + } + components.push(comp); + } + if (components.length <= 1) continue; + components.sort((a, b) => b.reduce((sum, unit) => sum + (unit.area || 0), 0) - a.reduce((sum, unit) => sum + (unit.area || 0), 0)); + for (const comp of components.slice(1)) { + const target = bestNeighborOwnerForUnits(comp, owner, compartments, true); + if (target < 0 || target === id) continue; + for (const unit of comp) { + owner[unit.id] = target; + changedCells += unit.area || 0; + passChanged += unit.area || 0; + } + changedComponents++; + } + } + if (!passChanged) break; + } + return { changedCells, changedComponents }; +} + +function repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, maxPasses = 6) { + let changedCells = 0; + let changedComponents = 0; + const outsideCache = new Map(); + const touchesOutside = (unit) => { + if (!outsideCache.has(unit.id)) outsideCache.set(unit.id, compartmentTouchesOutside(unit, prefectureMask, sea)); + return outsideCache.get(unit.id); + }; + for (let pass = 0; pass < maxPasses; pass++) { + const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b); + let passChanged = 0; + for (const id of ownerIds) { + const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id); + if (!members.length) continue; + const memberSet = new Set(members.map((unit) => unit.id)); + const seen = new Set(); + for (const unit of members) { + if (seen.has(unit.id)) continue; + const queue = [unit.id]; + const comp = []; + const boundaryOwners = new Map(); + let outside = false; + seen.add(unit.id); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const curUnit = compartments[cur]; + if (!curUnit) continue; + comp.push(curUnit); + if (touchesOutside(curUnit)) outside = true; + for (const [next, edge] of curUnit.adjacent || []) { + const nextOwner = owner[next]; + if (nextOwner === id) { + if (!seen.has(next) && memberSet.has(next)) { seen.add(next); queue.push(next); } + } else if (nextOwner >= 0) { + boundaryOwners.set(nextOwner, (boundaryOwners.get(nextOwner) || 0) + (edge.count || 1)); + } + } + } + if (outside || boundaryOwners.size !== 1) continue; + const [target] = boundaryOwners.keys(); + if (target < 0 || target === id) continue; + for (const compUnit of comp) { + owner[compUnit.id] = target; + changedCells += compUnit.area || 0; + passChanged += compUnit.area || 0; + } + changedComponents++; + } + } + if (!passChanged) break; + } + return { changedCells, changedComponents }; +} + +function lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, maxCells = 1100) { + const isUrbanUnit = (unit) => unit && unit.area > 0 && ( + unit.classId <= 3 || + (unit.urbanWeight || 0) >= 0.34 || + ((unit.urbanWeight || 0) >= 0.22 && (unit.lowlandFitness || 0) >= 0.34) + ); + const seen = new Set(); + let changedCells = 0; + let unifiedComponents = 0; + for (const start of compartments || []) { + if (!isUrbanUnit(start) || seen.has(start.id)) continue; + const queue = [start.id]; + const comp = []; + seen.add(start.id); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const unit = compartments[cur]; + if (!isUrbanUnit(unit)) continue; + comp.push(unit); + for (const next of unit.adjacent?.keys?.() || []) { + if (seen.has(next) || !isUrbanUnit(compartments[next])) continue; + seen.add(next); + queue.push(next); + } + } + const totalArea = comp.reduce((sum, unit) => sum + (unit.area || 0), 0); + if (comp.length <= 1 || totalArea <= 0 || totalArea > maxCells) continue; + const ownerScore = new Map(); + for (const unit of comp) { + const id = owner[unit.id]; + if (id < 0) continue; + const score = (unit.area || 0) * (1 + (unit.urbanWeight || 0) * 1.8); + ownerScore.set(id, (ownerScore.get(id) || 0) + score); + } + let best = -1, bestScore = -INF; + for (const [id, score] of ownerScore) if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; } + if (best < 0) continue; + let localChanged = 0; + for (const unit of comp) { + if (owner[unit.id] === best) continue; + owner[unit.id] = best; + localChanged += unit.area || 0; + } + if (localChanged > 0) { + changedCells += localChanged; + unifiedComponents++; + } + } + return { changedCells, unifiedComponents }; +} + + +function lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields = {}) { + const { modernCities = [] } = fields; + if (!owner || !compartments?.length || !modernCities?.length) return { changedCells: 0, unifiedCities: 0 }; + let changedCells = 0; + let unifiedCities = 0; + const urbanUnit = (unit) => unit && unit.area > 0 && ( + unit.classId <= 3 || + (unit.urbanWeight || 0) >= 0.24 || + ((unit.urbanWeight || 0) >= 0.16 && (unit.lowlandFitness || 0) >= 0.40) + ); + for (const city of modernCities) { + if (!city || !Number.isFinite(city.x) || !Number.isFinite(city.y) || (city.population || 0) < 18000) continue; + const radius = clamp( + (city.urbanRadius || 8) * ((city.population || 0) >= 200000 ? 1.95 : (city.population || 0) >= 80000 ? 1.65 : 1.35), + 8, + (city.population || 0) >= 200000 ? 34 : 24 + ); + const units = []; + for (const unit of compartments) { + if (!urbanUnit(unit)) continue; + const d = Math.hypot((unit.x || 0) - city.x, (unit.y || 0) - city.y); + if (d > radius) continue; + const weight = (unit.area || 1) * + (1 + (unit.urbanWeight || 0) * 2.4 + (unit.lowlandFitness || 0) * 0.55) * + Math.max(0.20, 1 - d / Math.max(1, radius) * 0.58); + units.push({ unit, weight, d }); + } + if (units.length <= 1) continue; + const totalArea = units.reduce((sum, row) => sum + (row.unit.area || 0), 0); + // Large multi-core conurbations may legitimately contain multiple municipalities. + // This pass targets compact urban areas that visually read as one city. + const maxArea = (city.population || 0) >= 200000 ? 1800 : 900; + if (totalArea > maxArea) continue; + const ownerScore = new Map(); + for (const row of units) { + const id = owner[row.unit.id]; + if (id < 0) continue; + ownerScore.set(id, (ownerScore.get(id) || 0) + row.weight); + } + if (ownerScore.size <= 1) continue; + let best = -1, bestScore = -INF, totalScore = 0; + for (const [id, score] of ownerScore) { + totalScore += score; + if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; } + } + if (best < 0 || bestScore / Math.max(1, totalScore) < 0.28) continue; + let localChanged = 0; + for (const row of units) { + if (owner[row.unit.id] === best) continue; + owner[row.unit.id] = best; + localChanged += row.unit.area || 0; + } + if (localChanged > 0) { + changedCells += localChanged; + unifiedCities++; + } + } + return { changedCells, unifiedCities }; +} + +function enforceSimpleAdministrativeHierarchy(adminId, compartments, prefectureMask, sea, fields = {}) { + const owner = dominantCompartmentOwners(compartments, adminId); + const urban = lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, fields.maxUrbanClusterCells || 1100); + const metro = lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields); + const connectivity1 = repairCompartmentOwnerConnectivity(owner, compartments, 8); + const enclave1 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6); + const singleMerge = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 8); + const connectivity2 = repairCompartmentOwnerConnectivity(owner, compartments, 8); + const enclave2 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6); + const singleMerge2 = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 4); + const connectivity3 = repairCompartmentOwnerConnectivity(owner, compartments, 4); + const enclave3 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 4); + applyCompartmentOwners(adminId, compartments, owner); + const counts = ownerAreaByCompartment(owner, compartments).count; + return { + changedAfterUrbanUnification: urban.changedCells + metro.changedCells, + urbanComponentsUnified: urban.unifiedComponents, + changedAfterCityMetroMunicipalityUnification: metro.changedCells, + cityMetroMunicipalitiesUnified: metro.unifiedCities, + changedAfterCompartmentConnectivity: connectivity1.changedCells + connectivity2.changedCells + connectivity3.changedCells, + disconnectedCompartmentComponentsMerged: connectivity1.changedComponents + connectivity2.changedComponents + connectivity3.changedComponents, + changedAfterCompartmentEnclaveRepair: enclave1.changedCells + enclave2.changedCells + enclave3.changedCells, + compartmentEnclaveComponentsMerged: enclave1.changedComponents + enclave2.changedComponents + enclave3.changedComponents, + changedAfterSingleCompartmentMunicipalityMerge: singleMerge.changedCells + singleMerge2.changedCells, + singleCompartmentMunicipalitiesMerged: singleMerge.mergedMunicipalities + singleMerge2.mergedMunicipalities, + remainingSingleCompartmentMunicipalities: [...counts.values()].filter((count) => count > 0 && count < (fields.minCompartmentsPerMunicipality || 2)).length, + }; +} + +function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, settlementFeatures = []) { const nodes = new Map(); const edges = new Map(); for (let i = 0; i < SIZE; i++) { if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; const id = adminId[i]; - if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, sx: 0, sy: 0, touchesOutside: false }); + if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, cityPopulation: 0, settlementPopulation: 0, majorCityCount: 0, sx: 0, sy: 0, touchesOutside: false }); const node = nodes.get(id); const [x, y] = xyOf(i); if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) node.touchesOutside = true; @@ -131,6 +558,21 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor edges.set(key, edge); } } + for (const feature of settlementFeatures || []) { + if (!feature || !inside(feature.x, feature.y)) continue; + const i = indexOf(feature.x, feature.y); + if (!prefectureMask[i] || sea[i]) continue; + const id = adminId[i]; + const node = nodes.get(id); + if (!node) continue; + const pop = Math.max(0, feature.population || 0); + node.settlementPopulation += pop; + if (feature.kind === "Regional Capital" || feature.kind === "Prefectural Capital" || feature.kind === "Regional City" || feature.kind === "Local City" || feature.isRegionalCapital || feature.isPrefecturalCapital) { + node.cityPopulation += pop; + node.majorCityCount += pop >= 120000 ? 1 : 0; + } + node.population += pop / 16000; + } for (const node of nodes.values()) { node.x = node.sx / Math.max(1, node.area); node.y = node.sy / Math.max(1, node.area); @@ -138,6 +580,11 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor } for (const edge of edges.values()) { edge.barrier /= Math.max(1, edge.count); + // Prefecture grouping should pay the same natural-compartment crossing + // cost that municipality generation uses: ridges, rivers, valley walls and + // other strong natural dividers should be expensive to cross. Short shared + // boundaries are also unstable, so they get a small extra penalty. + edge.crossingCost = 1.0 + edge.barrier * 8.5 + 2.6 / Math.sqrt(Math.max(1, edge.count)); nodes.get(edge.a)?.adjacent.set(edge.b, edge); nodes.get(edge.b)?.adjacent.set(edge.a, edge); } @@ -147,21 +594,44 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor function choosePrefectureMunicipalitySeeds(nodes, seed) { const active = [...nodes.values()].filter((node) => node.area > 0).sort((a, b) => b.area - a.area || a.id - b.id); const totalArea = active.reduce((sum, node) => sum + node.area, 0); - const targetCount = clamp(Math.round(totalArea / 7200), 3, 6); + // Real Japan's smallest prefecture by municipality count is roughly Toyama's 15. + // Keep generated prefectures near that scale by limiting prefecture count unless + // enough municipalities exist to give each prefecture a meaningful set. + const minMunicipalitiesPerPrefecture = 14; + const maxByMunicipalityCount = Math.max(3, Math.floor(active.length / minMunicipalitiesPerPrefecture)); + const areaBased = clamp(Math.round(totalArea / 7800), 3, 6); + const targetCount = clamp(Math.min(areaBased, maxByMunicipalityCount || areaBased), 3, 6); const seeds = []; - const first = active.sort((a, b) => (b.population * 1.8 + b.area * 0.08) - (a.population * 1.8 + a.area * 0.08) || a.id - b.id)[0]; - if (first) seeds.push(first); + const minSpacing = Math.max(18, Math.sqrt(totalArea / Math.max(1, targetCount)) * 0.46); + function tryAdd(node, relaxed = false) { + if (!node || seeds.includes(node) || seeds.length >= targetCount) return false; + const nearest = seeds.length ? Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y))) : INF; + if (!relaxed && nearest < minSpacing) return false; + seeds.push(node); + return true; + } + const capitalLike = active + .filter((node) => (node.cityPopulation || 0) >= 120000 || node.majorCityCount > 0) + .sort((a, b) => (b.cityPopulation || 0) - (a.cityPopulation || 0) || b.population - a.population || a.id - b.id); + for (const node of capitalLike) tryAdd(node, false); + for (const node of capitalLike) tryAdd(node, true); + if (!seeds.length) { + const first = active.slice().sort((a, b) => (b.population * 1.8 + b.area * 0.08) - (a.population * 1.8 + a.area * 0.08) || a.id - b.id)[0]; + if (first) seeds.push(first); + } while (seeds.length < targetCount) { let best = null, bestScore = -INF; for (const node of active) { if (seeds.includes(node)) continue; const nearest = Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y))); - const score = nearest * (1.0 + hash2(seed, node.id) * 0.08) + Math.sqrt(node.area) * 0.12 + node.population * 0.28; + const capitalBonus = Math.sqrt(Math.max(0, node.cityPopulation || 0)) * 0.05 + (node.majorCityCount || 0) * 8; + const score = nearest * (1.0 + hash2(seed, node.id) * 0.08) + Math.sqrt(node.area) * 0.12 + node.population * 0.28 + capitalBonus; if (score > bestScore) { bestScore = score; best = node; } } if (!best) break; seeds.push(best); } + seeds.minMunicipalitiesPerPrefecture = minMunicipalitiesPerPrefecture; return seeds; } @@ -186,7 +656,7 @@ function assignMunicipalitiesToPrefectures(nodes, seeds) { const next = nodes.get(nextId); if (!next) continue; const areaPressure = Math.max(0, ((area.get(cur.id) || 0) + next.area - maxArea) / Math.max(1, maxArea)); - const cost = cur.f + 1.0 + edge.barrier * 5.5 + areaPressure * 14 + hash2(cur.id, nextId) * 0.05; + const cost = cur.f + (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) + areaPressure * 14 + hash2(cur.id, nextId) * 0.05; owner.set(nextId, cur.id); area.set(cur.id, (area.get(cur.id) || 0) + next.area); heap.push({ i: nextId, id: cur.id, f: cost }); @@ -291,6 +761,108 @@ function repairPrefectureMunicipalityEnclaves(nodes, owner, maxPasses = 6) { return changed; } +function lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, context, maxCells = 2600) { + const { prefectureMask, sea, landuse, populationDensity } = context; + if (!owner || !adminId || !landuse) return 0; + const seen = new Uint8Array(SIZE); + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + const isUrban = (i) => { + const lu = landuse[i]; + return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.18; + }; + let changedMunicipalities = 0; + for (let start = 0; start < SIZE; start++) { + if (seen[start] || !prefectureMask[start] || sea[start] || adminId[start] < 0 || !isUrban(start)) continue; + const queue = [start]; + const comp = []; + seen[start] = 1; + const municipalityWeights = new Map(); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(cur); + const id = adminId[cur]; + const weight = 1 + Math.max(0, (populationDensity?.[cur] || 0) - 0.12) * 2.4 + (landuse[cur] === 3 ? 2.0 : landuse[cur] === 2 ? 1.2 : 0); + municipalityWeights.set(id, (municipalityWeights.get(id) || 0) + weight); + const [x, y] = xyOf(cur); + for (const [dx, dy] of dirs) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (seen[ni] || !prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || !isUrban(ni)) continue; + seen[ni] = 1; + queue.push(ni); + } + } + if (comp.length < 14 || comp.length > maxCells || municipalityWeights.size <= 1) continue; + const prefectureWeights = new Map(); + for (const [munId, weight] of municipalityWeights) { + const prefId = owner.get(munId); + if (prefId < 0) continue; + prefectureWeights.set(prefId, (prefectureWeights.get(prefId) || 0) + weight); + } + if (prefectureWeights.size <= 1) continue; + let bestPref = -1, bestWeight = -INF, totalWeight = 0; + for (const [prefId, weight] of prefectureWeights) { + totalWeight += weight; + if (weight > bestWeight || (weight === bestWeight && prefId < bestPref)) { bestPref = prefId; bestWeight = weight; } + } + if (bestPref < 0 || bestWeight / Math.max(1, totalWeight) < 0.34) continue; + for (const munId of municipalityWeights.keys()) { + if (owner.get(munId) === bestPref) continue; + owner.set(munId, bestPref); + changedMunicipalities++; + } + } + return changedMunicipalities; +} + +function lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, context) { + const { prefectureMask, sea, landuse, populationDensity, modernCities = [] } = context; + if (!owner || !adminId || !modernCities?.length) return 0; + let changed = 0; + const isUrban = (i) => { + const lu = landuse?.[i]; + return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.14; + }; + for (const city of modernCities) { + if (!city || (city.population || 0) < 24000 || !inside(city.x, city.y)) continue; + const centerAdmin = adminId[indexOf(city.x, city.y)]; + if (centerAdmin < 0) continue; + const centerPref = owner.get(centerAdmin); + if (centerPref < 0) continue; + const radius = clamp(Math.round((city.urbanRadius || 8) * ((city.population || 0) >= 160000 ? 1.55 : 1.25)), 7, 26); + const municipalityWeights = new Map(); + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y) || Math.hypot(dx, dy) > radius) continue; + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i] || adminId[i] < 0 || !isUrban(i)) continue; + const dist = Math.hypot(dx, dy) / Math.max(1, radius); + const weight = (1 - dist * 0.55) * (1 + (populationDensity?.[i] || 0) * 2.6 + (landuse?.[i] === 3 ? 1.6 : 0)); + municipalityWeights.set(adminId[i], (municipalityWeights.get(adminId[i]) || 0) + weight); + } + } + if (municipalityWeights.size <= 1) continue; + let total = 0, centerOwnedWeight = 0; + for (const [munId, weight] of municipalityWeights) { + total += weight; + if (owner.get(munId) === centerPref) centerOwnedWeight += weight; + } + // Only force compact city regions. If the center prefecture has almost no + // share, this is probably a genuine cross-prefecture conurbation or a city + // center on the edge; leave it to the graph repair. + if (centerOwnedWeight / Math.max(1, total) < 0.24) continue; + for (const munId of municipalityWeights.keys()) { + if (owner.get(munId) === centerPref) continue; + owner.set(munId, centerPref); + changed++; + } + } + return changed; +} + function repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea, maxPasses = 5) { let changed = 0; const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; @@ -452,7 +1024,7 @@ function mergeTinyMunicipalityPrefectures(nodes, owner) { for (const [nextId, edge] of node.adjacent) { const nextPref = owner.get(nextId); if (nextPref === tinyPref || nextPref < 0) continue; - const score = (neighborScores.get(nextPref) || 0) + edge.count * (0.6 + edge.barrier); + const score = (neighborScores.get(nextPref) || 0) + edge.count * 0.8 - (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) * 0.65; neighborScores.set(nextPref, score); } } @@ -593,17 +1165,192 @@ function extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefe return segments; } + +function prefectureMunicipalityCounts(owner) { + const counts = new Map(); + for (const pref of owner.values()) counts.set(pref, (counts.get(pref) || 0) + 1); + return counts; +} + +function ownerMembersByPref(owner) { + const by = new Map(); + for (const [id, pref] of owner) { + if (!by.has(pref)) by.set(pref, []); + by.get(pref).push(id); + } + return by; +} + +function wouldRemainConnectedAfterRemoval(nodes, owner, adminId, prefId) { + const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId && id !== adminId); + if (members.length <= 1) return true; + const memberSet = new Set(members); + const seen = new Set([members[0]]); + const queue = [members[0]]; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + for (const next of nodes.get(cur)?.adjacent.keys() || []) { + if (!memberSet.has(next) || seen.has(next)) continue; + seen.add(next); + queue.push(next); + } + } + return seen.size === members.length; +} + +function rebalanceSmallPrefecturesByMunicipalityCount(nodes, owner, minCount = 14, maxPasses = 96) { + let changed = 0; + for (let pass = 0; pass < maxPasses; pass++) { + const counts = prefectureMunicipalityCounts(owner); + const small = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0]; + if (!small) break; + const [smallPref, smallCount] = small; + let best = null, bestScore = -INF; + for (const [id, pref] of owner) { + if (pref === smallPref) continue; + const donorCount = counts.get(pref) || 0; + if (donorCount <= minCount + 1) continue; + const node = nodes.get(id); + if (!node) continue; + let edgeToSmall = null; + for (const [nextId, edge] of node.adjacent || []) { + if (owner.get(nextId) === smallPref) { + edgeToSmall = edge; + break; + } + } + if (!edgeToSmall) continue; + if (!wouldRemainConnectedAfterRemoval(nodes, owner, id, pref)) continue; + const capitalPenalty = (node.cityPopulation || 0) >= 150000 ? 18 : 0; + const donorSurplus = donorCount - minCount; + const score = (edgeToSmall.count || 1) * 2.5 - (edgeToSmall.crossingCost ?? (1.0 + (edgeToSmall.barrier || 0) * 8.5)) * 1.15 + donorSurplus * 1.6 - Math.sqrt(node.area || 1) * 0.03 - capitalPenalty; + if (score > bestScore || (score === bestScore && id < best?.id)) best = { id, pref, score }; + } + if (!best) break; + owner.set(best.id, smallPref); + changed++; + repairPrefectureMunicipalityConnectivity(nodes, owner); + } + return changed; +} + +function mergePersistentlyTinyPrefecturesByCount(nodes, owner, minCount = 12, minRemainingPrefectures = 2) { + let changed = 0; + for (let pass = 0; pass < 16; pass++) { + const counts = prefectureMunicipalityCounts(owner); + if (counts.size <= minRemainingPrefectures) break; + const tiny = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0]; + if (!tiny) break; + const [tinyPref] = tiny; + const neighborScores = new Map(); + for (const [id, pref] of owner) { + if (pref !== tinyPref) continue; + const node = nodes.get(id); + for (const [nextId, edge] of node?.adjacent || []) { + const other = owner.get(nextId); + if (other === undefined || other === tinyPref) continue; + const score = (edge.count || 1) * 2.4 - (edge.crossingCost ?? (1.0 + (edge.barrier || 0) * 8.5)) * 1.0 + Math.min(18, counts.get(other) || 0) * 0.20; + neighborScores.set(other, (neighborScores.get(other) || 0) + score); + } + } + let best = -1, bestScore = -INF; + for (const [candidate, score] of neighborScores) { + if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; } + } + if (best < 0) { + const tinyNodes = [...owner.keys()].filter((id) => owner.get(id) === tinyPref).map((id) => nodes.get(id)).filter(Boolean); + const tx = tinyNodes.reduce((sum, node) => sum + node.x * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0)); + const ty = tinyNodes.reduce((sum, node) => sum + node.y * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0)); + let bestDist = INF; + for (const [candidate, count] of counts) { + if (candidate === tinyPref || count <= 0) continue; + const candidateNodes = [...owner.keys()].filter((id) => owner.get(id) === candidate).map((id) => nodes.get(id)).filter(Boolean); + for (const node of candidateNodes) { + const d = Math.hypot(node.x - tx, node.y - ty); + if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; } + } + } + } + if (best < 0) break; + for (const [id, pref] of owner) if (pref === tinyPref) { owner.set(id, best); changed++; } + repairPrefectureMunicipalityConnectivity(nodes, owner); + } + // Renumber compactly so labels/debug do not expose deleted prefecture IDs. + const active = [...new Set(owner.values())].sort((a, b) => a - b); + const remap = new Map(active.map((id, n) => [id, n])); + for (const [id, pref] of owner) owner.set(id, remap.get(pref)); + return changed; +} + + +function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCount = 88, maxPrefectures = 8) { + let changed = 0; + for (let pass = 0; pass < 10; pass++) { + const counts = prefectureMunicipalityCounts(owner); + const oversized = [...counts.entries()].filter(([, count]) => count > maxCount).sort((a, b) => b[1] - a[1] || a[0] - b[0])[0]; + if (!oversized || counts.size >= maxPrefectures) break; + const [prefId, count] = oversized; + const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId); + if (members.length <= maxCount) break; + let sx = 0, sy = 0, area = 0; + for (const id of members) { + const node = nodes.get(id); + if (!node) continue; + sx += node.x * Math.max(1, node.area || 1); + sy += node.y * Math.max(1, node.area || 1); + area += Math.max(1, node.area || 1); + } + const cx = sx / Math.max(1, area); + const cy = sy / Math.max(1, area); + const seedNode = members + .map((id) => nodes.get(id)) + .filter(Boolean) + .sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id)[0]; + if (!seedNode) break; + const newPref = Math.max(-1, ...counts.keys()) + 1; + const target = Math.max(count - maxCount, Math.floor(count * 0.42)); + const queue = [seedNode.id]; + const picked = new Set([seedNode.id]); + for (let q = 0; q < queue.length && picked.size < target; q++) { + const cur = queue[q]; + const nexts = [...(nodes.get(cur)?.adjacent.keys() || [])] + .filter((id) => owner.get(id) === prefId && !picked.has(id)) + .map((id) => nodes.get(id)) + .filter(Boolean) + .sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id); + for (const next of nexts) { + picked.add(next.id); + queue.push(next.id); + if (picked.size >= target) break; + } + } + if (picked.size < Math.max(8, target * 0.55)) break; + for (const id of picked) owner.set(id, newPref); + changed += picked.size; + repairPrefectureMunicipalityConnectivity(nodes, owner); + } + return changed; +} + function generatePrefecturesFromMunicipalities(context, adminResult) { const { adminId } = adminResult; - const { prefectureMask, sea, naturalBarrierScore, populationDensity, seed } = context; - const graph = buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity); + const { prefectureMask, sea, naturalBarrierScore, populationDensity, landuse, modernCities, markets, seed } = context; + const graph = buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, [...(modernCities || []), ...(markets || [])]); const seeds = choosePrefectureMunicipalitySeeds(graph.nodes, seed + 91001); const owner = assignMunicipalitiesToPrefectures(graph.nodes, seeds); const changedForTinyMerge = mergeTinyMunicipalityPrefectures(graph.nodes, owner); + let changedForMetroUnification = lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600); + changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities }); let changedForConnectivity = repairPrefectureMunicipalityConnectivity(graph.nodes, owner); let changedForEnclaveRepair = repairPrefectureMunicipalityEnclaves(graph.nodes, owner); + changedForMetroUnification += lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600); + changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities }); + const changedForMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, seeds.minMunicipalitiesPerPrefecture || 14); + const changedForTinyPrefectureCountMerge = mergePersistentlyTinyPrefecturesByCount(graph.nodes, owner, Math.max(13, (seeds.minMunicipalitiesPerPrefecture || 14) - 1)); + const changedForPostMergeMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, Math.max(12, (seeds.minMunicipalitiesPerPrefecture || 14) - 1), 64); + const changedForOversizedPrefectureSplit = splitOversizedPrefecturesByMunicipalityCount(graph.nodes, owner, 88, 8); changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); - changedForEnclaveRepair += repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea); + // Keep prefectures as connected groups of municipalities; do not perform cell-level prefecture repair here. changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]); const municipalityToPrefectureId = new Int16Array(maxAdminId + 1); @@ -637,6 +1384,13 @@ function generatePrefecturesFromMunicipalities(context, adminResult) { prefectureTinyMergeChangedMunicipalities: changedForTinyMerge, prefectureConnectivityRepairChangedMunicipalities: changedForConnectivity, prefectureEnclaveRepairChangedMunicipalities: changedForEnclaveRepair, + prefectureUrbanMetroUnificationChangedMunicipalities: changedForMetroUnification, + prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0), + prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0, + prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0, + finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()), + finalRegionalMunicipalityCountCap: 88, + finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()), finalRegionalMaxAreaShare: totalArea ? Math.max(0, ...areaByPref.values()) / totalArea : 0, finalRegionalTinyPrefectureCount: [...areaByPref.values()].filter((area) => area < 520).length, finalRegionalPrefectureBorderCount: regionalPrefectureBorders.length, @@ -938,7 +1692,7 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520); const mountainRatio = landCells ? mountainCells / landCells : 0; const lowlandBonus = Math.min(7, lowlandCells / 430); - const rawTarget = Math.round(habitableCells / 175 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.65 + lowlandBonus * 1.15 - mountainRatio * 1.8); + const rawTarget = Math.round(habitableCells / 150 + settlementWeight * 1.08 + coastlineComplexity * 0.035 + basinBonus * 0.75 + lowlandBonus * 1.25 - mountainRatio * 1.45); const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta); return clamp(rawTarget, min, max); } @@ -1378,14 +2132,25 @@ function generateAdminLayoutForMask({ const oversizedCompartmentSplit = splitOversizedCompartmentMunicipalities(adminId, adminCentersRaw, compartmentAssignment.compartments, prefectureMask, sea, { elevation, slope, ridgeField: boundaryRidgeField, plain, agriculture, basinField, coastalLowland, populationDensity, }, seed + 21900); + const changedAfterInitialCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea); + const hierarchyRepair = enforceSimpleAdministrativeHierarchy(adminId, compartmentAssignment.compartments, prefectureMask, sea, { + minCompartmentsPerMunicipality: 2, + maxUrbanClusterCells: 1600, + modernCities, + }); const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea); - const changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980); - const changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6); const compacted = compactWholeCompartmentMunicipalities(adminId, adminCentersRaw, prefectureMask, sea, { populationDensity, plain, slope }); const adminBorders = extractAdminBorderSegments(compacted.adminId, prefectureMask); const actualMunicipalityCount = compacted.activeMunicipalityCount; + const naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length; const adminDebug = { ...compartmentAssignment.debug, + simpleHierarchyPrototype: true, + administrativeHierarchySpec: "natural-compartments->municipalities->prefectures", + naturalCompartmentsImmutable: true, + municipalitiesAreCompartmentGroups: true, + prefecturesAreMunicipalityGroups: true, + cellLevelAdminSmoothingDisabled: true, sharedNaturalCompartmentLayer: true, skippedLegacyCellCleanupForHierarchy: true, targetMunicipalityCount, @@ -1398,12 +2163,23 @@ function generateAdminLayoutForMask({ pendingSeedCount: 0, absorbedSeedCount: Math.max(0, adminCentersRaw.length - compacted.activeMunicipalityCount), targetNaturalCompartmentCount: targetCompartmentCount, - naturalCompartmentCount: compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length, - compartmentCount: compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length, - changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0, + naturalCompartmentCount, + compartmentCount: naturalCompartmentCount, + changedAfterCompartmentAssignment: naturalCompartmentCount, + changedAfterInitialCompartmentOwnership, changedAfterFinalCompartmentOwnership, - changedAfterUrbanUnification, - changedAfterAdminEnclaveRepair, + changedAfterUrbanUnification: hierarchyRepair.changedAfterUrbanUnification, + urbanComponentsUnified: hierarchyRepair.urbanComponentsUnified, + changedAfterCityMetroMunicipalityUnification: hierarchyRepair.changedAfterCityMetroMunicipalityUnification, + cityMetroMunicipalitiesUnified: hierarchyRepair.cityMetroMunicipalitiesUnified, + changedAfterCompartmentConnectivity: hierarchyRepair.changedAfterCompartmentConnectivity, + disconnectedCompartmentComponentsMerged: hierarchyRepair.disconnectedCompartmentComponentsMerged, + changedAfterAdminEnclaveRepair: hierarchyRepair.changedAfterCompartmentEnclaveRepair, + compartmentEnclaveComponentsMerged: hierarchyRepair.compartmentEnclaveComponentsMerged, + changedAfterSingleCompartmentMunicipalityMerge: hierarchyRepair.changedAfterSingleCompartmentMunicipalityMerge, + singleCompartmentMunicipalitiesMerged: hierarchyRepair.singleCompartmentMunicipalitiesMerged, + remainingSingleCompartmentMunicipalities: hierarchyRepair.remainingSingleCompartmentMunicipalities, + changedAfterPostMergeCompartmentOwnership: changedAfterFinalCompartmentOwnership, changedAfterOversizedCompartmentSplit: oversizedCompartmentSplit.changedCells, oversizedCompartmentMunicipalitiesSplit: oversizedCompartmentSplit.splitMunicipalities, oversizedCompartmentSplitAddedCenters: oversizedCompartmentSplit.addedCenters, diff --git a/mapFeatures.js b/mapFeatures.js index 0855ec6..3dd04ed 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -1,4 +1,4 @@ -import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise } from "./mapUtils.js"; +import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js"; import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js"; import { LANDUSE } from "./landuseCodes.js"; @@ -91,6 +91,7 @@ export function generateMapFeatures(seed, terrain) { const highPenalty = Math.max(0, elevation[i] - 0.56); const lowSlope = clamp(1 - slope[i] * 2.3); const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0; + const openPlainPotential = clamp(plain[i] * 0.68 + agriculture[i] * 0.54 + basinField[i] * 0.30 + lowSlope * 0.22 - river[i] * 0.18 - valleyField[i] * 0.08 - ridgeField[i] * 0.22 - slope[i] * 0.26); const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38; confluenceField[i] = confluence; @@ -132,23 +133,25 @@ export function generateMapFeatures(seed, terrain) { spine * 0.12 ); const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16; - settlementCluster[i] = clamp((developable[i] * 0.50 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.34 + plain[i] * 0.12) * clusterNoise); + settlementCluster[i] = clamp((developable[i] * 0.42 + valleySettlement[i] * 0.12 + coastalSettlement[i] * 0.22 + agriculture[i] * 0.48 + plain[i] * 0.32 + openPlainPotential * 0.46) * clusterNoise); ruralSuitability[i] = clamp( agriculture[i] * 0.54 + developable[i] * 0.30 + - valleySettlement[i] * 0.24 + - coastalSettlement[i] * 0.15 + + valleySettlement[i] * 0.18 + + coastalSettlement[i] * 0.20 + + openPlainPotential * 0.34 + settlementCluster[i] * 0.30 - Math.max(0, elevation[i] - 0.64) * 0.56 ); townSuitability[i] = clamp( developable[i] * 0.38 + agriculture[i] * 0.18 + - valleySettlement[i] * 0.24 + - coastalSettlement[i] * 0.20 + - confluence * 0.34 + - basinField[i] * 0.16 + - plain[i] * 0.18 + + valleySettlement[i] * 0.16 + + coastalSettlement[i] * 0.30 + + confluence * 0.20 + + basinField[i] * 0.18 + + plain[i] * 0.26 + + openPlainPotential * 0.44 + settlementCluster[i] * 0.22 - slope[i] * 0.34 - ridgeField[i] * 0.17 - @@ -304,9 +307,9 @@ export function generateMapFeatures(seed, terrain) { const villageScore = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) { if (sea[i]) continue; - villageScore[i] = clamp(ruralSuitability[i] * 0.60 + agriculture[i] * 0.30 + plain[i] * 0.15 + valleySettlement[i] * 0.24 + coastalSettlement[i] * 0.16 + settlementCluster[i] * 0.16); + villageScore[i] = clamp(ruralSuitability[i] * 0.50 + agriculture[i] * 0.48 + plain[i] * 0.42 + Math.max(0, plain[i] * 1.12 + agriculture[i] * 0.78 + basinField[i] * 0.30 - river[i] * 0.36 - valleyField[i] * 0.14 - flowAccum[i] * 0.10) * 0.58 + valleySettlement[i] * 0.06 + coastalSettlement[i] * 0.34 + settlementCluster[i] * 0.22 - river[i] * 0.10 - flowAccum[i] * 0.04); } - const villages = pickRegionalPoints(villageScore, { + let villages = pickRegionalPoints(villageScore, { stride: 2, threshold: 0.18 + rand(seed, 1031) * 0.030, totalMax: 280, @@ -316,18 +319,51 @@ export function generateMapFeatures(seed, terrain) { quotaForRegion: (regionId, st) => { if (!st || st.developableCells < 10) return 0; const vf = visibilityFactor(regionId, st); - const raw = (st.developableCells / 28 + st.plainCells / 42 + st.valleyCells / 34 + st.coastCells / 46 + 3.2) * vf; + const raw = (st.developableCells / 28 + st.plainCells / 42 + st.valleyCells / 34 + st.coastCells / 32 + 3.2) * vf; const min = st.area > 2600 ? 20 : st.area > 1400 ? 12 : st.area > 520 ? 5 : st.area > 220 ? 2 : 0; const max = st.area > 3600 ? 72 : st.area > 2200 ? 50 : st.area > 900 ? 25 : 10; return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max)); }, }).map((p, n) => { const i = indexOf(p.x, p.y); - const kind = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.43 ? "Coastal Village" : "Village"; - const population = Math.round((300 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.85) * 4700 + ruralSuitability[i] * 2600) / 100) * 100; + const kind = coastalSettlement[i] > 0.36 ? "Coastal Village" : valleySettlement[i] > 0.46 ? "Valley Village" : "Village"; + const population = Math.round((900 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.30) * 9800 + ruralSuitability[i] * 4700 + agriculture[i] * 3600) / 100) * 100; return { ...p, kind, population }; }); + // Supplemental open-plain villages: broad Japanese-style farmland should not be empty + // just because it lacks a river/confluence anchor. + const openPlainVillageScore = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + 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 + settlementCluster[i] * 0.16 + ruralSuitability[i] * 0.18); + } + const supplementalPlainVillages = pickRegionalPoints(openPlainVillageScore, { + stride: 2, + threshold: 0.235 + rand(seed, 1036) * 0.020, + totalMax: 120, + minDistance: 5, + seedOffset: 1035, + kind: "Plain Village", + predicate: (x, y, i) => plain[i] > 0.20 && agriculture[i] > 0.16 && river[i] < 0.30 && valleyField[i] < 0.52 && slope[i] < 0.32, + quotaForRegion: (regionId, st) => { + if (!st || st.plainCells < 24) return 0; + const vf = visibilityFactor(regionId, st); + const raw = (st.plainCells / 62 + st.developableCells / 180 + 1.4) * vf; + const min = st.plainCells > 360 ? 6 : st.plainCells > 160 ? 3 : st.plainCells > 70 ? 1 : 0; + const max = st.plainCells > 720 ? 24 : st.plainCells > 360 ? 16 : st.plainCells > 140 ? 8 : 3; + 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) >= 4.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; + return { ...p, kind: "Plain Village", population }; + }); + villages = [...villages, ...supplementalPlainVillages]; + const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2)); const marketScore = new Float32Array(SIZE); @@ -335,29 +371,34 @@ export function generateMapFeatures(seed, terrain) { for (let x = 2; x < MAP_W - 2; x++) { const i = indexOf(x, y); 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) < 8 ? 0.10 : 0, - distanceToNearest(crossings, x, y) < 6 ? 0.06 : 0, - confluenceField[i] * 0.16 + distanceToNearest(ports, x, y) < 10 ? 0.16 : 0, + distanceToNearest(crossings, 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; marketScore[i] = clamp( - townSuitability[i] * 0.58 + - agriculture[i] * 0.16 + - plain[i] * 0.14 + - villageInfluence[i] * 0.40 + + townSuitability[i] * 0.50 + + agriculture[i] * 0.30 + + plain[i] * 0.28 + + openPlainMarket * 0.74 + + coastalSettlement[i] * 0.22 + + villageInfluence[i] * 0.28 + featurePull + valleyMouth + - basinField[i] * 0.12 + - plain[i] * 0.14 + + basinField[i] * 0.14 + + plain[i] * 0.22 + coastalLowland[i] * 0.08 - slope[i] * 0.18 - - ridgeField[i] * 0.08 + ridgeField[i] * 0.08 - + river[i] * 0.08 - + flowAccum[i] * 0.035 ); } } - const markets = pickRegionalPoints(marketScore, { + let markets = pickRegionalPoints(marketScore, { stride: 2, threshold: 0.245 + rand(seed, 1041) * 0.035, totalMax: 110, @@ -367,19 +408,50 @@ export function generateMapFeatures(seed, terrain) { quotaForRegion: (regionId, st) => { if (!st || st.townCells < 8) return 0; const vf = visibilityFactor(regionId, st); - const raw = (st.developableCells / 105 + st.plainCells / 125 + st.valleyCells / 105 + st.coastCells / 130 + 2.1) * vf; + const raw = (st.developableCells / 92 + st.plainCells / 108 + st.valleyCells / 92 + st.coastCells / 72 + 2.7) * vf; const min = st.area > 2600 ? 9 : st.area > 1200 ? 5 : st.area > 520 ? 2 : 0; const max = st.area > 3600 ? 30 : st.area > 2200 ? 22 : st.area > 800 ? 10 : 5; return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max)); }, - extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 8 ? 0.07 : 0) + confluenceField[i] * 0.08, + 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, }).map((p, n) => { const i = indexOf(p.x, p.y); - const kind = coastalSettlement[i] > 0.45 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town"; - const population = Math.round((4000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.50) * 24000 + marketScore[i] * 13000) / 1000) * 1000; + 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 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 }; }); + const openPlainMarketScore = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + 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 + townSuitability[i] * 0.20 + villageInfluence[i] * 0.18 + settlementCluster[i] * 0.12); + } + const supplementalPlainMarkets = pickRegionalPoints(openPlainMarketScore, { + stride: 2, + threshold: 0.335 + rand(seed, 1046) * 0.025, + totalMax: 55, + minDistance: 9, + seedOffset: 1045, + kind: "Plain Market Town", + predicate: (x, y, i) => plain[i] > 0.22 && agriculture[i] > 0.18 && river[i] < 0.28 && valleyField[i] < 0.50 && slope[i] < 0.30, + quotaForRegion: (regionId, st) => { + if (!st || st.plainCells < 60) return 0; + const vf = visibilityFactor(regionId, st); + const raw = (st.plainCells / 260 + st.developableCells / 520 + 0.45) * vf; + const min = st.plainCells > 520 ? 2 : st.plainCells > 220 ? 1 : 0; + const max = st.plainCells > 900 ? 8 : st.plainCells > 420 ? 5 : st.plainCells > 160 ? 2 : 1; + 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) >= 8.5 && distanceToNearest(villages, p.x, p.y) >= 3.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]; + const defenseScore = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) { if (sea[i]) continue; @@ -428,7 +500,7 @@ export function generateMapFeatures(seed, terrain) { const dev = developable[i]; if (dev < 0.04) continue; const radial = clamp(1 - d / Math.max(1, radius)); - const terrainMultiplier = clamp(0.60 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.13 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.24); + const terrainMultiplier = clamp(0.60 + plain[i] * 0.48 + agriculture[i] * 0.22 + basinField[i] * 0.28 + coastalLowland[i] * 0.18 + valleyField[i] * 0.08 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.38); capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias; } } @@ -448,8 +520,8 @@ export function generateMapFeatures(seed, terrain) { const regionId = regionIdAt(p.x, p.y); if (regionId < 0) continue; const st = regionStats.get(regionId); - const cityRadius = st && st.area > 2400 ? 28 : st && st.area > 900 ? 24 : 20; - const capacity = estimateUrbanCapacity(p, cityRadius, 1.0); + const cityRadius = st && st.area > 2400 ? 30 : st && st.area > 900 ? 26 : 22; + const capacity = estimateUrbanCapacity(p, cityRadius, p.candidateKind === "town" ? 1.14 : p.candidateKind === "port" ? 1.10 : 1.06); const score = Math.log10(capacity + 1) * 0.72 + townSuitability[i] * 1.40 + @@ -469,8 +541,8 @@ export function generateMapFeatures(seed, terrain) { if (!st || st.developableCells < 30) continue; const vf = visibilityFactor(regionId, st); const maxCities = clamp( - Math.round((st.developableCells / 720 + 0.9) * vf + rand(seed, 12100 + regionId * 17) * 1.2), - st.area > 1600 ? 1 : 0, + Math.round((st.developableCells / 560 + 1.15) * vf + rand(seed, 12100 + regionId * 17) * 1.4), + (st.area > 900 || st.developableCells > 180) ? 1 : 0, st.area > 3600 ? 6 : st.area > 2200 ? 4 : st.area > 900 ? 3 : 1 ); const selected = pickEntities(list, { @@ -497,11 +569,11 @@ export function generateMapFeatures(seed, terrain) { const isPrefecturalCapital = inFocusedPrefecture(city) && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital); const isRegionalCapital = isFirstInRegion; const rawPop = isRegionalCapital - ? 150000 + rand(seed, 12201 + city.regionId * 17) * 520000 - : 32000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.7) * 260000; - const capMultiplier = isRegionalCapital ? 1.10 : 1.0; + ? 300000 + rand(seed, 12201 + city.regionId * 17) * 740000 + : 68000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.62) * 430000; + const capMultiplier = isRegionalCapital ? 1.68 : 1.28; const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000; - city.population = Math.max(isRegionalCapital ? 90000 : 24000, population); + city.population = Math.max(isRegionalCapital ? 260000 : 52000, population); city.isPrefecturalCapital = isPrefecturalCapital; city.isRegionalCapital = isRegionalCapital; city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City"; @@ -518,8 +590,422 @@ export function generateMapFeatures(seed, terrain) { return estimateUrbanCapacity(city, radius, bias); } - // --- 4. Lightweight corridors ------------------------------------------- - function routeLight(a, b, snapRadius = 3) { + // --- 4. Field-derived transport corridors ------------------------------- + const preliminaryUrbanInfluence = influenceFromPoints(modernCities, 18, (c) => clamp((c.population || 60000) / 260000, 0.55, 2.0)); + const preliminaryTownInfluence = influenceFromPoints([...markets, ...commercialPorts], 10, (p) => p.portClass === "major" ? 1.35 : clamp((p.population || 12000) / 36000, 0.42, 1.1)); + const preliminaryVillageInfluence = influenceFromPoints(villages, 7, (v) => clamp((v.population || 1800) / 5200, 0.22, 0.9)); + const settlementDemand = new Float32Array(SIZE); + const urbanEdge = new Float32Array(SIZE); + const logisticsPreSuitability = new Float32Array(SIZE); + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const density = clamp(preliminaryUrbanInfluence[i] * 0.62 + preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.16); + settlementDemand[i] = density; + urbanEdge[i] = clamp(1 - Math.abs(density - 0.46) / 0.32); + logisticsPreSuitability[i] = clamp( + agriculture[i] * 0.30 + + plain[i] * 0.24 + + basinField[i] * 0.14 + + coastalLowland[i] * 0.12 + + preliminaryTownInfluence[i] * 0.18 + + urbanEdge[i] * 0.34 - + preliminaryUrbanInfluence[i] * 0.20 - + slope[i] * 0.50 - + ridgeField[i] * 0.32 + ); + } + } + + function buildTransportCostFields() { + const expressway = new Float32Array(SIZE); + const rail = new Float32Array(SIZE); + const national = new Float32Array(SIZE); + const local = new Float32Array(SIZE); + const expresswayPotential = new Float32Array(SIZE); + const railPotential = new Float32Array(SIZE); + const nationalPotential = new Float32Array(SIZE); + const localPotential = new Float32Array(SIZE); + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) { + expressway[i] = rail[i] = national[i] = local[i] = INF; + continue; + } + if (elevation[i] > 0.72) { + expressway[i] = rail[i] = national[i] = INF; + local[i] = Math.max(2.8, 1.4 + slope[i] * 2.2 + ridgeField[i] * 1.4); + continue; + } + const density = settlementDemand[i]; + const mediumDensity = clamp(1 - Math.abs(density - 0.42) / 0.30); + const highDensity = clamp((density - 0.32) / 0.50); + const lowland = clamp(plain[i] * 0.48 + basinField[i] * 0.28 + valleyField[i] * 0.24 + coastalLowland[i] * 0.26 + agriculture[i] * 0.16); + const pass = passSuitability?.[i] || 0; + const crossing = crossingSuitability?.[i] || 0; + const waterCrossingPenalty = river[i] > 0.18 ? (1 - crossing) * (0.60 + river[i] * 1.1) : 0; + const highMountain = clamp((elevation[i] - 0.58) * 2.6 + ridgeField[i] * 0.65); + const denseCorePenalty = clamp((density - 0.66) / 0.28); + const openPlainParallelPenalty = clamp(plain[i] * 0.48 + agriculture[i] * 0.26 - density * 0.20 - valleyField[i] * 0.20 - coastalLowland[i] * 0.12); + + expresswayPotential[i] = clamp( + mediumDensity * 0.62 + + urbanEdge[i] * 0.38 + + logisticsPreSuitability[i] * 0.54 + + lowland * 0.36 + + agriculture[i] * 0.16 - + denseCorePenalty * 0.54 - + slope[i] * 0.64 - + highMountain * 0.58 - + river[i] * 0.14 + ); + railPotential[i] = clamp( + highDensity * 0.80 + + preliminaryTownInfluence[i] * 0.22 + + lowland * 0.46 + + valleyField[i] * 0.22 + + coastalLowland[i] * 0.22 - + slope[i] * 1.05 - + highMountain * 0.82 - + ridgeField[i] * 0.34 + ); + nationalPotential[i] = clamp( + density * 0.46 + + preliminaryTownInfluence[i] * 0.32 + + preliminaryVillageInfluence[i] * 0.20 + + agriculture[i] * 0.24 + + valleyField[i] * 0.28 + + coastalLowland[i] * 0.26 + + pass * 0.18 + + crossing * 0.18 - + slope[i] * 0.36 - + ridgeField[i] * 0.18 + ); + localPotential[i] = clamp( + preliminaryVillageInfluence[i] * 0.52 + + agriculture[i] * 0.38 + + coastalSettlement[i] * 0.30 + + valleySettlement[i] * 0.30 + + developable[i] * 0.18 - + slope[i] * 0.26 - + ridgeField[i] * 0.10 + ); + + expressway[i] = Math.max(0.18, 1.45 - expresswayPotential[i] * 1.06 + denseCorePenalty * 1.25 + slope[i] * 3.5 + highMountain * 2.9 + waterCrossingPenalty * 1.4 + openPlainParallelPenalty * 0.10 + hash2(x, y, seed + 13301) * 0.05); + rail[i] = Math.max(0.16, 1.38 - railPotential[i] * 1.08 + slope[i] * 5.6 + highMountain * 4.2 + waterCrossingPenalty * 1.1 + hash2(x, y, seed + 13302) * 0.04); + national[i] = Math.max(0.16, 1.22 - nationalPotential[i] * 0.88 + slope[i] * 1.65 + ridgeField[i] * 0.72 + Math.max(0, elevation[i] - 0.68) * 1.2 - pass * 0.30 + waterCrossingPenalty * 0.72 + hash2(x, y, seed + 13303) * 0.06); + local[i] = Math.max(0.14, 1.10 - localPotential[i] * 0.88 + slope[i] * 1.05 + ridgeField[i] * 0.42 + Math.max(0, elevation[i] - 0.72) * 0.90 + waterCrossingPenalty * 0.45 + hash2(x, y, seed + 13304) * 0.08); + } + } + return { expressway, rail, national, local, expresswayPotential, railPotential, nationalPotential, localPotential }; + } + + const transportFields = buildTransportCostFields(); + + function chooseCorridorSeeds(potentialField, spacing, maxCount, threshold, predicate = () => true, seedOffset = 0) { + const candidates = []; + for (let y = 3; y < MAP_H - 3; y += 2) { + for (let x = 3; x < MAP_W - 3; x += 2) { + const i = indexOf(x, y); + if (sea[i] || !predicate(x, y, i)) continue; + const score = potentialField[i] + hash2(x, y, seed + seedOffset) * 0.055; + if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) }); + } + } + return pickEntities(candidates, { max: maxCount, minDistance: spacing, threshold, seed: seed + seedOffset, jitter: 0.03 }); + } + + function corridorAllowance(i) { + return clamp(settlementDemand[i] * 0.58 + valleyField[i] * 0.42 + coastalLowland[i] * 0.36 - plain[i] * 0.18 - agriculture[i] * 0.14); + } + + 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 heap = new MinHeap(); + score[startIndex] = 0; + heap.push({ i: startIndex, f: 0 }); + const curvePenalty = options.curvePenalty ?? 0.12; + const penaltyStrength = options.penaltyStrength ?? 1.0; + const sameRegion = options.regionId ?? regionIdAt(start.x, start.y); + const minGoalDistance = options.minGoalDistance ?? 18; + const maxExpanded = options.maxExpanded ?? SIZE * 2; + let goalIndex = -1; + let expanded = 0; + + 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.i !== startIndex && Math.hypot(cx - start.x, cy - start.y) >= minGoalDistance && goalRegionPredicate(cx, cy, current.i)) { + goalIndex = current.i; + break; + } + 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 (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (closed[ni] || 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 ax = cx - px; + const ay = cy - py; + turn = Math.abs(ax * dy - ay * dx) > 0 ? curvePenalty : 0; + } + const existing = penaltyField?.[ni] || 0; + const antiConcentration = existing * penaltyStrength * (1 - corridorAllowance(ni) * 0.72); + const nd = score[current.i] + (costField[ni] + antiConcentration + turn) * Math.hypot(dx, dy); + if (nd < score[ni]) { + score[ni] = nd; + cameFrom[ni] = current.i; + heap.push({ i: ni, f: nd }); + } + } + } + } + if (goalIndex < 0) return []; + const path = []; + for (let p = goalIndex; p >= 0; p = cameFrom[p]) { + path.push(xyOf(p)); + if (p === startIndex) break; + } + return path.reverse(); + } + + function addCorridorInfluencePenalty(penaltyField, corridor, radius = 7, strength = 0.35) { + for (const [x, y] of corridor || []) { + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; 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 i = indexOf(nx, ny); + if (sea[i]) continue; + const openPlain = clamp(plain[i] * 0.54 + agriculture[i] * 0.34 - settlementDemand[i] * 0.24 - valleyField[i] * 0.22 - coastalLowland[i] * 0.18); + const allowParallel = corridorAllowance(i); + penaltyField[i] = Math.max(penaltyField[i], strength * (1 - d / radius) * (0.48 + openPlain * 1.15 - allowParallel * 0.42)); + } + } + } + } + + function endpointFromPath(path) { + const p = path?.[path.length - 1]; + return p ? { x: p[0], y: p[1], regionId: regionIdAt(p[0], p[1]) } : null; + } + + function generateCorridorsFromField({ potentialField, costField, spacing, maxCount, threshold, minLength, penaltyRadius, penaltyStrength, curvePenalty, seedOffset, startPredicate, goalPredicate }) { + const paths = []; + const penaltyField = new Float32Array(SIZE); + const seeds = chooseCorridorSeeds(potentialField, spacing, maxCount * 2, threshold, startPredicate, seedOffset); + const usedEndpoints = []; + for (const start of seeds) { + if (paths.length >= maxCount) break; + if (distanceToNearest(usedEndpoints, start.x, start.y) < spacing * 0.55) continue; + const path = traceCorridorByCost( + start, + (x, y, i) => goalPredicate(start, x, y, i, usedEndpoints), + costField, + penaltyField, + { curvePenalty, penaltyStrength: penaltyStrength * 2.2, minGoalDistance: minLength, regionId: start.regionId } + ); + if (path.length < minLength) continue; + paths.push(path); + usedEndpoints.push(start); + const end = endpointFromPath(path); + if (end) usedEndpoints.push(end); + addCorridorInfluencePenalty(penaltyField, path, penaltyRadius, penaltyStrength); + } + return paths; + } + + function rasterizeNetworkComponents(paths, mode, potentialField) { + const occupied = new Uint8Array(SIZE); + for (const path of paths) { + for (const [x, y] of path || []) { + if (inside(x, y) && !sea[indexOf(x, y)]) occupied[indexOf(x, y)] = 1; + } + } + const componentId = new Int32Array(SIZE); + componentId.fill(-1); + const components = []; + for (let i = 0; i < SIZE; i++) { + if (!occupied[i] || componentId[i] >= 0) continue; + const id = components.length; + const queue = [i]; + const cells = []; + const boundary = []; + componentId[i] = id; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const x = cur % MAP_W; + const y = Math.floor(cur / MAP_W); + let edge = false; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) { edge = true; continue; } + const ni = indexOf(nx, ny); + if (!occupied[ni]) { + edge = true; + continue; + } + if (componentId[ni] < 0) { + componentId[ni] = id; + queue.push(ni); + } + } + } + if (edge) boundary.push({ x, y, i: cur }); + } + let lengthScore = 0; + let densityScore = 0; + let townScore = 0; + let logisticsScore = 0; + let capitalScore = 0; + const sampleStride = Math.max(1, Math.floor(cells.length / 80)); + for (let c = 0; c < cells.length; c += sampleStride) { + const ci = cells[c]; + const x = ci % MAP_W; + const y = Math.floor(ci / MAP_W); + lengthScore += sampleStride; + densityScore += settlementDemand[ci] * sampleStride; + logisticsScore += logisticsPreSuitability[ci] * sampleStride; + for (const p of [...modernCities, ...markets]) { + const d = Math.hypot(p.x - x, p.y - y); + if (d <= 9) townScore += ((p.population || 8000) / 50000) * (1 - d / 9); + } + for (const cty of modernCities) { + if (!cty.isPrefecturalCapital) continue; + const d = Math.hypot(cty.x - x, cty.y - y); + if (d <= 14) capitalScore += 5.0 * (1 - d / 14); + } + } + const importance = + Math.sqrt(lengthScore) * 1.20 + + densityScore * 0.38 + + townScore * 0.55 + + logisticsScore * 0.24 + + capitalScore; + components.push({ id, mode, cells, boundary, importance, length: cells.length, repairCount: 0, potential: cells.reduce((sum, ci) => sum + (potentialField?.[ci] || 0), 0) / Math.max(1, cells.length) }); + } + return { occupied, componentId, components }; + } + + function componentAnchor(component, target, costField, usedAnchors = []) { + if (!component?.boundary?.length) return null; + let best = null; + let bestScore = INF; + const stride = Math.max(1, Math.floor(component.boundary.length / 90)); + for (let k = 0; k < component.boundary.length; k += stride) { + const p = component.boundary[k]; + if (costField[p.i] >= INF) continue; + if (distanceToNearest(usedAnchors, p.x, p.y) < 8) continue; + const d = target ? Math.hypot(p.x - target.x, p.y - target.y) : 0; + const score = d + costField[p.i] * 3.5 - corridorAllowance(p.i) * 2.4 + hash2(p.x, p.y, seed + 13701) * 1.8; + if (score < bestScore) { + bestScore = score; + best = { x: p.x, y: p.y, componentId: component.id, regionId: regionIdAt(p.x, p.y) }; + } + } + return best; + } + + function repairTransportConnectivity(paths, mode, costField, potentialField, options = {}) { + const debug = { mode, components: [], repairs: [] }; + const raster = rasterizeNetworkComponents(paths, mode, potentialField); + debug.components = raster.components.map((c) => ({ + id: c.id, + mode, + importance: c.importance, + length: c.length, + potential: c.potential, + cells: c.cells.filter((_, k) => k % Math.max(1, Math.floor(c.cells.length / 140)) === 0).map((i) => xyOf(i)), + })); + const important = raster.components + .filter((c) => c.length >= (options.minComponentCells ?? 18) && c.importance >= (options.minImportance ?? 8)) + .sort((a, b) => b.importance - a.importance) + .slice(0, options.maxComponents ?? 8); + if (important.length < 2) return debug; + + const networkPenalty = influenceFromPaths(paths, options.penaltyRadius ?? 8); + const usedAnchors = []; + const maxRepairs = options.maxRepairs ?? 4; + for (let r = 0; r < maxRepairs; r++) { + let bestPair = null; + let bestScore = INF; + for (let a = 0; a < important.length; a++) { + for (let b = a + 1; b < important.length; b++) { + const ca = important[a]; + const cb = important[b]; + if (ca.repairCount >= 2 || cb.repairCount >= 2) continue; + const centerA = ca.boundary[Math.floor(ca.boundary.length / 2)] || { x: 0, y: 0 }; + const centerB = cb.boundary[Math.floor(cb.boundary.length / 2)] || { x: 0, y: 0 }; + const d = Math.hypot(centerA.x - centerB.x, centerA.y - centerB.y); + if (d < (options.minRepairDistance ?? 14) || d > (options.maxRepairDistance ?? 120)) continue; + const score = d / Math.sqrt(ca.importance + cb.importance) + (ca.repairCount + cb.repairCount) * 18; + if (score < bestScore) { + bestScore = score; + bestPair = [ca, cb]; + } + } + } + if (!bestPair) break; + const [aComp, bComp] = bestPair; + const roughTarget = bComp.boundary[Math.floor(bComp.boundary.length / 2)]; + const start = componentAnchor(aComp, roughTarget, costField, usedAnchors); + const goalTarget = start ? componentAnchor(bComp, start, costField, usedAnchors) : null; + if (!start || !goalTarget) break; + const path = traceCorridorByCost( + start, + (x, y, i) => raster.componentId[i] === bComp.id || (potentialField[i] > (options.highPotentialThreshold ?? 0.42) && Math.hypot(x - goalTarget.x, y - goalTarget.y) < 5), + costField, + networkPenalty, + { + curvePenalty: options.curvePenalty ?? 0.14, + penaltyStrength: options.penaltyStrength ?? 1.8, + minGoalDistance: Math.min(12, Math.max(5, Math.hypot(start.x - goalTarget.x, start.y - goalTarget.y) * 0.35)), + keepRegion: false, + maxExpanded: SIZE, + } + ); + if (path.length < (options.minAddedLength ?? 6) || path.length > (options.maxAddedLength ?? 120)) { + aComp.repairCount++; + continue; + } + paths.push(path); + debug.repairs.push({ mode, path, from: aComp.id, to: bComp.id }); + usedAnchors.push(start, goalTarget); + aComp.repairCount++; + bComp.repairCount++; + addCorridorInfluencePenalty(networkPenalty, path, options.penaltyRadius ?? 8, options.addedPenalty ?? 0.38); + } + return debug; + } + + function routeLight(a, b, snapRadius = 3, costField = transportFields.local) { if (!a || !b) return []; const steps = Math.max(2, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y) * 1.15)); const out = []; @@ -537,16 +1023,16 @@ export function generateMapFeatures(seed, terrain) { const y = Math.round(fy + dy); if (!inside(x, y)) continue; const i = indexOf(x, y); - if (sea[i]) continue; + if (sea[i] || costField[i] >= INF) continue; const lineDist = Math.hypot(x - fx, y - fy); - const cost = lineDist * 0.72 + corridorCost[i] * 0.62 - valleySettlement[i] * 0.34 - developable[i] * 0.18 + hash2(x, y, seed + 13000 + s) * 0.05; + const cost = lineDist * 0.72 + costField[i] * 0.74 - valleySettlement[i] * 0.20 - developable[i] * 0.12 + hash2(x, y, seed + 13000 + s) * 0.05; if (cost < bestCost) { bestCost = cost; best = [x, y]; } } } - if (!best) best = [Math.round(fx), Math.round(fy)]; + if (!best) continue; const key = `${best[0]},${best[1]}`; if (key !== lastKey) { out.push(best); @@ -579,6 +1065,7 @@ export function generateMapFeatures(seed, terrain) { const ringExpressways = []; const externalExpressways = []; const icAccessRoads = []; + const interchanges = []; const externalGateways = []; // Premodern roads connect castles/markets/ports sparsely. @@ -590,48 +1077,68 @@ export function generateMapFeatures(seed, terrain) { } } - for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) { - const nodes = importantNodesForRegion(regionId); - if (nodes.length < 2) continue; - const connected = [nodes[0]]; - const remaining = nodes.slice(1); - const maxEdges = (regionStats.get(regionId)?.area || 0) > 2200 ? Math.min(13, nodes.length + 3) : Math.min(7, nodes.length + 1); - while (remaining.length && nationalRoads.length < 48) { - let best = null; - let bestScore = INF; - for (const a of connected) { - for (const b of remaining) { - const d = Math.hypot(a.x - b.x, a.y - b.y); - const score = d - (a.nodeWeight + b.nodeWeight) * 0.9; - if (score < bestScore) { bestScore = score; best = { a, b }; } - } - } - if (!best) break; - const path = routeLight(best.a, best.b, 3); - if (path.length > 2) nationalRoads.push(path); - connected.push(best.b); - remaining.splice(remaining.indexOf(best.b), 1); - if (connected.length - 1 >= maxEdges) break; - } + nationalRoads.push(...generateCorridorsFromField({ + potentialField: transportFields.nationalPotential, + costField: transportFields.national, + spacing: 17, + maxCount: 42, + threshold: 0.30, + minLength: 20, + penaltyRadius: 6, + penaltyStrength: 0.34, + curvePenalty: 0.10, + seedOffset: 13400, + startPredicate: (x, y, i) => transportFields.nationalPotential[i] > 0.27 && regionIdAt(x, y) >= 0, + goalPredicate: (start, x, y, i, used) => { + if (regionIdAt(x, y) !== start.regionId) return false; + if (transportFields.nationalPotential[i] < 0.34) return false; + if (distanceToNearest(used, x, y) < 11) return false; + const d = Math.hypot(x - start.x, y - start.y); + return d > 22 && d < 76; + }, + })); - // A few k-nearest shortcuts for urbanized regions. - const urbanNodes = nodes.filter((p) => p.population || p.portClass).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 7 : 4); - for (let i = 0; i < urbanNodes.length; i++) { - const a = urbanNodes[i]; - const b = urbanNodes.slice(i + 1).sort((p, q) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(a.x - q.x, a.y - q.y))[0]; - if (!b || Math.hypot(a.x - b.x, a.y - b.y) > 48) continue; - const path = routeLight(a, b, 3); - if (path.length > 2) nationalRoads.push(path); - } + railways.push(...generateCorridorsFromField({ + potentialField: transportFields.railPotential, + costField: transportFields.rail, + spacing: 24, + maxCount: 18, + threshold: 0.30, + minLength: 24, + penaltyRadius: 7, + penaltyStrength: 0.42, + curvePenalty: 0.30, + seedOffset: 13500, + startPredicate: (x, y, i) => transportFields.railPotential[i] > 0.26 && settlementDemand[i] > 0.16 && regionIdAt(x, y) >= 0, + goalPredicate: (start, x, y, i, used) => { + if (regionIdAt(x, y) !== start.regionId) return false; + if (transportFields.railPotential[i] < 0.30 || settlementDemand[i] < 0.18) return false; + if (distanceToNearest(used, x, y) < 15) return false; + const d = Math.hypot(x - start.x, y - start.y); + return d > 28 && d < 88; + }, + })); - // Railways: only high-order cities/ports, as a lightweight placeholder. - const railNodes = nodes.filter((p) => (p.population || 0) > 80000 || p.portClass === "major" || p.portClass === "regional").slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 6 : 4); - railNodes.sort((a, b) => a.x - b.x || a.y - b.y); - for (let i = 1; i < railNodes.length; i++) { - const path = routeLight(railNodes[i - 1], railNodes[i], 4); - if (path.length > 4) railways.push(path); - } - } + expressways.push(...generateCorridorsFromField({ + potentialField: transportFields.expresswayPotential, + costField: transportFields.expressway, + spacing: 29, + maxCount: 10, + threshold: 0.30, + minLength: 30, + penaltyRadius: 9, + penaltyStrength: 0.55, + curvePenalty: 0.16, + seedOffset: 13600, + startPredicate: (x, y, i) => transportFields.expresswayPotential[i] > 0.27 && regionIdAt(x, y) >= 0, + goalPredicate: (start, x, y, i, used) => { + if (regionIdAt(x, y) !== start.regionId) return false; + if (transportFields.expresswayPotential[i] < 0.30) return false; + if (distanceToNearest(used, x, y) < 18) return false; + const d = Math.hypot(x - start.x, y - start.y); + return d > 34 && d < 104; + }, + })); // External gateways at land edges; used by naming/UI and later transport work. for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) { @@ -659,20 +1166,105 @@ export function generateMapFeatures(seed, terrain) { externalGateways.push(gateway); const target = importantNodesForRegion(regionId)[0]; if (target) { - const path = routeLight(gateway, target, 3); + const path = routeLight(gateway, target, 3, transportFields.national); if (path.length > 2) externalRoads.push(path); } } } - // Approximate expressways as a very small subset of top inter-city links. - const topCities = modernCities.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 6); - for (let i = 1; i < topCities.length && expressways.length < 4; i++) { - const a = topCities[i - 1]; - const b = topCities[i]; - if (Math.hypot(a.x - b.x, a.y - b.y) < 85) { - const path = routeLight(a, b, 5); - if (path.length > 5) expressways.push(path); + const transportDebugLayers = { + expresswayPotential: transportFields.expresswayPotential, + railPotential: transportFields.railPotential, + nationalRoadPotential: transportFields.nationalPotential, + slopeSeaPenalty: (() => { + const out = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) out[i] = sea[i] ? 1 : clamp(slope[i] * 1.55 + Math.max(0, elevation[i] - 0.58) * 2.2 + ridgeField[i] * 0.42); + return out; + })(), + components: [], + repairedSegments: [], + unservedSettlements: [], + }; + for (const repair of [ + repairTransportConnectivity(expressways, "expressway", transportFields.expressway, transportFields.expresswayPotential, { + minImportance: 8.5, + minComponentCells: 18, + maxComponents: 7, + maxRepairs: 3, + maxRepairDistance: 115, + penaltyRadius: 9, + penaltyStrength: 2.3, + curvePenalty: 0.16, + highPotentialThreshold: 0.38, + }), + repairTransportConnectivity(railways, "rail", transportFields.rail, transportFields.railPotential, { + minImportance: 9.5, + minComponentCells: 16, + maxComponents: 8, + maxRepairs: 4, + maxRepairDistance: 105, + penaltyRadius: 7, + penaltyStrength: 2.0, + curvePenalty: 0.34, + highPotentialThreshold: 0.36, + }), + repairTransportConnectivity(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, { + minImportance: 7.5, + minComponentCells: 12, + maxComponents: 10, + maxRepairs: 6, + maxRepairDistance: 95, + penaltyRadius: 6, + penaltyStrength: 1.7, + curvePenalty: 0.11, + highPotentialThreshold: 0.34, + }), + ]) { + transportDebugLayers.components.push(...repair.components); + transportDebugLayers.repairedSegments.push(...repair.repairs); + } + + function generateLocalRoadsForUnservedSettlements() { + const trunkInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads], 8); + const candidates = [...villages, ...markets, ...ports] + .filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]) + .map((p) => { + const i = indexOf(p.x, p.y); + const settlementWeight = p.portClass ? 1.2 : p.population ? clamp(p.population / 18000, 0.35, 1.4) : 0.45; + return { ...p, score: settlementWeight + transportFields.localPotential[i] * 0.65 - trunkInfluence[i] * 1.15 }; + }) + .filter((p) => p.score > 0.18 && trunkInfluence[indexOf(p.x, p.y)] < 0.26) + .sort((a, b) => b.score - a.score) + .slice(0, 90); + const localPenalty = new Float32Array(SIZE); + const paths = []; + const served = []; + for (const start of candidates) { + if (paths.length >= 75) break; + if (distanceToNearest(served, start.x, start.y) < 4.5) continue; + const path = traceCorridorByCost( + start, + (x, y, i) => trunkInfluence[i] > 0.20 || (paths.length > 8 && localPenalty[i] > 0.05), + transportFields.local, + localPenalty, + { curvePenalty: 0.08, penaltyStrength: 1.15, minGoalDistance: 5, regionId: start.regionId, maxExpanded: SIZE } + ); + if (path.length < 4 || path.length > 70) continue; + paths.push(path); + served.push(start); + addCorridorInfluencePenalty(localPenalty, path, 4, 0.22); + } + return paths; + } + + minorRoads.push(...generateLocalRoadsForUnservedSettlements()); + for (const path of expressways) { + for (const p of samplePath(path, 24)) { + const i = indexOf(p.x, p.y); + if (sea[i] || transportFields.expresswayPotential[i] < 0.24) continue; + if (interchanges.every((q) => Math.hypot(q.x - p.x, q.y - p.y) > 14)) { + interchanges.push({ x: p.x, y: p.y, kind: "Interchange", score: transportFields.expresswayPotential[i], regionId: regionIdAt(p.x, p.y) }); + } } } @@ -782,7 +1374,6 @@ export function generateMapFeatures(seed, terrain) { const satelliteCities = []; const newTowns = []; - const interchanges = []; const logisticsScore = new Float32Array(SIZE); for (let y = 2; y < MAP_H - 2; y++) { for (let x = 2; x < MAP_W - 2; x++) { @@ -812,6 +1403,36 @@ export function generateMapFeatures(seed, terrain) { predicate: (x, y, i) => logisticsScore[i] > 0.30 && (roadInfluence[i] > 0.10 || railInfluence2[i] > 0.08 || stationInfluence[i] > 0.08), }).map((p) => ({ ...p, kind: "Logistics Park", score: logisticsScore[indexOf(p.x, p.y)], population: 0 })); const logisticsInfluence = influenceFromPoints(logisticsParks, 4.8, () => 1.0); + + function addFinalLocalAccessForUnservedSettlements() { + const accessInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 7); + const candidates = [ + ...markets.filter((p) => (p.population || 0) >= 5000), + ...ports, + ...logisticsParks, + ...villages.filter((p) => (elevation[indexOf(p.x, p.y)] > 0.48 || slope[indexOf(p.x, p.y)] > 0.30 || p.kind === "Valley Village") && (p.population || 0) >= 1200), + ] + .filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && accessInfluence[indexOf(p.x, p.y)] < 0.18) + .map((p) => ({ ...p, score: (p.population || 7000) / 18000 + (p.portClass ? 0.8 : 0) + (p.kind === "Logistics Park" ? 1.0 : 0) + transportFields.localPotential[indexOf(p.x, p.y)] })) + .sort((a, b) => b.score - a.score) + .slice(0, 55); + const localPenalty = influenceFromPaths(minorRoads, 4); + for (const start of candidates) { + const path = traceCorridorByCost( + start, + (x, y, i) => accessInfluence[i] > 0.20 || localPenalty[i] > 0.10, + transportFields.local, + localPenalty, + { curvePenalty: 0.08, penaltyStrength: 1.0, minGoalDistance: 4, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.55) } + ); + transportDebugLayers.unservedSettlements.push({ x: start.x, y: start.y, kind: start.kind || "Unserved Settlement", mode: "local-access", repaired: path.length >= 4 && path.length <= 64 }); + if (path.length < 4 || path.length > 64) continue; + minorRoads.push(path); + transportDebugLayers.repairedSegments.push({ mode: "local", path, from: "unserved", to: "network" }); + addCorridorInfluencePenalty(localPenalty, path, 4, 0.20); + } + } + addFinalLocalAccessForUnservedSettlements(); var landuse = new Uint8Array(SIZE); // Re-run land-use classification after landuse allocation. The loop above is @@ -1014,6 +1635,12 @@ export function generateMapFeatures(seed, terrain) { humanStageVersion: "v2-sparse-raster", aStarRoutes: 0, regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0), + fieldCorridorTransport: true, + expresswayFieldCorridors: expressways.length, + railFieldCorridors: railways.length, + nationalRoadFieldCorridors: nationalRoads.length, + localRoadFieldCorridors: minorRoads.length, + layers: transportDebugLayers, nationalRoadPopulationCoverage: 0, nationalRoadUncoveredPopulation: 0, }; diff --git a/mapOutput.js b/mapOutput.js index 63904f6..c5da6b8 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -1,9 +1,13 @@ import { createNameDebug } from "./names.js"; import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js"; -import { applyOutputOptions, attachIdsAndNames, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; +import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u; +function stripMunicipalSuffix(name) { + return String(name || "").replace(/[市町村区]$/u, "").trim(); +} + function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) { const i = inside(center?.x || 0, center?.y || 0) ? indexOf(center.x, center.y) : 0; const density = fields.populationDensity?.[i] || 0; @@ -18,16 +22,20 @@ function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) { function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) { let value = String(root || center?.name || "").trim(); if (!value) value = `自治${ordinal + 1}`; - value = value.replace(/[駅港城跡宿]$/u, ""); - if (Array.from(value).length < 2) value = `${value}${String(center?.generatedMunicipalityName || "里")}`.slice(0, 3); - if (MUNICIPAL_SUFFIX_RE.test(value)) return value; + value = value.replace(/[市町村区駅港城跡宿]$/gu, ""); + const fallback = String(center?.generatedMunicipalityName || "里").replace(/[市町村区駅港城跡宿]$/gu, ""); + if (Array.from(value).length < 2) value = `${value}${fallback || "里"}`; + // Municipality roots should be at most two toponymic elements. The admin + // suffix is separate; avoid direction+root+suffix three-element names. + value = Array.from(value).slice(0, 2).join(""); return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`; } -function assignMunicipalityPopulations(adminCenters, adminId, fields) { +function assignMunicipalityPopulations(adminCenters, adminId, fields, settlementFeatures = []) { if (!adminCenters?.length || !adminId) return; const totals = new Float64Array(adminCenters.length); + const settlementTotals = new Float64Array(adminCenters.length); for (let i = 0; i < adminId.length; i++) { const id = adminId[i]; if (id < 0 || id >= totals.length || fields.sea?.[i]) continue; @@ -39,15 +47,108 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields) { const ruralFloor = lu === 1 ? 10 + agri * 18 + plain * 10 : 0; totals[id] += density * builtWeight + ruralFloor; } + // Population-bearing generated settlements are canonical entities, so add + // their explicit populations exactly once to the municipality containing the + // point. Some towns are promoted to cities later, so the same coordinate can + // appear in both `markets` and `modernCities`; keep only the strongest record + // per coordinate to avoid double counting. + const uniqueSettlementByCell = new Map(); + for (const feature of settlementFeatures || []) { + if (!feature || !Number.isFinite(feature.population) || feature.population <= 0) continue; + if (!inside(feature.x, feature.y)) continue; + const i = indexOf(feature.x, feature.y); + if (fields.sea?.[i]) continue; + const key = `${Math.round(feature.x)}:${Math.round(feature.y)}`; + const current = uniqueSettlementByCell.get(key); + const priority = (feature.isPrefecturalCapital ? 4_000_000 : 0) + (feature.isRegionalCapital ? 1_000_000 : 0) + (feature.population || 0); + if (!current || priority > current.priority) uniqueSettlementByCell.set(key, { feature, priority, i }); + } + let skippedDuplicateSettlementPopulation = 0; + for (const { feature, i } of uniqueSettlementByCell.values()) { + const id = adminId[i]; + if (id < 0 || id >= totals.length) continue; + settlementTotals[id] += feature.population; + } + for (const feature of settlementFeatures || []) { + if (!feature || !Number.isFinite(feature.population) || feature.population <= 0 || !inside(feature.x, feature.y)) continue; + const key = `${Math.round(feature.x)}:${Math.round(feature.y)}`; + const kept = uniqueSettlementByCell.get(key)?.feature; + if (kept && kept !== feature) skippedDuplicateSettlementPopulation += feature.population || 0; + } for (let id = 0; id < adminCenters.length; id++) { - const raw = totals[id] || 0; + const raw = (totals[id] || 0) + (settlementTotals[id] || 0); const rounded = raw >= 10000 ? Math.round(raw / 1000) * 1000 : Math.round(raw / 100) * 100; adminCenters[id].municipalityPopulation = Math.max(0, rounded); + adminCenters[id].municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100); + adminCenters[id].municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100); } } -function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames, nameDebug) { + +function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, fields, seed, minPopulation = 200000) { + if (!prefectureRegionId) return 0; + const prefIds = new Set(); + for (let i = 0; i < prefectureRegionId.length; i++) { + const id = prefectureRegionId[i]; + if (!sea[i] && id >= 0) prefIds.add(id); + } + let promoted = 0; + function prefAt(p) { + if (!p || !inside(p.x, p.y)) return -1; + return prefectureRegionId[indexOf(p.x, p.y)] ?? -1; + } + for (const prefId of [...prefIds].sort((a, b) => a - b)) { + const cities = (modernCities || []).filter((p) => prefAt(p) === prefId); + let target = cities.slice().sort((a, b) => + ((b.isRegionalCapital ? 800000 : 0) + (b.population || 0)) - ((a.isRegionalCapital ? 800000 : 0) + (a.population || 0)) + )[0]; + if (!target) { + const market = (markets || []).filter((p) => prefAt(p) === prefId).sort((a, b) => (b.population || 0) - (a.population || 0))[0]; + if (market) { + target = { + ...market, + kind: "Regional Capital", + rank: "Regional Capital", + promotedFromMarketTown: true, + labelPriorityBase: 900, + }; + modernCities.push(target); + } + } + if (!target) { + const center = (adminCenters || []).filter((p) => prefAt(p) === prefId).sort((a, b) => (b.municipalityPopulation || 0) - (a.municipalityPopulation || 0))[0]; + if (center) { + target = { + ...center, + kind: "Regional Capital", + rank: "Regional Capital", + promotedFromMunicipalCenter: true, + labelPriorityBase: 880, + }; + modernCities.push(target); + } + } + if (!target) continue; + const promotedPopulation = Math.round((minPopulation + rand(seed + 52000, prefId * 37 + 11) * 120000) / 1000) * 1000; + if ((target.population || 0) < promotedPopulation) { + target.population = promotedPopulation; + promoted++; + } + 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.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); + target.urbanWeight = clamp(1.05 + Math.log10(Math.max(10000, target.population)) * 0.30, 1.25, 2.55); + } + return promoted; +} + +function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames, nameDebug, options = {}) { if (!prefectureRegionId) return []; + const { modernCities = [], adminCenters = [] } = options; const byId = new Map(); for (let i = 0; i < prefectureRegionId.length; i++) { const id = prefectureRegionId[i]; @@ -62,6 +163,21 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames row.cells.push(i); } const regions = []; + const capitalNameByPref = new Map(); + for (const city of modernCities || []) { + if (!city || !inside(city.x, city.y) || !city.name) continue; + 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" }); + } + for (const center of adminCenters || []) { + if (!center || !inside(center.x, center.y) || !center.name) continue; + const prefId = prefectureRegionId[indexOf(center.x, center.y)]; + if (prefId < 0 || capitalNameByPref.has(prefId)) continue; + capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(center.name), score: center.municipalityPopulation || 0, x: center.x, y: center.y, population: center.municipalityPopulation || 0, source: "admin" }); + } for (const row of [...byId.values()].sort((a, b) => a.id - b.id)) { const cx = row.sx / Math.max(1, row.area); const cy = row.sy / Math.max(1, row.area); @@ -80,14 +196,54 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames Math.hypot(x - cx, y - cy) * 0.08; if (score > bestScore) { bestScore = score; bestI = i; } } - const x = bestI % MAP_W; - const y = Math.floor(bestI / 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) }); + let x = bestI % MAP_W; + let y = Math.floor(bestI / MAP_W); + const capitalInfo = capitalNameByPref.get(row.id); + if (capitalInfo && inside(capitalInfo.x, capitalInfo.y)) { + const targetRing = clamp(5.5 + Math.sqrt(row.area) / 52, 6, 15); + let labelI = bestI; + let labelScore = -INF; + for (const i of row.cells) { + const lx = i % MAP_W; + const ly = Math.floor(i / MAP_W); + const d = Math.hypot(lx - capitalInfo.x, ly - capitalInfo.y); + if (d < 2 || d > Math.max(19, targetRing * 2.2)) continue; + const lu = fields.landuse?.[i] ?? 0; + const builtPenalty = lu === 3 ? 1.2 : lu === 2 || lu === 4 || lu === 7 || lu === 8 ? 0.70 : 0; + const score = + -Math.abs(d - targetRing) * 0.38 - + (fields.populationDensity?.[i] || 0) * 1.1 - + builtPenalty + + (fields.plain?.[i] || 0) * 0.22 + + (fields.basinField?.[i] || 0) * 0.14 + + (fields.coastalLowland?.[i] || 0) * 0.10 - + (fields.slope?.[i] || 0) * 0.30 - + (fields.ridgeField?.[i] || 0) * 0.24; + if (score > labelScore) { labelScore = score; labelI = i; } + } + 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 }); } - return attachIdsAndNames(regions, "prefecture", seed + 41000, null, fields, usedNames, nameDebug) + 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 })); + const usedPrefNames = new Set(); + for (const region of named) { + const capital = capitalNameByPref.get(region.id)?.name; + let candidate = capital && Array.from(capital).length >= 2 ? capital : stripMunicipalSuffix(region.name); + if (!candidate) candidate = stripMunicipalSuffix(region.name) || `県域${region.id + 1}`; + if (usedPrefNames.has(candidate)) candidate = `${candidate}${region.id + 1}`; + candidate = `${String(candidate).replace(/[都道府県]$/u, "")}県`; + region.name = candidate; + region.labelName = candidate; + region.prefectureCapitalDerivedName = Boolean(capital); + usedPrefNames.add(candidate); + } + return named; } + export function finishMapOutput({ seed, options, @@ -284,18 +440,24 @@ export function finishMapOutput({ for (const [index, center] of adminCenters.entries()) { center.adminNumericId = index; center.municipalityId = index; - let candidate = center.canonicalSettlementName || municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index); + let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index); const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index); if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) { candidate = generated; } if (usedAdminNames.has(candidate)) { const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index); - const root = String(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name || `自治${index + 1}`).replace(/[市町村区]$/u, ""); - const directions = ["東", "西", "南", "北", "上", "下", "中"]; - for (let attempt = 0; attempt < directions.length + 3 && usedAdminNames.has(candidate); attempt++) { - const prefix = directions[attempt % directions.length]; - candidate = attempt < directions.length ? `${prefix}${root}${suffix}` : `${root}${index + 1}${suffix}`; + const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${index + 1}`).replace(/[市町村区駅港城跡宿]$/gu, ""); + const chars = Array.from(rootSource || "里郷"); + const alternates = [ + chars.slice(0, 2).join(""), + chars.slice(-2).join(""), + `${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + index) % 8]}`, + `${["東", "西", "南", "北", "上", "下", "中"][(seed + index) % 7]}${chars[0] || "里"}`, + ].filter((v) => Array.from(v).length >= 2); + for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) { + const root = attempt < alternates.length ? alternates[attempt] : `第${(index + attempt) % 10}`; + candidate = `${Array.from(root).slice(0, 2).join("")}${suffix}`; } } center.name = candidate; @@ -303,13 +465,93 @@ export function finishMapOutput({ center.municipalityName = candidate; usedAdminNames.add(center.name); } - assignMunicipalityPopulations(adminCenters, adminId, nameFields); + const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000); + assignMunicipalityPopulations(adminCenters, adminId, nameFields, [ + ...modernCities, + ...markets, + ...villages, + ...satelliteCities, + ...newTowns, + ]); + + function addMunicipalCenterLocalAccess() { + if (!transportDebug) return; + const debugLayers = transportDebug.layers || (transportDebug.layers = { repairedSegments: [], unservedSettlements: [] }); + debugLayers.repairedSegments ||= []; + debugLayers.unservedSettlements ||= []; + const accessInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 7); + const localPenalty = influenceFromPaths(minorRoads, 4); + const candidates = adminCenters + .filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && accessInfluence[indexOf(p.x, p.y)] < 0.16) + .sort((a, b) => (b.municipalityPopulation || 0) - (a.municipalityPopulation || 0)) + .slice(0, 45); + + function routeAccess(start) { + const maxSteps = 72; + const out = []; + const visited = new Set([`${start.x},${start.y}`]); + let x = start.x; + let y = start.y; + let lastKey = ""; + for (let step = 0; step < maxSteps; step++) { + const i = indexOf(x, y); + if (step > 3 && (accessInfluence[i] > 0.20 || localPenalty[i] > 0.10)) return out; + let best = null; + let bestScore = INF; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + if (visited.has(`${nx},${ny}`)) continue; + const cost = + 1.1 + + slope[ni] * 1.4 + + ridgeField[ni] * 0.55 + + Math.max(0, elevation[ni] - 0.70) * 1.2 - + Math.max(roadInfluence[ni], railInfluence2[ni]) * 2.8 - + plain[ni] * 0.28 - + valleyField[ni] * 0.18 - + coastalLowland[ni] * 0.16 + + localPenalty[ni] * 0.55; + if (cost < bestScore) { + bestScore = cost; + best = [nx, ny]; + } + } + } + if (!best) break; + x = best[0]; + y = best[1]; + const key = `${x},${y}`; + visited.add(key); + if (key !== lastKey) { + out.push([x, y]); + lastKey = key; + } + } + return []; + } + + for (const center of candidates) { + const path = routeAccess(center); + debugLayers.unservedSettlements.push({ x: center.x, y: center.y, kind: "Municipal Center", mode: "municipal-access", repaired: path.length >= 4 }); + if (path.length < 4) continue; + minorRoads.push(path); + debugLayers.repairedSegments.push({ mode: "municipal-local", path, from: "municipal-center", to: "network" }); + } + } + addMunicipalCenterLocalAccess(); nameDebug.maxDerivedPerBase = 0; - const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug); + const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters }); const regionalPrefectureBorders = adminRegionalPrefectureBorders || []; if (regionalDebug) { regionalDebug.finalRegionalPrefectureBorderCount = regionalPrefectureBorders.length; regionalDebug.regionalPrefectureBordersRebuiltFromFinalId = true; + regionalDebug.promotedPrefectureCapitals = promotedPrefectureCapitals; } outputProgress("final package"); diff --git a/mapTerrain.js b/mapTerrain.js index 28e0a66..77b1fff 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -166,7 +166,7 @@ const TERRAIN_TYPES = [ seaRatioRange: [0.13, 0.23], twoSidedChance: 0.96, mountainOffsetRange: [0.47, 0.53], - baseHeightRange: [0.74, 1.10], + baseHeightRange: [0.56, 0.82], primaryLengthRange: [0.76, 0.96], primaryWidthRange: [0.13, 0.22], systemCountRange: [12, 16], @@ -175,7 +175,7 @@ const TERRAIN_TYPES = [ crossSpread: 0.38, lengthScale: 1.22, widthScale: 0.92, - heightScale: 1.24, + heightScale: 0.86, coastStrength: 0.90, plainBiasRange: [0.16, 0.34], riverRichnessRange: [0.70, 1.18], @@ -188,10 +188,10 @@ const TERRAIN_TYPES = [ coastStyle: "outer_coast", mountainMode: "massif", massifnessRange: [0.42, 0.74], - seaRatioRange: [0.10, 0.20], - twoSidedChance: 0.20, + seaRatioRange: [0.000, 0.030], + twoSidedChance: 0.10, mountainOffsetRange: [0.16, 0.36], - baseHeightRange: [0.68, 1.04], + baseHeightRange: [0.76, 1.12], primaryLengthRange: [0.62, 0.92], primaryWidthRange: [0.30, 0.58], systemCountRange: [16, 20], @@ -200,11 +200,11 @@ const TERRAIN_TYPES = [ crossSpread: 0.82, lengthScale: 1.34, widthScale: 1.30, - heightScale: 1.12, - coastStrength: 0.74, + heightScale: 1.32, + coastStrength: 0.30, plainBiasRange: [0.08, 0.24], - riverRichnessRange: [0.72, 1.12], - bigRiverChanceRange: [0.28, 0.58], + riverRichnessRange: [0.74, 1.14], + bigRiverChanceRange: [0.30, 0.60], }, { id: "setouchi_inland_sea", @@ -212,20 +212,20 @@ const TERRAIN_TYPES = [ weight: 0.16, coastStyle: "inland_sea", mountainMode: "mixed", - massifnessRange: [0.24, 0.48], + massifnessRange: [0.34, 0.62], seaRatioRange: [0.20, 0.33], twoSidedChance: 0.92, mountainOffsetRange: [0.22, 0.34], - baseHeightRange: [0.56, 1.00], - primaryLengthRange: [0.52, 0.76], - primaryWidthRange: [0.18, 0.34], - systemCountRange: [12, 16], - beltCountRange: [2, 3], - angleSpread: 0.24, - crossSpread: 0.70, - lengthScale: 0.98, - widthScale: 1.08, - heightScale: 1.08, + baseHeightRange: [0.46, 0.78], + primaryLengthRange: [0.52, 0.78], + primaryWidthRange: [0.20, 0.38], + systemCountRange: [16, 22], + beltCountRange: [3, 4], + angleSpread: 0.34, + crossSpread: 0.86, + lengthScale: 1.00, + widthScale: 1.18, + heightScale: 0.82, coastStrength: 1.10, plainBiasRange: [0.26, 0.50], riverRichnessRange: [0.58, 0.96], @@ -237,24 +237,24 @@ const TERRAIN_TYPES = [ weight: 0.16, coastStyle: "open_bay", mountainMode: "range", - massifnessRange: [0.18, 0.44], - seaRatioRange: [0.15, 0.26], - twoSidedChance: 0.18, + massifnessRange: [0.10, 0.30], + seaRatioRange: [0.08, 0.17], + twoSidedChance: 0.10, mountainOffsetRange: [0.28, 0.46], - baseHeightRange: [0.62, 1.04], - primaryLengthRange: [0.42, 0.70], - primaryWidthRange: [0.20, 0.36], - systemCountRange: [10, 14], + baseHeightRange: [0.48, 0.82], + primaryLengthRange: [0.38, 0.62], + primaryWidthRange: [0.16, 0.30], + systemCountRange: [7, 11], beltCountRange: [2, 3], angleSpread: 0.34, crossSpread: 0.62, lengthScale: 0.92, widthScale: 1.10, - heightScale: 1.10, - coastStrength: 0.92, - plainBiasRange: [0.56, 0.86], - riverRichnessRange: [0.98, 1.38], - bigRiverChanceRange: [0.62, 0.90], + heightScale: 0.84, + coastStrength: 0.68, + plainBiasRange: [0.70, 0.96], + riverRichnessRange: [1.18, 1.58], + bigRiverChanceRange: [0.80, 0.98], }, { id: "mixed_archipelago", @@ -284,13 +284,11 @@ const TERRAIN_TYPES = [ ]; function pickTerrainType(seed) { - const total = TERRAIN_TYPES.reduce((sum, type) => sum + type.weight, 0); - let r = rand(seed, 10001) * total; - for (const type of TERRAIN_TYPES) { - r -= type.weight; - if (r <= 0) return type; - } - return TERRAIN_TYPES[TERRAIN_TYPES.length - 1]; + // Terrain type selection is intentionally uniform. Individual terrain + // templates still contain their own parameter ranges, but there is no + // terrain-type appearance weighting. + const index = Math.floor(rand(seed, 10001) * TERRAIN_TYPES.length) % TERRAIN_TYPES.length; + return TERRAIN_TYPES[index]; } function rangeValue(seed, salt, [lo, hi]) { @@ -315,11 +313,12 @@ export function buildTerrainTemplate(seed) { const seaRatio = rangeValue(seed, 23, terrainType.seaRatioRange); let mountainAngle = coastAngle + Math.PI * (rangeValue(seed, 24, terrainType.mountainOffsetRange)); if (terrainType.id === "tohoku_spine") { - // 東北型は左右端または上下端に海を置き、海岸線にほぼ平行な長大脊梁を通す。 - // coastAngle は海へ向かう勾配方向、等値線としての海岸線は +90° 方向。 - coastAngle = (rand(seed, 2101) < 0.5 ? 0 : Math.PI / 2) + (rand(seed, 2102) - 0.5) * 0.10; + // 東北型の脊梁は南北/東西だけでなく斜め軸も許容する。 + // 海岸勾配は脊梁軸に概ね直交させるが、山脈自体の向きは独立に選ぶ。 + const axisChoices = [0, Math.PI / 2, Math.PI / 4, -Math.PI / 4, Math.PI * 0.35, Math.PI * 0.65]; + mountainAngle = axisChoices[Math.floor(rand(seed, 2101) * axisChoices.length) % axisChoices.length] + (rand(seed, 2102) - 0.5) * 0.24; + coastAngle = mountainAngle - Math.PI / 2 + (rand(seed, 2104) - 0.5) * 0.12; twoSidedCoast = true; - mountainAngle = coastAngle + Math.PI / 2 + (rand(seed, 2103) - 0.5) * 0.16; } const baseHeight = rangeValue(seed, 25, terrainType.baseHeightRange); const primaryLength = rangeValue(seed, 26, terrainType.primaryLengthRange); @@ -445,7 +444,7 @@ function buildMountainSystems(template, seed) { angle: centralAngle, length: (0.78 + rand(seed, 3333) * 0.18) * lengthScale, width: (0.12 + rand(seed, 3334) * 0.07) * widthScale, - height: template.mountainBaseHeight * heightScale * (0.58 + rand(seed, 3335) * 0.18), + height: template.mountainBaseHeight * heightScale * (0.50 + rand(seed, 3335) * 0.15), scratchCount: Math.round(20 + rand(seed, 3336) * 10), massifness: clamp((template.mountainMassifness ?? 0.16) * 0.55), role: "central-primary", @@ -462,7 +461,7 @@ function buildMountainSystems(template, seed) { angle: centralAngle + (rand(seed, 3341) - 0.5) * 0.08, length: (0.48 + rand(seed, 3342) * 0.20) * lengthScale, width: (0.075 + rand(seed, 3343) * 0.055) * widthScale, - height: template.mountainBaseHeight * heightScale * (0.38 + rand(seed, 3344) * 0.16), + height: template.mountainBaseHeight * heightScale * (0.33 + rand(seed, 3344) * 0.13), scratchCount: Math.round(12 + rand(seed, 3345) * 8), massifness: clamp((template.mountainMassifness ?? 0.16) * 0.65), role: "central-secondary", @@ -756,6 +755,140 @@ function traceFlowPath(start, sea, flowTo, maxSteps = 900) { return path; } + +function lineCellsBetween(ax, ay, bx, by) { + const cells = []; + const steps = Math.max(Math.abs(bx - ax), Math.abs(by - ay), 1); + for (let t = 0; t <= steps; t++) { + const x = Math.round(ax + (bx - ax) * t / steps); + const y = Math.round(ay + (by - ay) * t / steps); + if (inside(x, y) && (!cells.length || cells[cells.length - 1][0] !== x || cells[cells.length - 1][1] !== y)) cells.push([x, y]); + } + return cells; +} + +function meanderRiverPath(path, seed, salt, sea, lake, elevation, slope) { + if (!path || path.length < 18) return path; + // Add a visible but controlled meander before valley incision. The meander + // diameter is about 3-10 cells. Mountain reaches are no longer damped; + // the same low-frequency bend model is applied throughout the course. + const lengthFactor = clamp(path.length / 190); + const controlStep = Math.max(4, Math.round(8 - lengthFactor * 3)); + const phase = hash2(seed + salt, 17) * Math.PI * 2; + const waveCells = 14 + Math.floor(hash2(seed, salt + 31) * 16); // broad wavelength + const secondaryCells = 28 + Math.floor(hash2(seed + 3, salt + 53) * 24); + const maxDiameter = 3 + hash2(seed + 5, salt + 71) * 7; + const baseAmp = maxDiameter * 0.5; + const controls = []; + for (let k = 0; k < path.length; k += controlStep) controls.push(k); + if (controls[controls.length - 1] !== path.length - 1) controls.push(path.length - 1); + + const displacedControls = []; + for (const k of controls) { + const [x, y] = path[k]; + if (k === 0 || k === path.length - 1) { displacedControls.push([x, y]); continue; } + const [px, py] = path[Math.max(0, k - controlStep * 2)]; + const [nx0, ny0] = path[Math.min(path.length - 1, k + controlStep * 2)]; + const tx = nx0 - px; + const ty = ny0 - py; + const len = Math.hypot(tx, ty) || 1; + const normalX = -ty / len; + const normalY = tx / len; + const primary = Math.sin(k / waveCells * Math.PI * 2 + phase); + const secondary = Math.sin(k / secondaryCells * Math.PI * 2 + phase * 0.43) * 0.35; + const amp = baseAmp * 0.82 * (primary + secondary); + let mx = Math.round(x + normalX * amp); + let my = Math.round(y + normalY * amp); + if (!inside(mx, my)) { displacedControls.push([x, y]); continue; } + const mi = indexOf(mx, my); + if (sea[mi] && !lake[mi] && k < path.length - controlStep) { displacedControls.push([x, y]); continue; } + displacedControls.push([mx, my]); + } + + const out = []; + for (let c = 0; c < displacedControls.length - 1; c++) { + const [ax, ay] = displacedControls[c]; + const [bx, by] = displacedControls[c + 1]; + const line = lineCellsBetween(ax, ay, bx, by); + for (const cell of line) { + if (out.length && out[out.length - 1][0] === cell[0] && out[out.length - 1][1] === cell[1]) continue; + const ci = indexOf(cell[0], cell[1]); + if (sea[ci] && !lake[ci] && c < displacedControls.length - 3) continue; + out.push(cell); + } + } + return out.length >= Math.max(8, path.length * 0.42) ? out : path; +} + +function scoreRiverPathForDedup(path, flowAccum) { + let maxFlow = 0; + let meanFlow = 0; + for (const [x, y] of path) { + const f = flowAccum[indexOf(x, y)] || 0; + maxFlow = Math.max(maxFlow, f); + meanFlow += f; + } + meanFlow /= Math.max(1, path.length); + return path.length * 0.75 + maxFlow * 90 + meanFlow * 35; +} + +function dedupeRiverPaths(paths, flowAccum, sea, lake) { + // Multiple traces often share, or run one cell beside, the same downstream + // trunk. Trim later traces at the first near-confluence so visually only one + // river occupies a channel, while true tributaries remain visible upstream. + const sorted = paths + .map((path) => ({ path, score: scoreRiverPathForDedup(path, flowAccum) })) + .sort((a, b) => b.score - a.score); + const occupied = new Uint8Array(SIZE); + const accepted = []; + const nearOccupied = (x, y) => { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + if (occupied[indexOf(nx, ny)]) return true; + } + } + return false; + }; + const markNear = (x, y) => { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const nx = x + dx; + const ny = y + dy; + if (inside(nx, ny)) occupied[indexOf(nx, ny)] = 1; + } + } + }; + for (const item of sorted) { + const path = item.path; + if (!path || path.length < 8) continue; + let joinAt = -1; + let nearRun = 0; + for (let k = 0; k < path.length; k++) { + const [x, y] = path[k]; + if (nearOccupied(x, y) && k > 6) { + nearRun++; + if (nearRun >= 2) { joinAt = Math.max(6, k - 1); break; } + } else { + nearRun = 0; + } + } + const trimmed = joinAt >= 0 ? path.slice(0, Math.min(path.length, joinAt + 1)) : path; + let uniqueCells = 0; + for (const [x, y] of trimmed) if (!nearOccupied(x, y)) uniqueCells++; + if (trimmed.length < 8 || uniqueCells < Math.max(5, Math.min(18, trimmed.length * 0.38))) continue; + accepted.push(trimmed); + for (const [x, y] of trimmed) { + const i = indexOf(x, y); + if (!sea[i] || lake[i]) markNear(x, y); + } + if (accepted.length >= 62) break; + } + return accepted; +} + function buildRiverNetwork(seed, template, sea, lake, elevation, slope, flowTo, flowAccum, river, erosionField) { river.fill(0); const candidates = []; @@ -770,46 +903,39 @@ function buildRiverNetwork(seed, template, sea, lake, elevation, slope, flowTo, if (score > 0.24) candidates.push({ x, y, score }); } } - const desired = 44 + Math.floor(template.riverRichness * 28); - const sources = pickEntities(candidates, { max: desired, minDistance: 6, threshold: 0.26, seed: seed + 12100, jitter: 0.035 }); + const desired = 26 + Math.floor(template.riverRichness * 15); + const sources = pickEntities(candidates, { max: desired, minDistance: 8, threshold: 0.27, seed: seed + 12100, jitter: 0.035 }); const riverPaths = []; for (const s of sources) { - const path = traceFlowPath(indexOf(s.x, s.y), sea, flowTo); + const traced = traceFlowPath(indexOf(s.x, s.y), sea, flowTo); + const path = meanderRiverPath(traced, seed, s.x * 4096 + s.y, sea, lake, elevation, slope); if (path.length >= 8 && path.some(([x, y], k) => k > 5 && (sea[indexOf(x, y)] || lake[indexOf(x, y)]))) riverPaths.push(path); else if (path.length >= 14) riverPaths.push(path); } - const longPaths = riverPaths - .map((path) => { - let maxFlow = 0; - let meanFlow = 0; - for (const [x, y] of path) { - const f = flowAccum[indexOf(x, y)]; - maxFlow = Math.max(maxFlow, f); - meanFlow += f; - } - meanFlow /= Math.max(1, path.length); - return { path, score: path.length * 0.75 + maxFlow * 90 + meanFlow * 35 }; - }) + const visibleRiverPaths = dedupeRiverPaths(riverPaths, flowAccum, sea, lake); + const longPaths = visibleRiverPaths + .map((path) => ({ path, score: scoreRiverPathForDedup(path, flowAccum) })) .sort((a, b) => b.score - a.score); - const mainCount = Math.min(longPaths.length, template.bigRiverChance > 0.56 ? 4 : 3); + const mainCount = Math.min(longPaths.length, template.terrainType === "kanto_alluvial" ? 5 : template.bigRiverChance > 0.56 ? 4 : 3); const mainSet = new Set(longPaths.slice(0, mainCount).map((p) => p.path)); - const riverThreshold = 0.26 - template.riverRichness * 0.035 - (template.bigRiverChance > 0.56 ? 0.030 : 0); + const riverThreshold = 0.26 - template.riverRichness * 0.040 - (template.bigRiverChance > 0.56 ? 0.038 : 0) - (template.terrainType === "kanto_alluvial" ? 0.035 : 0); for (let i = 0; i < SIZE; i++) { if (sea[i]) continue; const f = flowAccum[i]; if (f > riverThreshold) river[i] = clamp((f - riverThreshold) / (0.55 - riverThreshold)); } - for (const path of riverPaths) { + for (const path of visibleRiverPaths) { const main = mainSet.has(path); for (let k = 0; k < path.length; k++) { const [x, y] = path[k]; const i = indexOf(x, y); if (sea[i]) continue; const downstream = k / Math.max(1, path.length - 1); - const boost = main ? 0.54 + downstream * 0.42 : 0.30 + downstream * 0.22; - river[i] = clamp(Math.max(river[i], boost + flowAccum[i] * (main ? 0.70 : 0.42))); + const kantoMain = main && template.terrainType === "kanto_alluvial"; + const boost = main ? (kantoMain ? 0.66 : 0.54) + downstream * (kantoMain ? 0.50 : 0.42) : 0.30 + downstream * 0.22; + river[i] = clamp(Math.max(river[i], boost + flowAccum[i] * (main ? (kantoMain ? 0.88 : 0.70) : 0.42))); } } @@ -835,8 +961,9 @@ function buildRiverNetwork(seed, template, sea, lake, elevation, slope, flowTo, const sortedPaths = longPaths.map((p) => p.path); const mainRivers = sortedPaths.filter((p) => mainSet.has(p)).slice(0, mainCount); - const tributaryRivers = sortedPaths.filter((p) => !mainSet.has(p)).slice(0, 24); - const smallStreams = sortedPaths.slice(mainCount + 8, mainCount + 58); + const nonMainPaths = sortedPaths.filter((p) => !mainSet.has(p)); + const tributaryRivers = nonMainPaths.slice(0, 24); + const smallStreams = nonMainPaths.slice(24, 74); return { riverPaths: sortedPaths.slice(0, 80), mainRivers, tributaryRivers, smallStreams }; } @@ -990,7 +1117,24 @@ export function generateTerrainAndRivers(seed) { e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80); e += global * 0.020; e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax; - elevation[i] = clamp(softCapElevation(e, terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91, 1.08), 0.025, 1.08); + if (terrainTemplate.terrainType === "tohoku_spine") { + // Keep the broad Ou/backbone footprint, but compress peak height so the + // range reads as a long Japanese spine rather than an alpine wall. + const high = Math.max(0, e - 0.48); + e -= high * clamp(0.18 + mountainMaskMax * 0.22, 0.18, 0.40); + } + if (terrainTemplate.terrainType === "setouchi_inland_sea") { + // Setouchi maps should have many low hills and island backbones rather + // than a few high alpine ridges. Add broad low relief, then cap peaks. + const lowHillNoise = clamp((fbm(x * 0.95 + 17, y * 0.95 - 23, seed + 571) - 0.38) * 2.9); + const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12); + e += lowHillMask * 0.145; + const high = Math.max(0, e - 0.62); + e -= high * 0.42; + } + const softCapStart = terrainTemplate.terrainType === "tohoku_spine" ? 0.78 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.72 : terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91; + const softCapMax = terrainTemplate.terrainType === "tohoku_spine" ? 0.96 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.92 : 1.08; + elevation[i] = clamp(softCapElevation(e, softCapStart, softCapMax), 0.025, softCapMax); visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40); surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46); valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18); @@ -1018,7 +1162,7 @@ export function generateTerrainAndRivers(seed) { const natural = buildNaturalCompartments( landMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, zeroDensity, zeroLanduse, - { seed: seed + 17003, targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 30), 80, 520) } + { seed: seed + 17003, targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 45), 70, 360) } ); const sharedNaturalBarrierScore = natural.naturalBarrierScore || naturalBarrierScore; const prefectureBorder = extractMaskBorder(prefectureMask, sea); diff --git a/names.js b/names.js index c7142af..bc59096 100644 --- a/names.js +++ b/names.js @@ -1,6 +1,17 @@ import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js"; -export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "二軒屋", "三軒家", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "観音寺",]; +export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山"]; + +function nameCharCount(value) { + return Array.from(String(value || "")).length; +} + +function isAtomicNamePart(value) { + // The generator used to create visibly synthetic three-part names by joining + // a prefix with a compound terrain word. For template generation, keep each + // lexical slot atomic so a generated root is at most two visible elements. + return nameCharCount(value) <= 1; +} export const NAME_KANJI_POOLS = { modifiers: [ @@ -11,20 +22,20 @@ export const NAME_KANJI_POOLS = { "白", "黒", "青", "赤", "藍", "奥", "前", "後", "内", "外", "美", "吉", "福", "幸", "徳", - "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万", + "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "霞", "朝", "日", "天", "土", "砂", "石", "岩", - "丑", "卯", "辰", "巳", "酉", - "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", + "卯", "辰", + "荒", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄" ], inlandTerrain: [ - "山", "野", "荒", "野", "沢", + "山", "野", "野", "沢", "森", "林", "岡", "丘", "坂", "峰", "峠", "嶺", "尾", "平", "坪", "延", - "窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古", + "窪", "久", "迫", "久保", "玖保", "佐古", "作古", "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生", - "聡", "郷", "里", + "郷", "里", "馬", "鹿", "亀", "鷲", "鷹", "鶴", "鴨", "竜", "龍", "牛", "鳥", "湯", ], @@ -53,7 +64,7 @@ export const NAME_KANJI_POOLS = { "橘", "柏", "槙", "柿", "桃", "梨", "桑", "麻", "芦", "茅", "粟", "稲", "稗", "米", "飯", "糠", "茜", "葵", - "榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜" + "榊", "楢", "檜", "椎", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜" ], postfixes: [ @@ -63,13 +74,13 @@ export const NAME_KANJI_POOLS = { "辺", "里", "郷", "村", "町", "宿", "庄", "台", "坂", "橋", "本", "内", "窪", "平", "塚", - "畑", "牧", "前", "見", "中", "羽", "生", "塚", "部", + "畑", "牧", "前", "見", "中", "羽", "生", "塚", "部", "栄", "永", ], archaicPrefixes: [ - "阿", "吾", "安", "有", "衣", "伊", "以", "井", "宇", "羽", "江", "恵", "尾", "小", "於", + "阿", "吾", "安", "有", "衣", "伊", "以", "井", "宇", "羽", "江", "恵", "尾", "小", "可", "加", "賀", "香", "鹿", "賀", "嘉", "喜", "紀", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", "巨", "己", - "佐", "紗", "左", "志", "師", "須", "瀬", "曽", "蘇", + "佐", "紗", "左", "志", "師", "須", "瀬", "曽", "蘇", "総", "多", "太", "知", "津", "土", "那", "奈", "名", "仁", "尼", "根", "乃", "能", "波", "氷", "比", "肥", "布", "夫", "戸", "保", "穂", @@ -81,14 +92,14 @@ export const NAME_KANJI_POOLS = { "日", "紀", "志", "尾", "駿", "甲", "信", "越", "備", "能", "薩", "隠", "美", "三", "若", - "遠", "近", "能", "加", "賀", "度", + "遠", "近", "能", "加", "賀", "度", "飾", "越", "淡", "壱", "衣", "古", "彦", "多", "志", "布", "治", "加茂", ], archaicSuffixes: [ - "井", "羽", "江", "恵", "尾", "於", + "井", "羽", "江", "恵", "尾", "賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", - "佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇", + "佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇", "総", "多", "太", "知", "津", "豆", "土", "登", "那", "奈", "名", "仁", "尼", "根", "乃", "能", "波", "布", "夫", "戸", "保", "穂", @@ -99,8 +110,8 @@ export const NAME_KANJI_POOLS = { "陀", "芸", "雲", "幡", "耆", "摩", "張", "江", "河", "斐", "濃", - "岐", "門", "隅", "向", - "居", "前", "中", "後", "波", + "岐", "門", "隅", "向", "飾", + "居", "前", "中", "後", "勢", "渡", "城", "紫", "野", "度", "津", "島", "信", "登", "賀", "志", "良", "美", "智", "茂", "代", "古", "麻", "彦", "比古", "子", "加茂", @@ -110,7 +121,7 @@ export const NAME_KANJI_POOLS = { "里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣", "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", "城", "館", "屋", "家", "所", - "市", "場", "関", "地蔵", "辻", "角", "堰", + "市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋", ] }; @@ -471,6 +482,7 @@ export function validateGeneratedName(name, options = {}) { return { valid: false, reason: "asciiDiagnostic" }; } if (!options.allowOneCharacter && length < 2) return { valid: false, reason: "oneCharacter" }; + if (Number.isFinite(options.maxLength) && length > options.maxLength) return { valid: false, reason: "tooLong" }; if (!options.allowLong && length > 4) return { valid: false, reason: "tooLong" }; if (!options.allowRepeatedKanji && hasRepeatedKanji(value)) return { valid: false, reason: "repeatedKanji" }; return { valid: true, reason: "valid" }; @@ -489,20 +501,24 @@ function generateTemplateNameDetails(seed, id, entity, fields, attempt, usedName const context = chooseNameContext(entity, fields); const templateKey = chooseTemplate(context, seed, id, attempt); const template = NAME_TEMPLATES[templateKey]; - if (!template) return { name: null, context, templateKey: null }; + // Do not generate old-style three-part random toponyms; names are now either + // curated list entries or at most two lexical elements plus any administrative suffix. + if (!template || (template.slots?.length || 0) > 2) return { name: null, context, templateKey: null }; const parts = []; for (let slotIndex = 0; slotIndex < template.slots.length; slotIndex++) { const slot = template.slots[slotIndex]; const slotPool = resolveSlotPool(slot, context, pools, probabilities, seed, id, attempt + slotIndex); if (!slotPool?.pool?.length) return { name: null, context, templateKey }; - const part = pick(slotPool.pool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key)); + const atomicPool = slotPool.pool.filter(isAtomicNamePart); + if (!atomicPool.length) return { name: null, context, templateKey }; + const part = pick(atomicPool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key)); if (!part) return { name: null, context, templateKey }; parts.push(part); } const name = parts.join(""); - const validation = validateGeneratedName(name); + const validation = validateGeneratedName(name, { allowLong: false, maxLength: 2 }); if (!validation.valid) return { name: null, context, templateKey, invalidReason: validation.reason }; if (usedNames?.has(name)) return { name: null, context, templateKey, duplicate: true }; return { name, context, templateKey }; @@ -533,6 +549,10 @@ function tryCustomNameList(seed, id, usedNames, debug) { const start = Math.floor(roll(seed, id, 0, 3539) * CUSTOM_NAME_LIST.length) % CUSTOM_NAME_LIST.length; for (let offset = 0; offset < CUSTOM_NAME_LIST.length; offset++) { const customName = CUSTOM_NAME_LIST[(start + offset) % CUSTOM_NAME_LIST.length]; + if (nameCharCount(customName) > 2) { + debug.invalidNamesRejected++; + continue; + } const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true }); if (!validation.valid) { if (validation.reason === "oneCharacter") { diff --git a/renderer.js b/renderer.js index 053096f..d7fd419 100644 --- a/renderer.js +++ b/renderer.js @@ -314,7 +314,8 @@ function terrainColorContinuous(map, fx, fy, mode) { if (isWaterSample(map, fx, fy)) { const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4); - color = [Math.round(170 + depth * 5), Math.round(218 + depth * 10), Math.round(255 - depth * 5)]; + // Calm sky-blue water; less saturated than the previous bright cyan. + color = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)]; } else if (mode === "development") { const dCity = distToNearest(map.modernCities, fx, fy); const urban = clamp(1 - dCity / 25); @@ -644,6 +645,36 @@ function drawDebugCells(ctx, map, field, color) { ctx.restore(); } +function drawTransportDebug(ctx, map) { + const layers = map.transportDebug?.layers; + if (!layers) return; + drawDebugCells(ctx, map, layers.expresswayPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(80, 190, 110, ${0.035 + v * 0.13})`); + drawDebugCells(ctx, map, layers.railPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(70, 120, 230, ${0.035 + v * 0.13})`); + drawDebugCells(ctx, map, layers.nationalRoadPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(245, 205, 65, ${0.030 + v * 0.12})`); + drawDebugCells(ctx, map, layers.slopeSeaPenalty, (v) => v < 0.45 ? "rgba(0,0,0,0)" : `rgba(80, 30, 30, ${0.025 + v * 0.10})`); + + const componentColors = { + expressway: "rgba(60, 165, 80, 0.42)", + rail: "rgba(65, 95, 210, 0.42)", + national: "rgba(210, 155, 20, 0.42)", + }; + ctx.save(); + for (const comp of layers.components || []) { + ctx.fillStyle = componentColors[comp.mode] || "rgba(150,150,150,0.35)"; + for (const [x, y] of comp.cells || []) ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE); + } + ctx.restore(); + + for (const repair of layers.repairedSegments || []) { + const color = repair.mode === "expressway" ? "rgba(0, 115, 40, 0.95)" + : repair.mode === "rail" ? "rgba(35, 70, 210, 0.95)" + : repair.mode === "national" ? "rgba(180, 120, 0, 0.95)" + : "rgba(210, 35, 155, 0.92)"; + drawPath(ctx, repair.path, "rgba(255,255,255,0.92)", 5.0); + drawPath(ctx, repair.path, color, 2.4); + } +} + function prefectureRegionColor(id) { const palette = [ [234, 220, 214], [218, 232, 218], [218, 224, 238], [238, 232, 208], @@ -653,10 +684,10 @@ function prefectureRegionColor(id) { } function drawPrefectureRegionFill(ctx, map, mode) { - if (!["all", "admin", "admin-debug", "borders-debug"].includes(mode)) return; + if (!["all", "admin", "borders-debug"].includes(mode)) return; const ids = map.prefectureRegionId; if (!ids) return; - const alpha = mode === "borders-debug" ? 0.34 : mode === "admin-debug" ? 0.26 : 0.18; + const alpha = mode === "borders-debug" ? 0.34 : 0.18; ctx.save(); ctx.globalAlpha = alpha; for (let y = 0; y < MAP_H; y++) { @@ -688,30 +719,38 @@ function boxesOverlap(a, b, pad = 3) { function labelWithCollision(ctx, p, occupied) { if (!p.name) return false; + const isPrefectureLabel = p.kind === "Prefecture" || p.kind === "Current Prefecture" || p.isPrefectureLabel; ctx.save(); - ctx.font = "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif"; + ctx.font = isPrefectureLabel + ? "900 20px ui-sans-serif, system-ui, -apple-system, sans-serif" + : "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif"; const baseX = p.x * CELL_SIZE + CELL_SIZE / 2; const baseY = p.y * CELL_SIZE + CELL_SIZE / 2; const textW = ctx.measureText(p.name).width; - const textH = 12; - const candidates = [ - [7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13], - [-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4], - ]; + const textH = isPrefectureLabel ? 22 : 12; + const candidates = isPrefectureLabel + ? [ + [-textW / 2, 6], [-textW / 2, -12], [-textW / 2, 24], + [10, 6], [-textW - 10, 6], + ] + : [ + [7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13], + [-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4], + ]; for (const [ox, oy] of candidates) { const x = baseX + ox; const y = baseY + oy; - const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 4 }; + const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 }; if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue; - if (occupied.some((b) => boxesOverlap(box, b))) continue; - + if (occupied.some((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : 3))) continue; + ctx.lineJoin = "round"; - ctx.lineWidth = 3.5; - ctx.strokeStyle = "rgba(255, 255, 255, 0.95)"; + ctx.lineWidth = isPrefectureLabel ? 6.2 : 3.5; + ctx.strokeStyle = isPrefectureLabel ? "rgba(255, 255, 255, 0.98)" : "rgba(255, 255, 255, 0.95)"; ctx.strokeText(p.name, x, y); - - ctx.fillStyle = p.isPrefecturalCapital ? "#111111" : "#333333"; + + ctx.fillStyle = isPrefectureLabel ? "rgba(62, 38, 112, 0.98)" : p.isPrefecturalCapital ? "#111111" : "#333333"; ctx.fillText(p.name, x, y); occupied.push(box); ctx.restore(); @@ -787,8 +826,8 @@ export function drawMap(canvas, map, options) { drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); // 2. Rivers - const waterBlue = "rgba(160, 205, 240, 1)"; - const mediumBlue = "rgba(160, 205, 240, 0.88)"; + const waterBlue = "rgba(116, 165, 202, 0.92)"; + const mediumBlue = "rgba(132, 184, 220, 0.78)"; const riverStrengthForPath = (path) => { if (!path || path.length === 0) return 0; let peak = 0; @@ -813,7 +852,7 @@ export function drawMap(canvas, map, options) { for (const path of map.smallStreams || []) { const strength = riverStrengthForPath(path); if ((path?.length || 0) < 5 || strength < 0.045) continue; - drawRiverPath(ctx, map, path, "rgba(150, 198, 235, 1)", (s) => s > 0.45 ? 0.58 : s > 0.22 ? 0.48 : 0.36, 0.34); + drawRiverPath(ctx, map, path, "rgba(140, 190, 224, 0.82)", (s) => s > 0.45 ? 0.52 : s > 0.22 ? 0.42 : 0.32, 0.28); } for (const path of map.tributaryRivers || []) { const strength = riverStrengthForPath(path); @@ -837,26 +876,28 @@ export function drawMap(canvas, map, options) { } const showHistory = ["history", "all", "terrain"].includes(mode); - const showModern = ["modern", "all", "development", "landuse", "admin-debug", "borders-debug"].includes(mode); - const showRoads = ["all", "development"].includes(mode); - const showMinorRoads = ["all", "modern", "development"].includes(mode); - const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); + const showTransportDebug = mode === "transport-debug"; + const showModern = ["modern", "all", "development", "landuse", "borders-debug", "transport-debug"].includes(mode); + const showRoads = ["all", "development", "transport-debug"].includes(mode); + const showMinorRoads = ["all", "modern", "development", "transport-debug"].includes(mode); + const showAdmin = ["admin", "all", "borders-debug"].includes(mode); // 3. Borders - const showPrefectureRegions = ["all", "admin", "admin-debug", "borders-debug"].includes(mode); + const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode); if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode); if (showAdmin && map.adminBorders) { drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); } - if (mode === "admin-debug" || mode === "borders-debug") { + if (mode === "borders-debug") { // Keep the natural barrier heatmap subtle. A dense cell fill can look like // artificial horizontal hatching, so only strong terrain dividers are shown. drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => v < 0.42 ? "rgba(0,0,0,0)" : `rgba(255, 120, 40, ${0.025 + v * 0.075})`); if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(45, 95, 160, 0.72)", 1.0, true); for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)"); } + if (showTransportDebug) drawTransportDebug(ctx, map); if (showPrefectureRegions && map.regionalPrefectureBorders) { drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); @@ -916,7 +957,7 @@ export function drawMap(canvas, map, options) { } // 6. Icons & Labels - if (["admin", "admin-debug", "borders-debug"].includes(mode)) { + if (["admin", "borders-debug"].includes(mode)) { for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)"); } @@ -937,20 +978,25 @@ export function drawMap(canvas, map, options) { } for (const p of map.interchanges || []) dot(ctx, p, 2.7, "rgba(250, 250, 245, 0.95)", "rgba(95, 125, 95, 0.95)"); for (const p of map.logisticsParks || []) dot(ctx, p, 2.4, "rgba(235, 238, 230, 0.95)", "rgba(105, 125, 105, 0.88)"); - if (mode === "admin-debug" || mode === "borders-debug") { + if (mode === "borders-debug") { for (const p of map.externalGateways || []) dot(ctx, p, 5.5, "rgba(255,255,255,0.95)", "rgba(40,80,170,0.95)"); for (const p of map.transportDebug?.missingRequiredNodes || []) dot(ctx, p, 6.5, "rgba(255,80,80,0.95)", "rgba(80,20,20,0.95)"); } + if (showTransportDebug) { + for (const p of map.transportDebug?.layers?.unservedSettlements || []) { + dot(ctx, p, p.repaired ? 3.4 : 4.8, p.repaired ? "rgba(255,255,255,0.92)" : "rgba(255,80,140,0.95)", "rgba(125,35,105,0.95)"); + } + } } if (showLabels) { - const prefectureLabels = showPrefectureRegions ? (map.prefectureRegions || []).map((p) => ({ ...p, labelPriorityBase: p.labelPriorityBase || 900 })) : []; + const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, labelPriorityBase: p.labelPriorityBase || 1700 })); if (mode === "admin") { drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity); drawScaleBar(ctx); return; } - if (mode === "admin-debug" || mode === "borders-debug") { + if (mode === "borders-debug") { drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity); drawScaleBar(ctx); return; @@ -970,7 +1016,7 @@ export function drawMap(canvas, map, options) { ...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })), ...(map.satelliteCities || []), ...allLayerTowns, - ].filter((p) => p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000); + ].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000); drawLabels(ctx, important, mode === "all" ? 85 : 60); } drawScaleBar(ctx);