This commit is contained in:
33333-33333 2026-05-22 02:11:18 +09:00
commit da9d8ef904
11 changed files with 612 additions and 344 deletions

View file

@ -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 = []) {

View file

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

View file

@ -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,
};
}

View file

@ -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,
},
};
}

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

125
test.js
View file

@ -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`);