diff --git a/adminRegions.js b/adminRegions.js index bf7aa0e..6bbd906 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -1057,13 +1057,20 @@ export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, } let splitMunicipalities = 0; + let rejectedMunicipalities = 0; for (const [id, cells] of area) { const averageLowland = (lowland.get(id) || 0) / cells; const averageMountain = (mountain.get(id) || 0) / cells; - if (cells < median * 2.25 || averageLowland < 0.28 || averageMountain > 0.44) continue; + if (cells < median * 1.85 || averageLowland < 0.24 || averageMountain > 0.48) { + if (cells >= median * 1.85) rejectedMunicipalities++; + continue; + } const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id); const meaningfulNodes = localSettlements.filter((p) => p.kind === "Satellite City" || p.kind === "New Town" || p.kind === "Market Town" || (p.population || 0) >= 30000); - if (meaningfulNodes.length < 2) continue; + if (meaningfulNodes.length < 2) { + rejectedMunicipalities++; + continue; + } let changedHere = 0; for (const unit of compartments) { if (!unit || unit.area === 0 || unitOwner[unit.id] !== id) continue; @@ -1094,7 +1101,7 @@ export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); let changedCells = 0; for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++; - return { changedCells, splitMunicipalities }; + return { changedCells, splitMunicipalities, rejectedMunicipalities }; } export function splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) { diff --git a/mapAdminStage.js b/mapAdminStage.js index 6f4e844..988e0e9 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -26,9 +26,10 @@ function municipalityAreaById(adminId, prefectureMask, sea) { return area; } -function computeTargetMunicipalityCount({ prefectureMask, sea, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) { +function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) { let landCells = 0; let habitableCells = 0; + let lowlandCells = 0; let coastlineComplexity = 0; let mountainCells = 0; for (let y = 1; y < MAP_H - 1; y++) { @@ -36,7 +37,8 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, slope, ridgeField const i = indexOf(x, y); if (!prefectureMask[i] || sea[i]) continue; landCells++; - if (slope[i] < 0.42 && ridgeField[i] < 0.55) habitableCells++; + if (slope[i] < 0.42 && ridgeField[i] < 0.55 && (!elevation || elevation[i] < 0.72)) habitableCells++; + if ((coastalLowland[i] > 0.20 || basinField[i] > 0.24) && slope[i] < 0.36 && ridgeField[i] < 0.52) lowlandCells++; if (ridgeField[i] > 0.52 || slope[i] > 0.48) mountainCells++; for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) { const ni = indexOf(nx, ny); @@ -48,10 +50,12 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, slope, ridgeField } } const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length; - const settlementNodes = modernCities.length * 1.25 + markets.length * 0.9 + ports.length * 0.7 + independentSatellites * 0.8 + villages.length * 0.35; + const settlementWeight = modernCities.length * 1.6 + markets.length * 1.0 + ports.length * 0.8 + independentSatellites * 0.7 + villages.length * 0.25; const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520); const mountainRatio = landCells ? mountainCells / landCells : 0; - return clamp(Math.round(habitableCells / 260 + settlementNodes * 0.45 + coastlineComplexity * 0.04 + basinBonus + mountainRatio * 4), 18, 48); + const lowlandBonus = Math.min(7, lowlandCells / 430); + const target = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2); + return clamp(target, 20, 50); } function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) { @@ -267,7 +271,7 @@ export function generateAdminLayout({ return !nearMajor && !nearSmallUrban; }); const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum); - const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }); + const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }); const satelliteMunicipalSeeds = (satelliteCities || []) .filter((city) => prefectureMask[indexOf(city.x, city.y)] && city.municipalityClass === "independentSatelliteMunicipality") .map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city })); @@ -310,8 +314,12 @@ export function generateAdminLayout({ changedAfterFinalMerge: 0, targetMunicipalityCount, actualMunicipalityCount: 0, + municipalityCountReason: "habitable cells, settlement weight, coastline complexity, basin/lowland bonus, and mountain-ratio adjustment", changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0, oversizedRuralSplits: 0, + oversizedLowlandSplits: 0, + ruralSplitsAccepted: 0, + ruralSplitsRejected: 0, satelliteMunicipalitiesCreated: satelliteMunicipalSeeds.length, satelliteMunicipalitiesMerged: 0, satelliteMunicipalitiesExpanded: 0, @@ -404,6 +412,9 @@ export function generateAdminLayout({ adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells; adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities; adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities; + adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities; + adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities; + adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0; previousSnapshot = new Int16Array(adminId); snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2); markChanged("changedAfterSnap"); @@ -448,6 +459,8 @@ export function generateAdminLayout({ Object.assign(adminDebug, landscapeDebug); adminDebug.naturalCompartmentCount = adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0; adminDebug.actualMunicipalityCount = new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size; + adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0; + adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0; const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); diff --git a/mapFeatures.js b/mapFeatures.js index e5567e0..c8ef326 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -971,6 +971,88 @@ export function generateMapFeatures(seed, terrain) { } }); + const requiredTransportNodes = []; + function addRequiredTransportNode(node, reason) { + if (!node || !inside(node.x, node.y) || sea[indexOf(node.x, node.y)]) return; + const key = `${node.x},${node.y}`; + if (requiredTransportNodes.some((p) => `${p.x},${p.y}` === key)) return; + requiredTransportNodes.push({ ...node, requiredTransportReason: reason }); + } + addRequiredTransportNode(capital, "capital"); + for (const gate of externalGateways) addRequiredTransportNode(gate, "externalGateway"); + for (const city of modernCities) if ((city.population || 0) >= 120000 || city.isPrefecturalCapital) addRequiredTransportNode(city, "majorCity"); + for (const port of majorPorts) addRequiredTransportNode(port, "majorPort"); + + const backboneAccess = new Map(); + function nodeKey(p) { + return `${p.x},${p.y}`; + } + function backbonePoint(node) { + const key = nodeKey(node); + if (!backboneAccess.has(key)) { + const mode = node.requiredTransportReason === "externalGateway" ? "road" : "road"; + backboneAccess.set(key, routePoint(node, mode, 18000 + node.x * 97 + node.y * 101)); + } + return backboneAccess.get(key); + } + function addBackboneRoad(a, b) { + const start = backbonePoint(a); + const goal = backbonePoint(b); + const existing = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways]; + const path = aStar(start, goal, makeTransportCost(roadCost, existing, roadHubs, [start, goal], 2, 4.2, townAvoidNodes, 2.6, 4.2)); + if (path.length <= 3 || pathCompactness(path) > 3.8 || path.some(([x, y]) => elevation[indexOf(x, y)] > 0.72)) return false; + nationalRoads.push(path); + incrementDegree(roadDegree, a); + incrementDegree(roadDegree, b); + return true; + } + const connectedBackboneNodes = requiredTransportNodes.length ? [requiredTransportNodes[0]] : []; + const pendingBackboneNodes = requiredTransportNodes.slice(1); + let backboneEdgeCount = 0; + while (pendingBackboneNodes.length && connectedBackboneNodes.length) { + let bestIndex = -1; + let bestAnchor = null; + let bestScore = INF; + for (let i = 0; i < pendingBackboneNodes.length; i++) { + const node = pendingBackboneNodes[i]; + for (const anchor of connectedBackboneNodes) { + const d = Math.hypot(node.x - anchor.x, node.y - anchor.y); + const ai = indexOf(anchor.x, anchor.y); + const bi = indexOf(node.x, node.y); + const corridor = sameCorridorAffinity(anchor, node); + const score = d * (1.0 - corridor * 0.22) + Math.max(elevation[ai], elevation[bi]) * 8 - Math.max(passSuitability[ai], passSuitability[bi]) * 4; + if (score < bestScore) { + bestScore = score; + bestIndex = i; + bestAnchor = anchor; + } + } + } + if (bestIndex < 0 || !bestAnchor) break; + const node = pendingBackboneNodes.splice(bestIndex, 1)[0]; + if (addBackboneRoad(bestAnchor, node)) backboneEdgeCount++; + connectedBackboneNodes.push(node); + } + + function nearestPathCellDistance(node, paths) { + let best = INF; + for (const path of paths) { + for (const [x, y] of path) best = Math.min(best, Math.hypot(node.x - x, node.y - y)); + } + return best; + } + const combinedModernBackbone = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways, ...externalRailways, ...expressways]; + const connectedRequiredNodeCount = requiredTransportNodes.filter((node) => nearestPathCellDistance(node, combinedModernBackbone) <= 7).length; + const missingRequiredNodes = requiredTransportNodes + .filter((node) => nearestPathCellDistance(node, combinedModernBackbone) > 7) + .map((node) => ({ x: node.x, y: node.y, kind: node.kind, reason: node.requiredTransportReason })); + const transportDebug = { + requiredNodeCount: requiredTransportNodes.length, + connectedRequiredNodeCount, + missingRequiredNodes, + backboneEdgeCount, + }; + function pruneHighMountainTransport(paths, threshold = 0.82) { for (let i = paths.length - 1; i >= 0; i--) { if (paths[i].some(([x, y]) => elevation[indexOf(x, y)] > threshold)) paths.splice(i, 1); @@ -1327,6 +1409,7 @@ export function generateMapFeatures(seed, terrain) { railInfluence2, villageInfluence, externalGateways, + transportDebug, cityPopulationCap, }; } diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js index 918db7d..4bcb6be 100644 --- a/mapGeneratorHelpers.js +++ b/mapGeneratorHelpers.js @@ -498,6 +498,7 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river, compartmentCount: compartments.filter((unit) => unit.area > 0).length, changedAfterCompartmentAssignment: changed, borderNaturalBarrierAverage: afterNaturalAverage, + voronoiLikeRate: afterVoronoiLikeRate, }, }; } diff --git a/mapOutput.js b/mapOutput.js index be7b84c..9545cfb 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -72,10 +72,12 @@ export function finishMapOutput({ tributaryRivers, smallStreams, externalGateways, + transportDebug, prefectureMask, prefectureBorder, prefectureRegionId, regionalDebug, + terrainDebug, regionalPrefectureBorders, }) { // Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion. @@ -171,28 +173,59 @@ export function finishMapOutput({ center.name = best.name; } } - const adminNamePrefixes = ["\u6771", "\u897F", "\u5357", "\u5317", "\u4E0A", "\u4E0B", "\u65B0", "\u65E7", "\u4E2D", "\u5916"]; + const adminNameSuffixes = [ + "\u753A\u57DF", + "\u5E02\u57DF", + "\u90F7\u57DF", + "\u6D41\u57DF", + "\u6E7E\u5CB8", + "\u5C71\u9E93", + "\u5E73\u91CE", + "\u5730\u533A", + ]; const adminNameCounts = new Map(); for (const center of adminCenters) adminNameCounts.set(center.name, (adminNameCounts.get(center.name) || 0) + 1); - const duplicateOrdinal = new Map(); + const baseVariantCounts = new Map(); for (const center of adminCenters) { - if ((adminNameCounts.get(center.name) || 0) <= 1) continue; - const n = duplicateOrdinal.get(center.name) || 0; - duplicateOrdinal.set(center.name, n + 1); - const prefix = adminNamePrefixes[(Math.floor(center.x / Math.max(1, MAP_W / 3)) + Math.floor(center.y / Math.max(1, MAP_H / 3)) * 3 + n) % adminNamePrefixes.length]; - if (!String(center.name).startsWith(prefix)) center.name = `${prefix}${center.name}`; + const base = String(center.name || ""); + const count = adminNameCounts.get(base) || 0; + if (count <= 1) { + baseVariantCounts.set(base, Math.max(baseVariantCounts.get(base) || 0, 1)); + continue; + } + const usedForBase = baseVariantCounts.get(base) || 0; + if (usedForBase === 0) { + baseVariantCounts.set(base, 1); + continue; + } + if (usedForBase < 2) { + const i = indexOf(center.x, center.y); + const naturalSuffix = coastalLowland[i] > 0.28 + ? "\u6E7E\u5CB8" + : basinField[i] > 0.28 + ? "\u5E73\u91CE" + : ridgeField[i] > 0.42 || slope[i] > 0.34 + ? "\u5C71\u9E93" + : river[i] > 0.25 || flowAccum[i] > 0.38 + ? "\u6D41\u57DF" + : adminNameSuffixes[(Math.floor(center.x / 12) + Math.floor(center.y / 12)) % adminNameSuffixes.length]; + center.name = `${base}${naturalSuffix}`; + center.derivedFromBaseName = base; + nameDebug.derivedNameCount++; + baseVariantCounts.set(base, usedForBase + 1); + } } const usedAdminNames = new Set(); for (const center of adminCenters) { let candidate = center.name; - let guard = 0; - while (usedAdminNames.has(candidate) && guard < adminNamePrefixes.length) { - candidate = `${adminNamePrefixes[(guard + Math.floor(center.x / 12) + Math.floor(center.y / 12)) % adminNamePrefixes.length]}${center.name}`; - guard++; + const generated = String(center.generatedMunicipalityName || ""); + if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) { + candidate = generated; } center.name = candidate; usedAdminNames.add(center.name); } + nameDebug.maxDerivedPerBase = Math.max(0, ...baseVariantCounts.values()); const entitiesForNames = [ ...modernCities, @@ -220,6 +253,7 @@ export function finishMapOutput({ prefectureBorder, prefectureRegionId, regionalDebug, + terrainDebug, regionalPrefectureBorders, elevation, moisture, @@ -291,6 +325,7 @@ export function finishMapOutput({ tributaryRivers, smallStreams, externalGateways, + transportDebug, entitiesForNames, nameDebug, }, options); diff --git a/mapPipeline.js b/mapPipeline.js index 0ec01f0..0c18ed2 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -42,6 +42,7 @@ export function generateMap(seedInput = 114514, options = {}) { prefectureBorder, prefectureRegionId, regionalDebug, + terrainDebug, regionalPrefectureBorders, riverPaths, mainRivers, @@ -53,7 +54,7 @@ export function generateMap(seedInput = 114514, options = {}) { const { ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, premodernRoads, minorRoads, castleTowns, modernCities, populationDensity, railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways, - interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap, + interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap, transportDebug, } = features; const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({ @@ -69,6 +70,6 @@ export function generateMap(seedInput = 114514, options = {}) { railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways, interchanges, logisticsParks, satelliteCities, newTowns, landuse, adminCentersRaw, adminId, adminBorders, adminDebug, riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, prefectureMask, prefectureBorder, prefectureRegionId, regionalPrefectureBorders, - regionalDebug, + regionalDebug, terrainDebug, transportDebug, }); } diff --git a/mapTerrain.js b/mapTerrain.js index 31d47c8..ce0e763 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -1219,6 +1219,72 @@ export function generateTerrainAndRivers(seed) { } } + function countWaterComponents(mask, minArea = 1) { + const seen = new Uint8Array(SIZE); + let count = 0; + for (let i = 0; i < SIZE; i++) { + if (!mask[i] || seen[i]) continue; + const queue = [i]; + seen[i] = 1; + let area = 0; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + area++; + const x = cur % MAP_W; + const y = Math.floor(cur / MAP_W); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (!mask[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + if (area >= minArea) count++; + } + return count; + } + + function countSmallLandIslands(maxArea = 8) { + const seen = new Uint8Array(SIZE); + let count = 0; + for (let i = 0; i < SIZE; i++) { + if (sea[i] || seen[i]) continue; + const queue = [i]; + seen[i] = 1; + let area = 0; + let touchesEdge = false; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + area++; + const x = cur % MAP_W; + const y = Math.floor(cur / MAP_W); + if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesEdge = true; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (sea[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + if (!touchesEdge && area <= maxArea) count++; + } + return count; + } + + const spineValues = [...arcSpineField].filter((_, i) => !sea[i]).sort((a, b) => b - a); + const strongSpineSample = Math.max(1, Math.floor(spineValues.length * 0.05)); + const primarySpineStrength = spineValues.slice(0, strongSpineSample).reduce((sum, value) => sum + value, 0) / strongSpineSample; + const riverConnectivityRate = mainRivers.length + ? mainRivers.filter((path) => path.some(([x, y], k) => k > path.length * 0.45 && neighbors8(x, y).some(([nx, ny]) => sea[indexOf(nx, ny)] || lake[indexOf(nx, ny)]))).length / mainRivers.length + : 0; + const depositionLowlandArea = [...depositionalLowland].filter((value, i) => !sea[i] && value > 0.24).length; + const terrainDebug = { + primarySpineStrength, + riverConnectivityRate, + smallIslandCount: countSmallLandIslands(8), + largeInlandLakeCount: countWaterComponents(Float32Array.from(lake, (value) => value ? 1 : 0), 120), + depositionLowlandArea, + }; return { terrainTemplate, @@ -1252,6 +1318,7 @@ export function generateTerrainAndRivers(seed) { prefectureBorder, prefectureRegionId, regionalDebug, + terrainDebug, regionalPrefectureBorders, riverPaths, mainRivers, diff --git a/names.js b/names.js index 81ef204..693308e 100644 --- a/names.js +++ b/names.js @@ -406,9 +406,13 @@ export function createNameDebug(probabilities = NAME_PROBABILITIES, pools = NAME generatedNamesUsed: 0, invalidNamesRejected: 0, oneCharacterNamesPrevented: 0, + rejectedOneCharacterNames: 0, duplicateRetries: 0, fallbackAttempts: 0, legacyFallbackUsed: 0, + oneKanjiAppendFallbackUsed: 0, + derivedNameCount: 0, + maxDerivedPerBase: 0, }; } @@ -514,6 +518,7 @@ function tryCustomName(seed, id, usedNames, debug) { const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true }); if (!validation.valid) { if (validation.reason === "oneCharacter") debug.oneCharacterNamesPrevented++; + if (validation.reason === "oneCharacter") debug.rejectedOneCharacterNames++; else debug.invalidNamesRejected++; return null; } @@ -546,7 +551,10 @@ export function generateEntityName(seed, id, entity, fields, usedNames = null, d continue; } if (result.invalidReason) { - if (result.invalidReason === "oneCharacter") debug.oneCharacterNamesPrevented++; + if (result.invalidReason === "oneCharacter") { + debug.oneCharacterNamesPrevented++; + debug.rejectedOneCharacterNames++; + } else debug.invalidNamesRejected++; continue; } diff --git a/renderer.js b/renderer.js index 0794714..b88c4a6 100644 --- a/renderer.js +++ b/renderer.js @@ -9,9 +9,9 @@ function distToNearest(points, x, y, fallback = 999) { function blendOutside(color, isInside) { if (isInside) return color; return [ - Math.round(color[0] * 0.55 + 112), - Math.round(color[1] * 0.55 + 112), - Math.round(color[2] * 0.55 + 112), + Math.round(color[0] * 0.8 + 50), + Math.round(color[1] * 0.8 + 50), + Math.round(color[2] * 0.8 + 50), ]; } @@ -38,47 +38,36 @@ function terrainColorContinuous(map, fx, fy, mode) { let color; if (map.sea[i]) { - // Google Map風の海の色(明るい青) const depth = clamp((0.35 - fieldSample(map.elevation, fx, fy)) * 2.4); - color = [Math.round(170 + depth * 10), Math.round(211 + depth * 15), Math.round(223 + depth * 20)]; + color = [Math.round(170 + depth * 5), Math.round(218 + depth * 10), Math.round(255 - depth * 5)]; } else if (mode === "suitability") { const a = fieldSample(map.agriculture, fx, fy); const p = fieldSample(map.plain, fx, fy); const f = fieldSample(map.floodplain, fx, fy); color = [ - Math.round(230 - p * 25 + f * 15), - Math.round(235 + a * 15), - Math.round(210 - a * 25 + p * 20), + Math.round(240 - p * 15 + f * 10), + Math.round(242 + a * 10), + Math.round(235 - a * 15 + p * 10), ]; } else if (mode === "development") { - const x = Math.floor(fx); - const y = Math.floor(fy); - const baseIndex = indexOf(Math.max(0, Math.min(MAP_W - 1, x)), Math.max(0, Math.min(MAP_H - 1, y))); const dCity = distToNearest(map.modernCities, fx, fy); - const dInd = distToNearest(map.industrialZones, fx, fy); - const dLog = distToNearest(map.logisticsParks, fx, fy); - const dNew = distToNearest(map.newTowns, fx, fy); const urban = clamp(1 - dCity / 25); const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban; - const industrial = clamp(1 - dInd / 10); - const logistics = clamp(1 - dLog / 9); - const newTown = clamp(1 - dNew / 9); - const base = 214 + map.plain[baseIndex] * 22; + const base = 235; color = [ - Math.round(base + density * 30 + urban * 8 + industrial * 14), - Math.round(base + density * 10 + logistics * 15 + newTown * 12), - Math.round(208 + map.agriculture[baseIndex] * 22 + density * 18 + newTown * 22), + Math.round(base + density * 20), + Math.round(base + density * 5), + Math.round(230 + density * 10), ]; } else { - // 起伏の大きさが読めるよう、標高段彩をやや強める + // 地形色を少し濃く(暗く)調整 const e = fieldSample(map.elevation, fx, fy); - const m = fieldSample(map.moisture, fx, fy); - if (e > 0.82) color = [178, 170, 160]; - else if (e > 0.68) color = [198, 188, 164]; - else if (e > 0.52) color = [205, 222 + m * 5, 184]; - else if (e > 0.34) color = [224, 238 + m * 6, 206]; - else if (e > 0.24) color = [236, 245 + m * 5, 220]; - else color = [218, 232 + m * 6, 206]; + if (e > 0.82) color = [210, 205, 195]; + else if (e > 0.68) color = [218, 215, 205]; + else if (e > 0.52) color = [220, 225, 210]; + else if (e > 0.34) color = [225, 230, 215]; + else if (e > 0.24) color = [230, 235, 220]; + else color = [238, 242, 228]; } return blendOutside(color, isInside); @@ -89,62 +78,31 @@ function discreteColor(map, x, y, mode) { let color; if (map.sea[i]) { - // Google Map like styled color = [170, 218, 255]; } else if (mode === "landuse") { const colors = { - 0: [230, 242, 220], // farmland - 1: [235, 245, 225], // plain - 2: [235, 230, 220], // old city - 3: [224, 202, 190], // CBD - 4: [245, 240, 230], // suburb - 5: [220, 220, 225], // industrial area - 6: [225, 235, 225], // logistics area - 7: [238, 242, 248], // new town - 8: [248, 242, 230], // coastal development - 9: [225, 238, 220], // others + 0: [242, 248, 238], + 1: [248, 250, 245], + 2: [240, 238, 232], + 3: [245, 230, 220], + 4: [250, 248, 245], + 5: [235, 235, 240], + 6: [240, 245, 240], + 7: [245, 248, 252], + 8: [250, 248, 240], + 9: [240, 245, 238], }; color = colors[map.landuse[i]] || colors[0]; } else if (mode === "admin") { const palette = [ - [245, 235, 230], - [235, 245, 235], - [240, 240, 250], - [250, 245, 230], - [245, 240, 248], - [230, 245, 245], - [250, 240, 240], - [240, 250, 235], + [250, 245, 242], [245, 250, 245], [245, 245, 252], + [252, 250, 242], [250, 245, 250], [242, 250, 250] ]; const a = map.adminId[i]; - color = a >= 0 ? palette[a % palette.length] : [220, 225, 220]; - } else if (mode === "terrain-debug") { - const ridge = clamp(map.ridgeField[i] * 0.68 + (map.arcSpineField?.[i] || 0) * 0.42 + (map.branchRidgeField?.[i] || 0) * 0.34); - const deposit = clamp((map.depositionField?.[i] || 0) * 5.0 + (map.depositionalLowland?.[i] || 0) * 0.48 + (map.alluvialFanField?.[i] || 0) * 0.34 + (map.deltaField?.[i] || 0) * 0.46); - const valley = clamp(map.valleyField[i] * 0.72 + map.river[i] * 0.22); - const barrier = clamp(map.naturalBarrierScore?.[i] || ridge); - color = [ - Math.round(220 - deposit * 70 + ridge * 48), - Math.round(226 + deposit * 38 + valley * 28 - barrier * 52), - Math.round(214 + valley * 58 + barrier * 34 - ridge * 42), - ]; - } else if (mode === "admin-debug" || mode === "borders-debug") { - const barrier = clamp( - map.ridgeField[i] * 0.88 + - Math.max(0, map.river[i] - 0.28) * 1.25 + - Math.max(0, map.flowAccum[i] - 0.36) * 0.72 + - map.slope[i] * 0.48 + - Math.max(0, map.elevation[i] - 0.54) * 0.34 - ); - color = [ - Math.round(238 - barrier * 28), - Math.round(242 - barrier * 88), - Math.round(226 + barrier * 20), - ]; + color = a >= 0 ? palette[a % palette.length] : [240, 242, 240]; } else { color = terrainColorContinuous(map, x, y, "terrain"); } - return blendOutside(color, Boolean(map.prefectureMask[i])); } @@ -165,17 +123,12 @@ function drawBase(ctx, map, mode, continuousTerrain) { const eR = fieldSample(map.elevation, fx + 0.6, fy); const eU = fieldSample(map.elevation, fx, fy - 0.6); const eD = fieldSample(map.elevation, fx, fy + 0.6); - const shade = clamp(0.9 + (eR - eL) * 1.0 + (eD - eU) * 0.65, 0.72, 1.18); - const elevation = fieldSample(map.elevation, fx, fy); - const contour = Math.abs((elevation * 16) - Math.round(elevation * 16)); - const majorContour = Math.abs((elevation * 8) - Math.round(elevation * 8)); - const isLand = !map.sea[indexOf(Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))), Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))))]; - const contourFactor = isLand && majorContour < 0.022 ? 0.86 : isLand && contour < 0.028 ? 0.94 : 1; + const shade = clamp(0.95 + (eR - eL) * 0.6 + (eD - eU) * 0.4, 0.85, 1.08); const ii = (py * width + px) * 4; - img.data[ii] = Math.round(r * shade * contourFactor); - img.data[ii + 1] = Math.round(g * shade * contourFactor); - img.data[ii + 2] = Math.round(b * shade * contourFactor); + img.data[ii] = Math.round(r * shade); + img.data[ii + 1] = Math.round(g * shade); + img.data[ii + 2] = Math.round(b * shade); img.data[ii + 3] = 255; } } @@ -195,7 +148,6 @@ function drawBase(ctx, map, mode, continuousTerrain) { } } } - ctx.putImageData(img, 0, 0); } @@ -206,7 +158,7 @@ function drawPath(ctx, path, color, width, dashed = false) { ctx.lineJoin = "round"; ctx.strokeStyle = color; ctx.lineWidth = width; - if (dashed) ctx.setLineDash([6, 5]); + if (dashed) ctx.setLineDash([8, 6]); ctx.beginPath(); ctx.moveTo(path[0][0] * CELL_SIZE + CELL_SIZE / 2, path[0][1] * CELL_SIZE + CELL_SIZE / 2); for (let k = 1; k < path.length; k++) { @@ -216,72 +168,64 @@ function drawPath(ctx, path, color, width, dashed = false) { ctx.restore(); } -function segmentPointKey(p) { - return `${p[0]},${p[1]}`; -} +// 魚の骨(私鉄記号)スタイルを描画するための専用関数 +function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) { + if (!path || path.length < 2) return; + ctx.save(); + ctx.lineCap = "butt"; + ctx.lineJoin = "round"; + ctx.strokeStyle = color; -function chainSegments(segments) { - const unused = segments.map((seg) => [seg[0], seg[1]]); - const chains = []; - while (unused.length) { - const chain = unused.pop(); - let grew = true; - while (grew) { - grew = false; - const head = segmentPointKey(chain[0]); - const tail = segmentPointKey(chain[chain.length - 1]); - for (let i = unused.length - 1; i >= 0; i--) { - const [a, b] = unused[i]; - const ak = segmentPointKey(a); - const bk = segmentPointKey(b); - if (ak === tail) { chain.push(b); unused.splice(i, 1); grew = true; break; } - if (bk === tail) { chain.push(a); unused.splice(i, 1); grew = true; break; } - if (bk === head) { chain.unshift(a); unused.splice(i, 1); grew = true; break; } - if (ak === head) { chain.unshift(b); unused.splice(i, 1); grew = true; break; } - } - } - chains.push(chain); + // 中心の実線を描画 + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.moveTo(path[0][0] * CELL_SIZE + CELL_SIZE / 2, path[0][1] * CELL_SIZE + CELL_SIZE / 2); + for (let k = 1; k < path.length; k++) { + ctx.lineTo(path[k][0] * CELL_SIZE + CELL_SIZE / 2, path[k][1] * CELL_SIZE + CELL_SIZE / 2); } - return chains; -} + ctx.stroke(); -function chaikin(points, passes = 1) { - let out = points; - for (let pass = 0; pass < passes; pass++) { - if (out.length < 3) return out; - const next = [out[0]]; - for (let i = 0; i < out.length - 1; i++) { - const a = out[i]; - const b = out[i + 1]; - next.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]); - next.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]); + // 棘(クロスハッチ)を描画 + ctx.lineWidth = 1.0; + ctx.beginPath(); + let leftover = 0; + for (let k = 0; k < path.length - 1; k++) { + const x1 = path[k][0] * CELL_SIZE + CELL_SIZE / 2; + const y1 = path[k][1] * CELL_SIZE + CELL_SIZE / 2; + const x2 = path[k+1][0] * CELL_SIZE + CELL_SIZE / 2; + const y2 = path[k+1][1] * CELL_SIZE + CELL_SIZE / 2; + const dx = x2 - x1; + const dy = y2 - y1; + const dist = Math.hypot(dx, dy); + if (dist === 0) continue; + + // 法線(直角)ベクトル + const nx = dx / dist; + const ny = dy / dist; + const px = -ny * (tickLen / 2); + const py = nx * (tickLen / 2); + + let d = (spacing / 2) + leftover; + while (d < dist) { + const cx = x1 + nx * d; + const cy = y1 + ny * d; + ctx.moveTo(cx + px, cy + py); + ctx.lineTo(cx - px, cy - py); + d += spacing; } - next.push(out[out.length - 1]); - out = next; + leftover = d - dist; } - return out; + ctx.stroke(); + ctx.restore(); } -function drawSegments(ctx, segments, color, width, dashed = false, smooth = false) { +function drawSegments(ctx, segments, color, width, dashed = false) { ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = "round"; ctx.lineJoin = "round"; - if (dashed) ctx.setLineDash([4, 4]); - - if (smooth) { - for (const chain of chainSegments(segments)) { - const points = chaikin(chain, 1); - if (points.length < 2) continue; - ctx.beginPath(); - ctx.moveTo(points[0][0] * CELL_SIZE, points[0][1] * CELL_SIZE); - for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0] * CELL_SIZE, points[i][1] * CELL_SIZE); - ctx.stroke(); - } - ctx.restore(); - return; - } + if (dashed) ctx.setLineDash([6, 5]); for (const seg of segments) { ctx.beginPath(); @@ -296,15 +240,14 @@ function drawUrbanAreas(ctx, map, mode) { const visibleModes = ["all", "modern", "development", "landuse", "roads", "admin"]; if (!visibleModes.includes(mode)) return; - // Google Map風の都市部の色 const colors = { - 2: "rgba(235, 230, 220, 0.75)", // 旧市街 - 薄いベージュ - 3: "rgba(224, 202, 190, 0.9)", // 中心市街地 / CBD - cell fill - 4: "rgba(245, 242, 235, 0.7)", // 郊外 - 薄いクリーム - 5: "rgba(220, 220, 228, 0.8)", // 工業地域 - 薄いグレー - 6: "rgba(225, 235, 228, 0.75)", // 物流 - 薄い緑グレー - 7: "rgba(238, 242, 250, 0.75)", // ニュータウン - 薄い青白 - 8: "rgba(248, 245, 238, 0.7)", // 沿道 - クリーム + 2: "rgba(225, 222, 215, 0.6)", + 3: "rgba(240, 220, 205, 0.85)", + 4: "rgba(242, 240, 235, 0.5)", + 5: "rgba(220, 220, 225, 0.6)", + 6: "rgba(225, 230, 225, 0.5)", + 7: "rgba(235, 240, 245, 0.6)", + 8: "rgba(245, 242, 235, 0.5)", }; ctx.save(); @@ -319,38 +262,22 @@ function drawUrbanAreas(ctx, map, mode) { const py = y * CELL_SIZE; ctx.fillStyle = colors[lu]; ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE); + } + } + ctx.restore(); +} - const h = ((x * 92821 + y * 68917 + lu * 131) >>> 0); - if (lu === 3 || lu === 2 || lu === 5 || lu === 6) { - // 建物の表現を控えめに - ctx.fillStyle = lu === 3 ? "rgba(200,200,200,0.25)" : "rgba(210,210,210,0.2)"; - if (h % 3 !== 0) ctx.fillRect(px + 1, py + 1, 2, 2); - if (h % 5 !== 0) ctx.fillRect(px + 3, py + 2, 2, 2); - if (h % 7 !== 0) ctx.fillRect(px + 2, py + 4, 2, 1.5); - } else if (lu === 4 || lu === 7) { - ctx.strokeStyle = lu === 7 ? "rgba(220,220,230,0.15)" : "rgba(200,190,180,0.15)"; - ctx.lineWidth = 0.8; - ctx.beginPath(); - if (h % 2 === 0) { - ctx.moveTo(px + 1, py + 1); - ctx.lineTo(px + CELL_SIZE - 1, py + 1); - ctx.moveTo(px + 1, py + 4); - ctx.lineTo(px + CELL_SIZE - 1, py + 4); - } else { - ctx.moveTo(px + 1, py + 1); - ctx.lineTo(px + 1, py + CELL_SIZE - 1); - ctx.moveTo(px + 4, py + 1); - ctx.lineTo(px + 4, py + CELL_SIZE - 1); - } - ctx.stroke(); - } else if (lu === 8) { - ctx.strokeStyle = "rgba(180,170,160,0.2)"; - ctx.lineWidth = 0.9; - ctx.beginPath(); - ctx.moveTo(px + 1, py + 3); - ctx.lineTo(px + CELL_SIZE - 1, py + 3); - ctx.stroke(); - } +function drawDebugCells(ctx, map, field, color) { + if (!field) return; + ctx.save(); + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!map.prefectureMask[i] || map.sea[i]) continue; + const v = clamp(field[i] || 0, 0, 1); + if (v <= 0.12) continue; + ctx.fillStyle = color(v); + ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE); } } ctx.restore(); @@ -361,91 +288,41 @@ function dot(ctx, p, radius, fill, stroke = "white") { ctx.arc(p.x * CELL_SIZE + CELL_SIZE / 2, p.y * CELL_SIZE + CELL_SIZE / 2, radius, 0, Math.PI * 2); ctx.fillStyle = fill; ctx.fill(); - ctx.lineWidth = 1.5; + ctx.lineWidth = 1.2; ctx.strokeStyle = stroke; ctx.stroke(); } -function squareIcon(ctx, p, size, fill, stroke = "white") { - const x = p.x * CELL_SIZE + CELL_SIZE / 2; - const y = p.y * CELL_SIZE + CELL_SIZE / 2; - ctx.save(); - ctx.fillStyle = fill; - ctx.strokeStyle = stroke; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.rect(x - size / 2, y - size / 2, size, size); - ctx.fill(); - ctx.stroke(); - ctx.restore(); -} - -function triangleIcon(ctx, p, size, fill, stroke = "white") { - const x = p.x * CELL_SIZE + CELL_SIZE / 2; - const y = p.y * CELL_SIZE + CELL_SIZE / 2; - ctx.save(); - ctx.fillStyle = fill; - ctx.strokeStyle = stroke; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(x, y - size / 2); - ctx.lineTo(x + size / 2, y + size / 2); - ctx.lineTo(x - size / 2, y + size / 2); - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - ctx.restore(); -} - -function railStationIcon(ctx, p) { - squareIcon(ctx, p, 5.2, "rgba(255,255,255,0.96)", "rgba(55,55,55,0.92)"); -} - - -function drawHarborWorks(ctx, map) { - if (!map.harborWorks) return; - ctx.save(); - ctx.strokeStyle = "rgba(95, 120, 150, 0.9)"; - ctx.lineWidth = 2.2; - ctx.lineCap = "round"; - for (const harbor of map.harborWorks) { - for (const seg of harbor.segments || []) { - ctx.beginPath(); - ctx.moveTo(seg[0][0] * CELL_SIZE + CELL_SIZE / 2, seg[0][1] * CELL_SIZE + CELL_SIZE / 2); - ctx.lineTo(seg[1][0] * CELL_SIZE + CELL_SIZE / 2, seg[1][1] * CELL_SIZE + CELL_SIZE / 2); - ctx.stroke(); - } - } - ctx.restore(); -} - -function boxesOverlap(a, b, pad = 2) { +function boxesOverlap(a, b, pad = 3) { return !(a.x2 + pad < b.x1 || a.x1 - pad > b.x2 || a.y2 + pad < b.y1 || a.y1 - pad > b.y2); } function labelWithCollision(ctx, p, occupied) { if (!p.name) return false; ctx.save(); - ctx.font = "11px ui-sans-serif, system-ui, sans-serif"; + ctx.font = "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, -5], [7, 12], [-textW - 7, -5], [-textW - 7, 12], - [-textW / 2, -13], [-textW / 2, 20], [12, 2], [-textW - 12, 2], + [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 + 3 }; + const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 4 }; 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; - ctx.lineWidth = 3; - ctx.strokeStyle = "rgba(255,255,255,0.95)"; - ctx.fillStyle = "rgba(40,40,40,0.95)"; + + ctx.lineJoin = "round"; + ctx.lineWidth = 3.5; + ctx.strokeStyle = "rgba(255, 255, 255, 0.95)"; ctx.strokeText(p.name, x, y); + + ctx.fillStyle = p.isPrefecturalCapital ? "#111111" : "#333333"; ctx.fillText(p.name, x, y); occupied.push(box); ctx.restore(); @@ -459,7 +336,7 @@ function drawLabels(ctx, points, limit = Infinity) { const occupied = []; const prioritized = points .filter((p) => p?.name) - .map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) + (p.kind === "Market Town" ? 48 : 0) + (p.kind?.includes("Castle") ? 70 : 0) + (p.kind === "External Gateway" ? 60 : 0) + (p.kind === "Municipal Center" ? 34 : 0) })) + .map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) })) .sort((a, b) => b.labelPriority - a.labelPriority); for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied); } @@ -471,111 +348,113 @@ export function drawMap(canvas, map, options) { const mode = options.mode || "all"; const showFeatures = options.showFeatures !== false; const showLabels = options.showLabels !== false; - const continuousTerrain = true; - + const width = MAP_W * CELL_SIZE; const height = MAP_H * CELL_SIZE; canvas.width = width; canvas.height = height; - drawBase(ctx, map, mode, continuousTerrain); + // 1. Base Terrain & Urban + drawBase(ctx, map, mode, true); drawUrbanAreas(ctx, map, mode); - // Rivers use the same hue family as the sea; hierarchy is expressed by width/opacity. - const waterBlue = "rgba(170, 218, 255, 0.95)"; - // Small streams remain in the data model but are not drawn by default. - for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(170, 218, 255, 0.78)", 1.35); - for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.2); - drawHarborWorks(ctx, map); + // 2. Rivers + const waterBlue = "rgba(160, 205, 240, 1)"; + for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(160, 205, 240, 0.8)", 1.5); + for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.5); - const debugBorders = mode === "admin-debug" || mode === "borders-debug"; - if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, debugBorders ? "rgba(40,40,40,0.82)" : mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", debugBorders ? 1.8 : 1.0, false, mode === "all"); - drawSegments(ctx, map.prefectureBorder, "rgba(30,30,30,0.82)", 2.4, false, true); - drawSegments(ctx, map.prefectureBorder, "rgba(255,255,255,0.74)", 1.05, false, true); + const showHistory = ["history", "all", "terrain"].includes(mode); + const showModern = ["modern", "all", "development", "landuse", "roads", "admin-debug", "borders-debug"].includes(mode); + const showRoads = ["roads", "all", "development"].includes(mode); + const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); + + // 3. Borders + if (showAdmin && map.adminBorders) { + drawSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false); + drawSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true); + } + if (mode === "admin-debug" || mode === "borders-debug") { + drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => `rgba(255, 120, 40, ${0.06 + v * 0.18})`); + if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(60, 110, 170, 0.42)", 0.8, true); + if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false); + } + + drawSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false); + drawSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true); if (!showFeatures) return; - const showHistory = ["history", "all", "terrain", "suitability"].includes(mode); - const showModern = ["modern", "all", "development", "landuse"].includes(mode); - const showRoads = ["roads", "all", "development", "landuse"].includes(mode); - const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); - - if (debugBorders && map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(70,70,70,0.32)", 0.75); - if (showAdmin) drawSegments(ctx, map.adminBorders, debugBorders ? "rgba(20,90,180,0.96)" : mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", debugBorders ? 2.1 : mode === "all" ? 0.9 : 1.3); - if (showAdmin && mode !== "all") { - for (const p of map.adminCenters || []) dot(ctx, p, 3.0, "rgba(255,255,255,0.96)", "rgba(70,90,120,0.85)"); - } - + // 4. Casings (Outlines) if (showHistory) { - for (const path of map.premodernRoads) drawPath(ctx, path, mode === "all" ? "rgba(150, 120, 90, 0.34)" : "rgba(150, 120, 90, 0.55)", mode === "all" ? 1.15 : 1.45, true); - for (const path of map.minorRoads) drawPath(ctx, path, mode === "all" ? "rgba(180, 150, 120, 0.28)" : "rgba(180, 150, 120, 0.62)", mode === "all" ? 0.8 : 1.05); + // 古い道は白の実線が引き立つように淡いケーシングを敷く + for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5); } - if (showModern) { - // 鉄道 - 濃いグレー - for (const path of map.railways) drawPath(ctx, path, "rgba(80, 80, 80, 0.9)", 2.8); - for (const path of map.ringRailways || []) drawPath(ctx, path, "rgba(70, 70, 70, 0.82)", 2.1); - for (const path of map.branchRailways) drawPath(ctx, path, "rgba(100, 100, 100, 0.82)", 1.9); - for (const path of map.externalRailways) drawPath(ctx, path, "rgba(90, 90, 90, 0.9)", 2.4); - } - - if (showRoads) { - // Google Map風の道路 - 白と黄色とオレンジ - for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.2); - for (const path of map.icAccessRoads || []) drawPath(ctx, path, "rgba(255, 230, 150, 0.82)", 1.45); - for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.0); - for (const path of map.expressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.0); - for (const path of map.externalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.5); - for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.2); - } - - if (showHistory) { - for (const p of map.villages) dot(ctx, p, mode === "all" ? 1.35 : 1.9, mode === "all" ? "rgba(120, 100, 80, 0.42)" : "rgba(120, 100, 80, 0.72)"); - for (const p of map.markets) dot(ctx, p, 4.8, "rgba(200, 130, 80, 0.95)"); - for (const p of map.ports) { - const color = p.portClass === "major" ? "rgba(40, 105, 190, 0.98)" : p.portClass === "regional" ? "rgba(70, 130, 200, 0.95)" : p.portClass === "lake" ? "rgba(80, 155, 180, 0.92)" : "rgba(95, 150, 195, 0.82)"; - triangleIcon(ctx, p, p.portClass === "major" ? 8.2 : 6.5, color); + if (showModern || showRoads) { + // 鉄道のケーシング(白背景を敷いて視認性を保つ) + for (const path of map.railways) drawPath(ctx, path, "rgba(255, 255, 255, 0.75)", 3.5); + for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.6)", 2.5); + for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.75)", 3.5); + + // 幹線道路のケーシング(色を濃く) + if (showRoads) { + for (const path of map.expressways) drawPath(ctx, path, "rgba(80, 140, 100, 1)", 5.0); + for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(80, 140, 100, 1)", 5.0); + for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); + for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); + for (const path of map.externalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); } - for (const p of map.crossings) dot(ctx, p, 3.5, "rgba(255, 240, 180, 0.95)", "rgba(100,90,70,0.8)"); - for (const p of map.passes) dot(ctx, p, 3.9, "rgba(150, 120, 180, 0.95)"); - for (const p of map.castles) squareIcon(ctx, p, 7.0, "rgba(180, 70, 70, 0.96)"); - for (const p of map.castleRuins) dot(ctx, p, 3.2, "rgba(110, 90, 90, 0.9)", "rgba(220,220,220,0.8)"); } + // 5. Fills (Inner colors) & Fishbones + if (showHistory) { + for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(255, 255, 255, 1)", 1.2, false); + } + + if (showModern || showRoads) { + // 鉄道の骨線描画(色, 線幅, 棘の長さ, 棘の間隔) + for (const path of map.railways) drawRailway(ctx, path, "rgba(110, 110, 110, 1)", 1.5, 5.0, 7.0); + for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(140, 140, 140, 1)", 1.0, 4.0, 6.0); + for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(110, 110, 110, 1)", 1.5, 5.0, 7.0); + + // 幹線道路の塗り(色を濃く) + if (showRoads) { + for (const path of map.expressways) drawPath(ctx, path, "rgba(110, 185, 130, 1)", 3.0); + for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(110, 185, 130, 1)", 3.0); + for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); + for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); + for (const path of map.externalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); + } + } + + // 6. Icons & Labels if (showModern) { - for (const p of map.industrialZones) squareIcon(ctx, p, 6.5, "rgba(140, 140, 150, 0.96)"); - for (const p of map.stations) railStationIcon(ctx, p); - for (const p of map.satelliteCities || []) dot(ctx, p, 4.8, "rgba(215, 95, 145, 0.95)"); - for (const p of map.newTowns) triangleIcon(ctx, p, 6.5, "rgba(180, 210, 240, 0.95)"); + for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444"); for (const p of map.modernCities) { - const popRadius = p.population ? Math.min(9.2, 3.4 + Math.sqrt(p.population) / 360) : 4.7; - const rankBoost = p.isPrefecturalCapital || p.rank === "Prefectural Capital" ? 1.6 : p.rank === "Regional Center" ? 0.45 : 0; - dot(ctx, p, popRadius + rankBoost, "rgba(230, 100, 100, 0.95)"); - if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 4.2, "rgba(255,255,255,0.0)", "rgba(180,60,60,0.95)"); + const popRadius = p.population ? Math.min(8.5, 3.5 + Math.sqrt(p.population) / 400) : 4.5; + dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)"); + if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 3.0, "transparent", "rgba(200,80,80,0.9)"); + } + if (mode === "admin-debug" || 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 (showRoads) { - for (const p of map.logisticsParks) squareIcon(ctx, p, 6.2, "rgba(110, 170, 140, 0.96)"); - for (const p of map.interchanges) dot(ctx, p, 4.1, "rgba(255, 255, 255, 0.98)", "rgba(220, 90, 60, 0.95)"); - for (const p of map.externalGateways) dot(ctx, p, 4.4, "rgba(255, 250, 200, 0.98)", "rgba(60,60,60,0.92)"); } if (showLabels) { - const adminLabels = (map.adminCenters || []) - .map((p) => ({ ...p, labelPriorityBase: mode === "admin" || mode === "admin-debug" || mode === "borders-debug" ? 90 : 8 })) - .filter((p) => mode !== "all" || p.representativeFeatureName); + if (mode === "admin") { + drawLabels(ctx, map.adminCenters || [], Infinity); + return; + } + if (mode === "admin-debug" || mode === "borders-debug") { + drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity); + return; + } const important = [ ...map.modernCities, ...map.ports, - ...map.markets.slice(0, mode === "all" ? 6 : 10), - ...map.castles.slice(0, mode === "all" ? 5 : 8), ...(map.satelliteCities || []), - ...map.newTowns, - ...(mode === "admin" || mode === "admin-debug" || mode === "borders-debug" ? adminLabels : adminLabels.slice(0, 8)), - ...map.externalGateways, ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); - - drawLabels(ctx, important, mode === "all" ? 34 : Infinity); + drawLabels(ctx, important, 60); } } diff --git a/styles.css b/styles.css index 92d07c0..5b45a82 100644 --- a/styles.css +++ b/styles.css @@ -1,11 +1,68 @@ -*{box-sizing:border-box} body{margin:0;background:#f5f5f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} button,input{font:inherit} code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .app{min-height:100vh;padding:24px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px;max-width:1400px;margin:0 auto}.header{margin-bottom:16px}.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a}.header p{margin:0;color:#5a5a5a;line-height:1.65;font-size:14px}.canvas-shell,.card{background:#ffffff;border:1px solid rgb(0 0 0 / 0.12);border-radius:18px;box-shadow:0 2px 8px rgb(0 0 0 / 0.08)}.canvas-shell{padding:12px;overflow:auto;position:relative}.map-canvas{display:block;border-radius:12px;background:#f8f8f8}.sidebar{display:flex;flex-direction:column;gap:14px}.card{padding:16px}.label,.card-title{display:block;margin-bottom:10px;color:#2c2c2c;font-size:14px;font-weight:650}.input{width:100%;border:1px solid rgb(0 0 0 / 0.18);background:#fafafa;color:#2c2c2c;border-radius:12px;padding:9px 11px;outline:none}.input:focus{border-color:rgb(66 133 244 / 0.6)}.primary-button,.mode-button{border:0;border-radius:12px;padding:9px 11px;cursor:pointer}.primary-button{margin-top:10px;width:100%;background:#1a73e8;color:#fff;font-weight:650}.primary-button:hover{background:#1557b0}.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.mode-button{background:#f1f3f4;color:#3c4043;border:1px solid transparent}.mode-button:hover{background:#e8eaed}.mode-button.active{background:#1a73e8;color:#fff;font-weight:650;border:1px solid #1a73e8}.checkbox-row{display:flex;gap:8px;align-items:center;margin-top:12px;color:#3c4043;font-size:14px}.stats{display:flex;flex-direction:column;gap:7px}.stat-row{display:flex;justify-content:space-between;gap:12px;color:#5f6368;font-size:13px;align-items:baseline}.stat-row strong{color:#202124;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.legend{color:#5f6368;font-size:12px;line-height:1.65}.legend p{margin:8px 0 0}.example{margin:10px 0;padding:10px;background:#f8f9fa;border:1px solid rgb(0 0 0 / 0.1);border-radius:10px;color:#3c4043;overflow:auto}.id-list{max-height:220px;overflow:auto;margin-top:10px;display:flex;flex-direction:column;gap:6px}.id-row{display:grid;grid-template-columns:112px 1fr;gap:8px;align-items:center;color:#5f6368;font-size:12px}@media (max-width:1100px){.layout{grid-template-columns:1fr}} +*{box-sizing:border-box} +body{margin:0;background:#f0f2f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input{font:inherit} +code{background:#e8e8e8;border-radius:4px;padding:1px 4px} +.app{min-height:100vh;padding:24px} +.layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:20px;max-width:1400px;margin:0 auto} +.header{margin-bottom:16px} +.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a;font-weight:700} +.header p{margin:0;color:#5a5a5a;line-height:1.6;font-size:14px} +.canvas-shell,.card{background:#ffffff;border:1px solid rgba(0,0,0,0.08);border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,0.04)} +.canvas-shell{padding:12px;overflow:auto;position:relative} +.map-canvas{display:block;border-radius:8px;background:#f8f9fa} +.sidebar{display:flex;flex-direction:column;gap:16px} +.card{padding:18px} +.label,.card-title{display:block;margin-bottom:12px;color:#1a1a1a;font-size:14px;font-weight:600} +.input{width:100%;border:1px solid rgba(0,0,0,0.15);background:#fff;color:#2c2c2c;border-radius:8px;padding:10px 12px;outline:none;transition:border-color 0.2s} +.input:focus{border-color:#1a73e8;box-shadow:0 0 0 3px rgba(26,115,232,0.15)} +.primary-button,.mode-button{border:0;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;transition:background 0.2s} +.primary-button{margin-top:12px;width:100%;background:#1a73e8;color:#fff} +.primary-button:hover{background:#1557b0} +.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.mode-button{background:#f1f3f4;color:#3c4043;border:1px solid transparent} +.mode-button:hover{background:#e8eaed} +.mode-button.active{background:#e8f0fe;color:#1a73e8;border:1px solid #1a73e8} +.checkbox-row{display:flex;gap:8px;align-items:center;margin-top:12px;color:#3c4043;font-size:14px;cursor:pointer} +.stats{display:flex;flex-direction:column;gap:8px} +.stat-row{display:flex;justify-content:space-between;gap:12px;color:#5f6368;font-size:13px;align-items:baseline} +.stat-row strong{color:#202124;font-family:ui-monospace,monospace} +.legend{color:#5f6368;font-size:13px;line-height:1.6} +.legend p{margin:8px 0 0} +.example{margin:10px 0;padding:12px;background:#f8f9fa;border:1px solid rgba(0,0,0,0.08);border-radius:8px;color:#3c4043;overflow:auto;font-size:12px} +.id-list{max-height:220px;overflow:auto;margin-top:12px;display:flex;flex-direction:column;gap:6px} +.id-row{display:grid;grid-template-columns:112px 1fr;gap:8px;align-items:center;color:#5f6368;font-size:12px} -.legend-grid{display:flex;flex-direction:column;gap:7px;margin-top:8px}.legend-row{display:grid;grid-template-columns:30px 1fr;gap:8px;align-items:center;min-height:20px}.legend-line{display:inline-block;width:28px;height:0;border-top:3px solid #777;border-radius:999px}.legend-swatch{display:inline-block;width:26px;height:14px;border-radius:5px;background:#f5f5f5}.border-swatch{border:2px solid rgba(80,80,80,.8);box-shadow:inset 0 0 0 1px rgba(255,255,255,.9)}.river-major{border-top:4px solid rgba(100,170,210,.95);box-shadow:0 3px 0 rgba(120,180,215,.55)}.rail-line{border-top:3px solid rgba(70,70,70,.95)}.road-line{border-top:3px solid rgba(252,210,90,.95)}.express-line{border-top:5px solid rgba(245,140,60,.95)}.old-road-line{border-top:2px dashed rgba(150,120,90,.75)}.legend-icon{display:inline-block;width:15px;height:15px;justify-self:center;border:2px solid #fff;box-shadow:0 0 0 1px rgb(0 0 0 / .28)}.city-icon{border-radius:50%;background:rgba(230,100,100,.95);width:17px;height:17px}.port-icon{width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:15px solid rgba(70,130,200,.95);border-top:0;box-shadow:none;background:transparent}.castle-icon{background:rgba(180,70,70,.96);border-radius:2px}.station-icon{background:#fff;border-color:rgba(60,60,60,.9);border-radius:2px}.industry-icon{background:rgba(110,170,140,.96);border-radius:2px}.newtown-icon{width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:15px solid rgba(180,210,240,.95);border-top:0;box-shadow:none;background:transparent} +@media (max-width:1100px){.layout{grid-template-columns:1fr}} +.legend-grid{display:flex;flex-direction:column;gap:8px;margin-top:12px} +.legend-row{display:grid;grid-template-columns:32px 1fr;gap:8px;align-items:center;min-height:22px} +/* Layered GIS style CSS equivalents */ +.legend-line{display:inline-block;width:28px;height:4px;border-radius:2px;} +.express-line{background:#6eb982; border:1px solid #508c64;} +.road-line{background:#f5e182; border:1px solid #beaf8c;} -.legend-swatch{width:18px;height:14px;border-radius:4px;border:1px solid rgb(0 0 0 / 0.18);display:inline-block}.cbd-swatch{background:rgb(224 202 190)}.satellite-icon{background:rgba(215,95,145,0.95);border-radius:999px;border:2px solid #fff} +/* Fishbone Railway Style */ +.rail-line{background:#6e6e6e; height:1.5px; position:relative; border:none; margin-top:2px; border-radius:0} +.rail-line::after{content:"";position:absolute;top:-2.5px;left:0;right:0;height:7px;background:repeating-linear-gradient(90deg, transparent, transparent 5px, #6e6e6e 5px, #6e6e6e 6px);} -.legend-line.harbor-line::before{background:#6b86a0;height:3px;top:7px} +.river-major{background:#a0cdf0; border:none; height:3px;} +.old-road-line{background:#fff; border:1px solid #dcdcdc; height:3px; border-top:none;} -.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:190px;max-width:270px;background:rgba(255,255,255,.96);border:1px solid rgb(0 0 0 / .16);border-radius:10px;box-shadow:0 8px 24px rgb(0 0 0 / .16);padding:8px 10px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(2px);transition:opacity .08s ease,transform .08s ease}.map-tooltip.visible{opacity:1;transform:translateY(0)} +.legend-swatch{display:inline-block;width:24px;height:14px;border-radius:4px;background:#f5f5f5} +.border-swatch{border:2px dashed rgba(110,90,110,1);box-shadow:inset 0 0 0 1px rgba(255,255,255,1), 0 0 0 1px rgba(255,255,255,1)} +.cbd-swatch{background:#f0dccd; border:1px solid rgba(0,0,0,0.1)} + +.legend-icon{display:inline-block;width:14px;height:14px;justify-self:center;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 1px rgba(0,0,0,0.15)} +.city-icon{background:#f06e6e;} +.satellite-icon{background:#d75f91;} +.station-icon{background:#fff;border-color:#444;} +.industry-icon{background:#8caaa0; border-radius:3px;} +.castle-icon{background:#b44646; border-radius:3px;} + +.port-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #4682c8;border-top:0;box-shadow:none;background:transparent;border-radius:0} +.newtown-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #b4d2f0;border-top:0;box-shadow:none;background:transparent;border-radius:0} +.legend-line.harbor-line{background:transparent; border-top:2px solid #5f7896; height:0} + +.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.98);border:1px solid rgba(0,0,0,0.1);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.1);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500} +.map-tooltip.visible{opacity:1;transform:translateY(0)} \ No newline at end of file diff --git a/test.js b/test.js index 147505c..2d02c79 100644 --- a/test.js +++ b/test.js @@ -284,6 +284,97 @@ function terrainCoreMetrics(map) { }; } +function requiredTransportNodes(map) { + const nodes = []; + const seen = new Set(); + function add(p, reason) { + if (!p) return; + const key = `${p.x},${p.y}`; + if (seen.has(key)) return; + seen.add(key); + nodes.push({ ...p, requiredTransportReason: reason }); + } + add(map.prefecturalCapital, "capital"); + for (const gate of map.externalGateways || []) add(gate, "externalGateway"); + for (const city of map.modernCities || []) if ((city.population || 0) >= 120000 || city.isPrefecturalCapital) add(city, "majorCity"); + for (const port of map.ports || []) if (port.portClass === "major") add(port, "majorPort"); + return nodes; +} + +function transportConnectivityMetrics(map) { + const paths = [ + ...map.railways, + ...map.branchRailways, + ...map.externalRailways, + ...map.nationalRoads, + ...(map.ringRoads || []), + ...map.expressways, + ...map.externalRoads, + ...map.externalExpressways, + ]; + const pathCells = new Set(); + for (const path of paths) for (const [x, y] of path) pathCells.add(`${x},${y}`); + const nodes = requiredTransportNodes(map); + function nearestCell(node) { + let best = null; + let bestD = Infinity; + for (const key of pathCells) { + const [x, y] = key.split(",").map(Number); + const d = Math.hypot(node.x - x, node.y - y); + if (d < bestD) { + bestD = d; + best = key; + } + } + return { key: best, distance: bestD }; + } + const seen = new Set(); + const components = []; + for (const key of pathCells) { + if (seen.has(key)) continue; + const queue = [key]; + const component = new Set([key]); + seen.add(key); + for (let q = 0; q < queue.length; q++) { + const [x, y] = queue[q].split(",").map(Number); + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { + const nk = `${x + dx},${y + dy}`; + if (!pathCells.has(nk) || seen.has(nk)) continue; + seen.add(nk); + component.add(nk); + queue.push(nk); + } + } + components.push(component); + } + const mapped = nodes.map((node) => ({ + node, + nearest: nearestCell(node), + components: components + .map((component, componentIndex) => ({ + componentIndex, + near: [...component].some((key) => { + const [x, y] = key.split(",").map(Number); + return Math.hypot(node.x - x, node.y - y) <= 7; + }), + })) + .filter((item) => item.near) + .map((item) => item.componentIndex), + })); + const reachable = mapped.filter((item) => item.components.length > 0); + let largestRequiredComponent = 0; + for (let componentIndex = 0; componentIndex < components.length; componentIndex++) { + largestRequiredComponent = Math.max(largestRequiredComponent, reachable.filter((item) => item.components.includes(componentIndex)).length); + } + return { + requiredCount: nodes.length, + reachableCount: reachable.length, + largestRequiredComponent, + isolatedExternalGateways: mapped.filter((item) => item.node.requiredTransportReason === "externalGateway" && item.components.length === 0).length, + isolatedMajorCities: mapped.filter((item) => item.node.requiredTransportReason === "majorCity" && item.components.length === 0).length, + }; +} + try { const map = generateMap(12345); const other = generateMap(54321); @@ -401,6 +492,7 @@ try { const satelliteMetrics = satelliteMunicipalityMetrics(map); const regionalMetrics = regionalComponentMetrics(map); const terrainMetrics = terrainCoreMetrics(map); + const transportMetrics = transportConnectivityMetrics(map); assert(NAME_KANJI_POOLS && Array.isArray(NAME_KANJI_POOLS.modifiers), "NAME_KANJI_POOLS exists"); assert(NAME_TEMPLATES && NAME_TEMPLATES.modifierTerrain?.slots?.length === 2, "NAME_TEMPLATES exists"); @@ -430,6 +522,11 @@ try { assert(map.depositionalLowland.length === size && map.alluvialFanField.length === size && map.deltaField.length === size, "depositional debug fields match map size"); assert(map.naturalBarrierScore.length === size, "natural barrier score field matches map size"); assert(map.terrainTemplate && Number.isFinite(map.terrainTemplate.deposition) && Number.isFinite(map.terrainTemplate.erosion), "terrain template parameters are exposed"); + assert(map.terrainDebug && Number.isFinite(map.terrainDebug.primarySpineStrength), "terrain debug metrics exist"); + assert(map.terrainDebug.primarySpineStrength > 0.08, "primary mountain spine has visible strength"); + assert(map.terrainDebug.largeInlandLakeCount <= 1, "large inland lakes are rare"); + assert(map.terrainDebug.smallIslandCount <= 24, "small island/coast speckles stay limited"); + assert(map.terrainDebug.depositionLowlandArea > 0, "depositional lowland area is tracked"); assert(["east-west", "north-south", "diagonal"].includes(map.terrainTemplate.coastAxis) && map.terrainTemplate.coastSides?.length === 2, "paired coast template parameters are exposed"); assert(map.settlementCluster.length === size, "settlement cluster field matches map size"); assert(Array.isArray(map.bridges) && Array.isArray(map.tunnels) && Array.isArray(map.harborWorks), "legacy bridge/tunnel arrays and harbor arrays exist"); @@ -439,6 +536,8 @@ try { assert(map.regionalDebug.regionalBorderCountBefore > 0 && map.regionalDebug.regionalBorderCountAfter > 0, "regional border counts are tracked"); assert(map.regionalDebug.regionalNaturalBarrierAverageAfter >= map.regionalDebug.regionalNaturalBarrierAverageBefore - 0.08, "regional border natural-barrier affinity does not degrade meaningfully"); assert(map.regionalDebug.regionalVoronoiLikeRateAfter <= map.regionalDebug.regionalVoronoiLikeRateBefore + 0.22, "regional weak Voronoi-like border rate stays bounded"); + assert(map.regionalDebug.compartmentCount > 0 && map.regionalDebug.changedAfterCompartmentAssignment > 0, "regional compartment assignment debug is available"); + assert(Number.isFinite(map.regionalDebug.borderNaturalBarrierAverage) && Number.isFinite(map.regionalDebug.voronoiLikeRate), "regional natural-border aliases are exposed"); assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents <= 5, "regional prefecture regions remain connected enough for display"); assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist"); assert(Array.isArray(map.icAccessRoads), "IC access road array exists"); @@ -495,8 +594,12 @@ try { assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified"); assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes"); assert(map.externalGateways.length > 0, "external gateways exist"); + assert(map.transportDebug && map.transportDebug.requiredNodeCount > 0, "transport required-node debug exists"); + assert(transportMetrics.requiredCount > 0 && transportMetrics.reachableCount === transportMetrics.requiredCount, "required transport nodes touch the modern network"); + assert(transportMetrics.largestRequiredComponent === transportMetrics.requiredCount, "required transport nodes are in one connected modern component"); + assert(transportMetrics.isolatedExternalGateways === 0 && transportMetrics.isolatedMajorCities === 0, "external gateways and major cities are not isolated"); assert(map.minorRoads.length > 0, "minor roads exist"); - assert(map.adminCenters.length >= 12, "municipality count is sufficiently large"); + assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large"); assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count"); assert(adminMetrics.centerValidRatio >= 0.95, "municipality centers remain on valid assigned land cells"); assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments"); @@ -544,7 +647,7 @@ try { const adminNameDuplicateRatio = map.adminCenters.length ? 1 - new Set(map.adminCenters.map((item) => item.name)).size / map.adminCenters.length : 0; assert(adminNameDuplicateRatio < 0.22, "duplicate municipal names stay low"); assert(map.adminDebug.naturalCompartmentCount > adminMetrics.municipalityCount, "natural compartments are finer than municipalities"); - assert(map.adminDebug.targetMunicipalityCount >= 18 && map.adminDebug.actualMunicipalityCount >= 16, "municipality target and actual counts are dense enough"); + assert(map.adminDebug.targetMunicipalityCount >= 20 && map.adminDebug.targetMunicipalityCount <= 50 && map.adminDebug.actualMunicipalityCount >= 18, "municipality target and actual counts are dense enough"); assert(map.adminDebug.changedAfterCompartmentAssignment > 0, "municipal compartment assignment is active"); assert(Array.isArray(map.adminDebug.compartmentBorders) && map.adminDebug.compartmentBorders.length > map.adminBorders.length, "natural compartment borders are available for debug rendering"); assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings"); @@ -553,6 +656,9 @@ try { assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented"); assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low"); assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools"); + assert(map.nameDebug.oneKanjiAppendFallbackUsed === 0, "one-kanji append fallback is never used"); + assert((map.nameDebug.derivedNameCount || 0) <= Math.max(6, Math.ceil(map.adminCenters.length * 0.18)), "derived names do not dominate municipality names"); + assert((map.nameDebug.maxDerivedPerBase || 0) <= 2, "derived names per base stay small"); assert(map.nameDebug.emptyPools.length === Object.values(NAME_KANJI_POOLS).filter((pool) => pool.length === 0).length, "nameDebug empty pools match configured pools"); assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists"); assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists"); @@ -581,6 +687,8 @@ try { assert(JSON.stringify([...againA.prefectureRegionId]) === JSON.stringify([...againB.prefectureRegionId]), "regional prefecture ids are deterministic for the same seed"); assert(JSON.stringify(againA.adminDebug) === JSON.stringify(againB.adminDebug), "admin debug metrics are deterministic for the same seed"); assert(JSON.stringify(againA.regionalDebug) === JSON.stringify(againB.regionalDebug), "regional debug metrics are deterministic for the same seed"); + assert(JSON.stringify(againA.transportDebug) === JSON.stringify(againB.transportDebug), "transport debug metrics are deterministic for the same seed"); + assert(JSON.stringify(transportConnectivityMetrics(againA)) === JSON.stringify(transportConnectivityMetrics(againB)), "transport connectivity metrics are deterministic for the same seed"); assert(JSON.stringify([...againA.elevation]) === JSON.stringify([...againB.elevation]), "elevation is deterministic for the same seed"); assert(JSON.stringify([...againA.ridgeField]) === JSON.stringify([...againB.ridgeField]), "ridge field is deterministic for the same seed"); assert(JSON.stringify([...againA.river]) === JSON.stringify([...againB.river]), "river field is deterministic for the same seed"); @@ -599,12 +707,21 @@ try { assert(metrics.ridgeVariance > 0.003 && metrics.ridgeSinuosity > 0.010, `seed ${seedValue}: ridges have varied jagged structure`); assert(metrics.depositionSum > 0.1 && metrics.depositionTargetMean >= metrics.depositionOtherMean * 0.75, `seed ${seedValue}: deposition is active in plausible lowlands`); assert(metrics.riverValleyMean > metrics.nonRiverValleyMean, `seed ${seedValue}: rivers follow valley fields`); + assert(seeded.terrainDebug?.primarySpineStrength > 0.08, `seed ${seedValue}: primary spine is strong enough`); + assert((seeded.terrainDebug?.largeInlandLakeCount || 0) <= 1, `seed ${seedValue}: large inland lakes are rare`); + assert((seeded.terrainDebug?.smallIslandCount || 0) <= 24, `seed ${seedValue}: small island speckles are limited`); assert(seeded.villages.length > 0 && seeded.markets.length > 0 && seeded.modernCities.length > 0, `seed ${seedValue}: settlements are generated`); assert(seeded.premodernRoads.length > 0 && seeded.railways.length > 0, `seed ${seedValue}: roads and railways are generated`); + const seededTransport = transportConnectivityMetrics(seeded); + assert(seeded.transportDebug?.requiredNodeCount === seededTransport.requiredCount, `seed ${seedValue}: required transport node count is exposed`); + assert(seededTransport.reachableCount === seededTransport.requiredCount && seededTransport.largestRequiredComponent === seededTransport.requiredCount, `seed ${seedValue}: required transport nodes are connected`); + assert(seededTransport.isolatedExternalGateways === 0 && seededTransport.isolatedMajorCities === 0, `seed ${seedValue}: no gateway or major city is isolated`); assert(seeded.adminId.length === size && seeded.adminBorders.length > 0 && seeded.regionalPrefectureBorders.length > 0, `seed ${seedValue}: admin and regional borders exist`); assert(seeded.adminCenters.every((item) => item.name && Array.from(String(item.name)).length >= 2), `seed ${seedValue}: every admin center has a valid name`); assert(seeded.entitiesForNames.some((item) => item.kind === "Municipal Center"), `seed ${seedValue}: admin labels are included in label candidates`); assert(seeded.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), `seed ${seedValue}: no dangling one-kanji admin suffix fallback`); + assert(seeded.nameDebug?.oneKanjiAppendFallbackUsed === 0, `seed ${seedValue}: one-kanji append fallback stays unused`); + assert((seeded.nameDebug?.derivedNameCount || 0) <= Math.max(6, Math.ceil(seeded.adminCenters.length * 0.18)), `seed ${seedValue}: derived names are bounded`); } const byDeposition = capitalNameMaps .map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio })) @@ -650,7 +767,7 @@ try { assert(seededRegional.maxComponents <= 5, `seed ${seed}: regional regions remain connected enough`); assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`); assert(seeded.adminDebug.naturalCompartmentCount > metrics.municipalityCount, `seed ${seed}: compartments are finer than municipalities`); - assert(seeded.adminDebug.targetMunicipalityCount >= 18 && seeded.adminDebug.actualMunicipalityCount >= 16, `seed ${seed}: municipality count is dense enough`); + assert(seeded.adminDebug.targetMunicipalityCount >= 20 && seeded.adminDebug.actualMunicipalityCount >= 18, `seed ${seed}: municipality count is dense enough`); assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0, `seed ${seed}: compartment assignment is not a no-op`); assert(Array.isArray(seeded.adminDebug.compartmentBorders) && seeded.adminDebug.compartmentBorders.length > seeded.adminBorders.length, `seed ${seed}: compartment border debug exists`); assert(seeded.regionalDebug?.changedAfterCompartmentAssignment > 0, `seed ${seed}: regional compartment assignment changes cells`); @@ -658,7 +775,7 @@ try { assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0 || seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal compartment or terrain passes change cells`); assert(seededSatellites.largeTooSmall.length === 0, `seed ${seed}: large satellites are not tiny independent municipalities`); assert(seededSatellites.independent.length < 3 || seededSatellites.small.length / seededSatellites.independent.length <= 0.35, `seed ${seed}: tiny satellite municipalities remain uncommon`); - assert(metrics.municipalityCount >= 8, `seed ${seed}: municipality count remains reasonable`); + assert(metrics.municipalityCount >= 18, `seed ${seed}: municipality count remains reasonable`); assert(metrics.centerValidRatio >= 0.90, `seed ${seed}: municipality centers remain valid`); assert(metrics.maxComponents <= 5, `seed ${seed}: topology repair limits disconnected fragments`); assert(metrics.avgTarget > 0.14, `seed ${seed}: borders retain terrain-boundary affinity`);