map/mapOutput.js
2026-05-29 15:49:09 +09:00

1369 lines
58 KiB
JavaScript

import { createNameDebug } from "./names.js";
import { CELL_SIZE, INF, MAP_H, MAP_W, MinHeap, clamp, indexOf, inside, rand, xyOf } from "./mapUtils.js";
import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
import { reconcileMunicipalMetadata } from "./mapMunicipalCoherence.js";
import { routeQualityAcceptable } from "./mapTransport.js";
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
function stripMunicipalSuffix(name) {
return String(name || "").replace(/[市町村区]$/u, "").trim();
}
function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) {
const i = inside(center?.x || 0, center?.y || 0) ? indexOf(center.x, center.y) : 0;
const density = fields.populationDensity?.[i] || 0;
const land = fields.landuse?.[i] ?? 0;
const urban = density > 0.36 || [2, 3, 4, 7, 8].includes(land) || center?.protectedSatellite;
const rural = (fields.elevation?.[i] || 0) > 0.58 || (fields.slope?.[i] || 0) > 0.40 || (fields.ridgeField?.[i] || 0) > 0.46;
if (urban) return "市";
if (rural && rand(seed + ordinal * 17, 9021) < 0.58) return "村";
return "町";
}
function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
let value = String(root || center?.name || "").trim();
if (!value) value = `自治${ordinal + 1}`;
value = value.replace(/[市町村区駅港城跡宿]$/gu, "");
const fallback = String(center?.generatedMunicipalityName || "里").replace(/[市町村区駅港城跡宿]$/gu, "");
if (!value) value = fallback || "里";
return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`;
}
function centerMunicipalityId(center, fallback = -1) {
for (const key of ["adminId", "municipalityId", "adminNumericId"]) {
const value = center?.[key];
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
}
return fallback;
}
function assignMunicipalityPopulations(adminCenters, adminId, fields, settlementFeatures = []) {
if (!adminCenters?.length || !adminId) return;
const centerById = new Map();
let maxId = -1;
for (const center of adminCenters) {
const id = centerMunicipalityId(center);
if (id >= 0 && !centerById.has(id)) {
centerById.set(id, center);
maxId = Math.max(maxId, id);
}
}
for (let i = 0; i < adminId.length; i++) if (adminId[i] >= 0) maxId = Math.max(maxId, adminId[i]);
const totals = new Float64Array(maxId + 1);
const settlementTotals = new Float64Array(maxId + 1);
const landCells = new Uint32Array(maxId + 1);
const inhabitedCells = new Uint32Array(maxId + 1);
for (let i = 0; i < adminId.length; i++) {
const id = adminId[i];
if (id < 0 || fields.sea?.[i]) continue;
landCells[id]++;
const density = fields.populationDensity?.[i] || 0;
const lu = fields.landuse?.[i] ?? 0;
const plain = fields.plain?.[i] || 0;
const agri = fields.agriculture?.[i] || 0;
const builtWeight = lu === 3 ? 520 : lu === 2 ? 360 : lu === 4 || lu === 7 || lu === 8 ? 260 : lu === 5 || lu === 6 ? 160 : lu === 1 ? 82 : 22;
const ruralFloor = lu === 1 ? 10 + agri * 18 + plain * 10 : 0;
if (density > 0.006 || ruralFloor > 0 || lu > 0) inhabitedCells[id]++;
totals[id] += density * builtWeight + ruralFloor;
}
// Population-bearing generated settlements are canonical entities, so add
// their explicit populations exactly once to the municipality containing the
// point. Some towns are promoted to cities later, so the same coordinate can
// appear in both `markets` and `modernCities`; keep only the strongest record
// per coordinate to avoid double counting.
const uniqueSettlementByCell = new Map();
for (const feature of settlementFeatures || []) {
if (!feature || !Number.isFinite(feature.population) || feature.population <= 0) continue;
if (!inside(feature.x, feature.y)) continue;
const i = indexOf(feature.x, feature.y);
if (fields.sea?.[i]) continue;
const key = `${Math.round(feature.x)}:${Math.round(feature.y)}`;
const current = uniqueSettlementByCell.get(key);
const priority = (feature.isPrefecturalCapital ? 4_000_000 : 0) + (feature.isRegionalCapital ? 1_000_000 : 0) + (feature.population || 0);
if (!current || priority > current.priority) uniqueSettlementByCell.set(key, { feature, priority, i });
}
let skippedDuplicateSettlementPopulation = 0;
for (const { feature, i } of uniqueSettlementByCell.values()) {
const id = adminId[i];
if (id < 0 || id >= settlementTotals.length) continue;
settlementTotals[id] += feature.population;
}
for (const feature of settlementFeatures || []) {
if (!feature || !Number.isFinite(feature.population) || feature.population <= 0 || !inside(feature.x, feature.y)) continue;
const key = `${Math.round(feature.x)}:${Math.round(feature.y)}`;
const kept = uniqueSettlementByCell.get(key)?.feature;
if (kept && kept !== feature) skippedDuplicateSettlementPopulation += feature.population || 0;
}
for (const [id, center] of centerById) {
const raw = (totals[id] || 0) + (settlementTotals[id] || 0);
const minimumResidentPopulation = landCells[id] > 0
? Math.round(Math.max(100, Math.min(3800, 80 + landCells[id] * 9 + inhabitedCells[id] * 16)) / 100) * 100
: 0;
const adjustedRaw = Math.max(raw, minimumResidentPopulation);
const rounded = adjustedRaw >= 10000 ? Math.round(adjustedRaw / 1000) * 1000 : Math.max(minimumResidentPopulation, Math.round(adjustedRaw / 100) * 100);
const safePopulation = Math.max(landCells[id] > 0 ? 100 : 0, rounded);
center.municipalityPopulation = safePopulation;
// Some consumers still read the generic `population` field from municipal
// centers. Mirror the municipality total there so no municipality is shown
// as 0人 merely because it is not a canonical city/market entity.
center.population = Math.max(center.population || 0, safePopulation);
center.municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
center.municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
}
}
function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, fields, seed, minPopulation = 200000, focusedPrefectureMask = null) {
if (!prefectureRegionId) return 0;
const prefIds = new Set();
for (let i = 0; i < prefectureRegionId.length; i++) {
const id = prefectureRegionId[i];
if (!sea[i] && id >= 0) prefIds.add(id);
}
const prefProfiles = new Map();
for (let i = 0; i < prefectureRegionId.length; i++) {
const prefId = prefectureRegionId[i];
if (sea[i] || prefId < 0) continue;
const profile = prefProfiles.get(prefId) || { landCells: 0, habitableCells: 0, lowlandCells: 0, densitySum: 0 };
profile.landCells++;
const slopeV = fields.slope?.[i] || 0;
const ridgeV = fields.ridgeField?.[i] || 0;
const elevV = fields.elevation?.[i] || 0;
const lowland = ((fields.plain?.[i] || 0) > 0.24 || (fields.basinField?.[i] || 0) > 0.26 || (fields.coastalLowland?.[i] || 0) > 0.22) && slopeV < 0.38 && ridgeV < 0.55;
if (slopeV < 0.42 && ridgeV < 0.58 && elevV < 0.74) profile.habitableCells++;
if (lowland) profile.lowlandCells++;
profile.densitySum += fields.populationDensity?.[i] || 0;
prefProfiles.set(prefId, profile);
}
function capitalFloorForPref(prefId) {
const p = prefProfiles.get(prefId) || { landCells: 0, habitableCells: 0, lowlandCells: 0, densitySum: 0 };
const lowlandRatio = p.landCells ? p.lowlandCells / p.landCells : 0;
const densityBoost = clamp((p.densitySum / Math.max(1, p.landCells) - 0.16) / 0.42);
let base = 45000;
if (p.lowlandCells > 900 || (p.lowlandCells > 650 && lowlandRatio > 0.28)) base = minPopulation * 0.90;
else if (p.lowlandCells > 520) base = 160000;
else if (p.lowlandCells > 260) base = 110000;
else if (p.lowlandCells > 120 || p.habitableCells > 420) base = 70000;
const adjusted = base + densityBoost * 50000;
return Math.round(clamp(adjusted, 42000, minPopulation + 45000) / 1000) * 1000;
}
let promoted = 0;
function prefAt(p) {
if (!p || !inside(p.x, p.y)) return -1;
return prefectureRegionId[indexOf(p.x, p.y)] ?? -1;
}
const focusedPrefCounts = new Map();
if (focusedPrefectureMask) {
for (let i = 0; i < focusedPrefectureMask.length; i++) {
if (!focusedPrefectureMask[i] || sea[i]) continue;
const prefId = prefectureRegionId[i] ?? -1;
if (prefId >= 0) focusedPrefCounts.set(prefId, (focusedPrefCounts.get(prefId) || 0) + 1);
}
}
const focusedPrefId = focusedPrefCounts.size
? [...focusedPrefCounts.entries()].sort((a, b) => b[1] - a[1])[0][0]
: 0;
for (const city of modernCities || []) {
if (city.isPrefecturalCapital) {
city.isPrefecturalCapital = false;
city.rank = city.isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City";
city.kind = city.rank;
}
}
for (const prefId of [...prefIds].sort((a, b) => a - b)) {
const cities = (modernCities || []).filter((p) => prefAt(p) === prefId);
let target = cities.slice().sort((a, b) =>
((b.isRegionalCapital ? 800000 : 0) + (b.population || 0)) - ((a.isRegionalCapital ? 800000 : 0) + (a.population || 0))
)[0];
if (!target) {
const market = (markets || []).filter((p) => prefAt(p) === prefId).sort((a, b) => (b.population || 0) - (a.population || 0))[0];
if (market) {
target = {
...market,
kind: "Regional Capital",
rank: "Regional Capital",
promotedFromMarketTown: true,
labelPriorityBase: 900,
};
modernCities.push(target);
}
}
if (!target) {
const center = (adminCenters || []).filter((p) => prefAt(p) === prefId).sort((a, b) => (b.municipalityPopulation || 0) - (a.municipalityPopulation || 0))[0];
if (center) {
target = {
...center,
kind: "Regional Capital",
rank: "Regional Capital",
promotedFromMunicipalCenter: true,
labelPriorityBase: 880,
};
modernCities.push(target);
}
}
if (!target) continue;
const regionalFloor = capitalFloorForPref(prefId);
const candidateCapacity = Number.isFinite(target.capacity) ? Math.max(42000, target.capacity * 1.18) : Infinity;
const randomizedFloor = Math.round((regionalFloor + rand(seed + 52000, prefId * 37 + 11) * Math.max(12000, regionalFloor * 0.28)) / 1000) * 1000;
const promotedPopulation = Math.max(42000, Math.round(Math.min(randomizedFloor, candidateCapacity) / 1000) * 1000);
if ((target.population || 0) < promotedPopulation) {
target.population = promotedPopulation;
promoted++;
}
target.isRegionalCapital = true;
target.isPrefecturalCapital = prefId === focusedPrefId;
target.rank = target.isPrefecturalCapital ? "Prefectural Capital" : "Regional Capital";
target.kind = target.rank;
target.labelPriorityBase = Math.max(target.labelPriorityBase || 0, 1150);
target.urbanRadius = clamp(6.2 + Math.sqrt(target.population) / 80, 9, 30);
target.coreRadius = clamp(2.0 + Math.sqrt(target.population) / 390, 2.4, 6.4);
target.sprawlRadius = clamp(target.urbanRadius * 1.34, target.urbanRadius + 2, 38);
target.urbanWeight = clamp(1.05 + Math.log10(Math.max(10000, target.population)) * 0.30, 1.25, 2.55);
}
return promoted;
}
function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames, nameDebug, options = {}) {
if (!prefectureRegionId) return [];
const { modernCities = [], adminCenters = [] } = options;
const byId = new Map();
for (let i = 0; i < prefectureRegionId.length; i++) {
const id = prefectureRegionId[i];
if (sea[i] || id < 0) continue;
if (!byId.has(id)) byId.set(id, { id, area: 0, sx: 0, sy: 0, cells: [] });
const row = byId.get(id);
const x = i % MAP_W;
const y = Math.floor(i / MAP_W);
row.area++;
row.sx += x;
row.sy += y;
row.cells.push(i);
}
const regions = [];
const capitalNameByPref = new Map();
for (const city of modernCities || []) {
if (!city || !inside(city.x, city.y) || !city.name) continue;
const prefId = prefectureRegionId[indexOf(city.x, city.y)];
if (prefId < 0) continue;
const current = capitalNameByPref.get(prefId);
const tier = city.isPrefecturalCapital || city.rank === "Prefectural Capital" || city.kind === "Prefectural Capital" ? 3 : city.isRegionalCapital || city.rank === "Regional Capital" || city.kind === "Regional Capital" ? 2 : 1;
const score = tier * 50_000_000 + (city.population || 0);
if (!current || score > current.score) capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(city.name), score, x: city.x, y: city.y, population: city.population || 0, source: tier === 3 ? "prefecture-capital" : "city" });
}
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y) || !center.name) continue;
const prefId = prefectureRegionId[indexOf(center.x, center.y)];
if (prefId < 0 || capitalNameByPref.has(prefId)) continue;
capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(center.name), score: center.municipalityPopulation || 0, x: center.x, y: center.y, population: center.municipalityPopulation || 0, source: "admin" });
}
for (const row of [...byId.values()].sort((a, b) => a.id - b.id)) {
const cx = row.sx / Math.max(1, row.area);
const cy = row.sy / Math.max(1, row.area);
let bestI = row.cells[0];
let bestScore = -INF;
for (const i of row.cells) {
const x = i % MAP_W;
const y = Math.floor(i / MAP_W);
const score =
(fields.populationDensity?.[i] || 0) * 1.6 +
(fields.plain?.[i] || 0) * 0.28 +
(fields.basinField?.[i] || 0) * 0.22 +
(fields.coastalLowland?.[i] || 0) * 0.16 -
(fields.slope?.[i] || 0) * 0.36 -
(fields.ridgeField?.[i] || 0) * 0.30 -
Math.hypot(x - cx, y - cy) * 0.08;
if (score > bestScore) { bestScore = score; bestI = i; }
}
let x = bestI % MAP_W;
let y = Math.floor(bestI / MAP_W);
const capitalInfo = capitalNameByPref.get(row.id);
if (capitalInfo && inside(capitalInfo.x, capitalInfo.y)) {
const targetRing = clamp(5.5 + Math.sqrt(row.area) / 52, 6, 15);
let labelI = bestI;
let labelScore = -INF;
for (const i of row.cells) {
const lx = i % MAP_W;
const ly = Math.floor(i / MAP_W);
const d = Math.hypot(lx - capitalInfo.x, ly - capitalInfo.y);
if (d < 2 || d > Math.max(19, targetRing * 2.2)) continue;
const lu = fields.landuse?.[i] ?? 0;
const builtPenalty = lu === 3 ? 1.2 : lu === 2 || lu === 4 || lu === 7 || lu === 8 ? 0.70 : 0;
const score =
-Math.abs(d - targetRing) * 0.38 -
(fields.populationDensity?.[i] || 0) * 1.1 -
builtPenalty +
(fields.plain?.[i] || 0) * 0.22 +
(fields.basinField?.[i] || 0) * 0.14 +
(fields.coastalLowland?.[i] || 0) * 0.10 -
(fields.slope?.[i] || 0) * 0.30 -
(fields.ridgeField?.[i] || 0) * 0.24;
if (score > labelScore) { labelScore = score; labelI = i; }
}
x = labelI % MAP_W;
y = Math.floor(labelI / MAP_W);
}
regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 950 + Math.sqrt(row.area), forceLabel: true, capitalX: capitalInfo?.x, capitalY: capitalInfo?.y });
}
const named = attachIdsAndNames(regions, "prefecture", seed + 41000, null, fields, usedNames, nameDebug)
.map((region, index) => ({ ...region, featureId: region.id, id: regions[index].id, labelName: region.name }));
const usedPrefNames = new Set();
for (const region of named) {
const capital = capitalNameByPref.get(region.id)?.name;
let candidate = capital && Array.from(capital).length >= 2 ? capital : stripMunicipalSuffix(region.name);
if (!candidate) candidate = stripMunicipalSuffix(region.name) || `県域${region.id + 1}`;
if (usedPrefNames.has(candidate)) candidate = `${candidate}${region.id + 1}`;
candidate = `${String(candidate).replace(/[都道府県]$/u, "")}`;
region.name = candidate;
region.labelName = candidate;
region.prefectureCapitalDerivedName = Boolean(capital);
usedPrefNames.add(candidate);
}
return named;
}
export function finishMapOutput({
seed,
options,
terrain,
features,
admin,
geography = null,
}) {
const {
terrainTemplate,
seaLevel,
elevation,
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
agriculture,
ridgeField,
valleyField,
visibleRavineField,
surfaceTextureField,
basinField,
coastalLowland,
flowAccum,
watershedId,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
prefectureMask,
prefectureBorder,
terrainDebug,
} = terrain;
const {
cityPopulationCap,
stationInfluence,
roadInfluence,
railInfluence2,
settlementCluster,
villages: inputVillages,
geographicUrbanAnchors: inputGeographicUrbanAnchors = [],
ports: inputPorts,
crossings: inputCrossings,
passes: inputPasses,
markets: inputMarkets,
castles: inputCastles,
castleTowns: inputCastleTowns,
premodernRoads,
minorRoads,
modernCities: inputModernCities,
populationDensity,
railways,
branchRailways,
ringRailways,
externalRailways,
stations: inputStations,
industrialZones: inputIndustrialZones,
nationalRoads,
ringRoads,
expressways,
ringExpressways,
icAccessRoads,
externalRoads,
externalExpressways,
interchanges: inputInterchanges,
logisticsParks: inputLogisticsParks,
satelliteCities: inputSatelliteCities,
newTowns: inputNewTowns,
landuse,
externalGateways: inputExternalGateways,
transportDebug,
} = features;
const {
adminCentersRaw,
adminId,
adminBorders,
adminDebug,
prefectureRegionId,
municipalityToPrefectureId,
regionalPrefectureBorders: adminRegionalPrefectureBorders,
regionalDebug,
naturalCompartmentId,
naturalCompartments,
} = admin;
let villages = inputVillages;
let geographicUrbanAnchors = inputGeographicUrbanAnchors;
let ports = inputPorts;
let crossings = inputCrossings;
let passes = inputPasses;
let markets = inputMarkets;
let castles = inputCastles;
let castleTowns = inputCastleTowns;
let modernCities = inputModernCities;
let stations = inputStations;
let industrialZones = inputIndustrialZones;
let interchanges = inputInterchanges;
let logisticsParks = inputLogisticsParks;
let satelliteCities = inputSatelliteCities;
let newTowns = inputNewTowns;
let externalGateways = inputExternalGateways;
const outputProgress = (step) => options?.onProgress?.({ status: "output-step", key: "output", label: `Output: ${step}`, step });
outputProgress("final packaging");
// Use all generated prefecture regions for human-geography masks, not only
// the focused prefecture. Population density itself is already generated in
// the human stage and is not rebuilt here.
const humanRegionMask = new Uint8Array(MAP_W * MAP_H);
for (let i = 0; i < humanRegionMask.length; i++) {
humanRegionMask[i] = !sea[i] && ((prefectureRegionId?.[i] ?? -1) >= 0 || prefectureMask[i]) ? 1 : 0;
}
// Population density is generated directly in the human stage. Do not rebuild
// it here from land-use or municipal offices; output should only package and
// name features.
for (const city of modernCities) {
const cap = cityPopulationCap(city);
if (cap < INF && (city.population || 0) > cap) {
city.population = Math.round(cap / 1000) * 1000;
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2);
city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0);
}
}
let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" }));
const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity };
const usedNames = new Set();
const nameDebug = createNameDebug();
outputProgress("feature naming");
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug);
geographicUrbanAnchors = attachIdsAndNames(tagInsidePrefecture(geographicUrbanAnchors, prefectureMask), "geoAnchor", seed, null, nameFields, usedNames, nameDebug);
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug);
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug);
passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug);
markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames, nameDebug);
castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames, nameDebug);
castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames, nameDebug);
modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames, nameDebug);
stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames, nameDebug);
industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames, nameDebug);
interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames, nameDebug);
logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames, nameDebug);
satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames, nameDebug);
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
outputProgress("municipality naming");
const municipalCoherence = reconcileMunicipalMetadata({
adminId,
prefectureRegionId,
sea,
adminCenters: adminCentersRaw,
municipalityToPrefectureId,
fields: nameFields,
width: MAP_W,
height: MAP_H,
seed,
});
if (adminDebug) adminDebug.municipalCoherence = municipalCoherence.debug;
const coherentMunicipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId || municipalityToPrefectureId;
const adminCenters = attachIdsAndNames(tagInsidePrefecture(municipalCoherence.adminCenters, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
const representativeFeatures = [
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
...ports.map((p) => ({ ...p, representativeWeight: p.portClass === "major" ? 3.8 : 2.4 })),
...villages.map((p) => ({ ...p, representativeWeight: 1.6 })),
].filter((p) => p.name && (p.insidePrefecture || humanRegionMask[indexOf(p.x, p.y)]));
for (const center of adminCenters) {
const centerAdmin = adminId?.[indexOf(center.x, center.y)];
let best = null;
let bestScore = -INF;
for (const sameAdminOnly of [true, false]) {
for (const feature of representativeFeatures) {
const featureAdmin = adminId?.[indexOf(feature.x, feature.y)];
if (sameAdminOnly && centerAdmin >= 0 && featureAdmin >= 0 && featureAdmin !== centerAdmin) continue;
const d = Math.hypot(center.x - feature.x, center.y - feature.y);
const score = feature.representativeWeight - d * 0.11 - (sameAdminOnly ? 0 : 1.2);
if (score > bestScore) {
bestScore = score;
best = feature;
}
}
if (best) break;
}
center.generatedMunicipalityName = center.generatedMunicipalityName || center.name;
if (best) {
center.representativeFeatureId = best.id;
center.representativeFeatureName = best.name;
center.canonicalSettlementId = best.id;
center.canonicalSettlementName = best.name;
center.municipalityRootName = best.name;
const bestAdmin = adminId?.[indexOf(best.x, best.y)];
const canSnapOffice = inside(best.x, best.y) && !sea[indexOf(best.x, best.y)] && (
centerAdmin == null || centerAdmin < 0 || bestAdmin == null || bestAdmin < 0 || bestAdmin === centerAdmin
);
if (canSnapOffice) {
center.generatedOfficeX = center.generatedOfficeX ?? center.x;
center.generatedOfficeY = center.generatedOfficeY ?? center.y;
center.x = best.x;
center.y = best.y;
center.officeSnappedToSettlement = true;
}
} else {
center.municipalityRootName = center.generatedMunicipalityName;
}
}
const usedAdminNames = new Set();
for (const [index, center] of adminCenters.entries()) {
const municipalId = centerMunicipalityId(center, index);
center.adminId = municipalId;
center.adminNumericId = municipalId;
center.municipalityId = municipalId;
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, municipalId);
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, municipalId);
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
candidate = generated;
}
if (usedAdminNames.has(candidate)) {
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, municipalId);
const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${municipalId + 1}`).replace(/[市町村区駅港城跡宿]$/gu, "");
const chars = Array.from(rootSource || "里郷");
const alternates = [
chars.slice(0, 2).join(""),
chars.slice(-2).join(""),
`${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + municipalId) % 8]}`,
`${["東", "西", "南", "北", "上", "下", "中"][(seed + municipalId) % 7]}${chars[0] || "里"}`,
].filter((v) => Array.from(v).length >= 2);
for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) {
const root = attempt < alternates.length ? alternates[attempt] : `${(municipalId + attempt) % 10}`;
candidate = `${root}${suffix}`;
}
}
center.name = candidate;
center.labelName = candidate;
center.municipalityName = candidate;
usedAdminNames.add(center.name);
}
const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000, prefectureMask);
assignMunicipalityPopulations(adminCenters, adminId, nameFields, [
...modernCities,
...markets,
...villages,
...satelliteCities,
...newTowns,
]);
function addMunicipalCenterLocalAccess() {
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = { repairedSegments: [], unservedSettlements: [] })) : { repairedSegments: [], unservedSettlements: [] };
debugLayers.repairedSegments ||= [];
debugLayers.unservedSettlements ||= [];
const influenceCache = new Map();
const signature = (paths) => `${paths?.length || 0}:${(paths || []).reduce((sum, path) => sum + (path?.length || 0), 0)}`;
const cachedInfluence = (paths, radius, label) => {
const key = `${label}:${radius}:${signature(paths)}`;
let grid = influenceCache.get(key);
if (!grid) {
grid = influenceFromPaths(paths, radius);
influenceCache.set(key, grid);
}
return grid;
};
const accessInfluence = cachedInfluence([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 8, "municipal-access");
const localPenalty = cachedInfluence(minorRoads, 4, "municipal-minor");
const perPrefectureQuota = new Map();
const candidates = adminCenters
.filter((p) => {
if (!inside(p.x, p.y) || sea[indexOf(p.x, p.y)]) return false;
const i = indexOf(p.x, p.y);
const meaningful = (p.municipalityPopulation || 0) >= 4500 || (populationDensity?.[i] || 0) > 0.08 || (p.representativeFeatureName && accessInfluence[i] < 0.22);
return meaningful && accessInfluence[i] < 0.42;
})
.sort((a, b) => {
const ai = indexOf(a.x, a.y);
const bi = indexOf(b.x, b.y);
const as = (a.municipalityPopulation || 0) + Math.max(0, 0.42 - accessInfluence[ai]) * 145000;
const bs = (b.municipalityPopulation || 0) + Math.max(0, 0.42 - accessInfluence[bi]) * 145000;
return bs - as;
})
.filter((p) => {
const prefId = coherentMunicipalityToPrefectureId?.[p.municipalityId] ?? -1;
const used = perPrefectureQuota.get(prefId) || 0;
if (used >= 18) return false;
perPrefectureQuota.set(prefId, used + 1);
return true;
})
.slice(0, 95);
function addLocalPenalty(path, radius = 4, strength = 0.20) {
for (const [x, y] of path || []) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const i = indexOf(nx, ny);
if (sea[i]) continue;
localPenalty[i] = Math.max(localPenalty[i], strength * (1 - d / radius));
}
}
}
}
function routeAccess(start) {
const startIndex = indexOf(start.x, start.y);
if (sea[startIndex]) return [];
const score = new Float32Array(MAP_W * MAP_H);
const cameFrom = new Int32Array(MAP_W * MAP_H);
const closed = new Uint8Array(MAP_W * MAP_H);
score.fill(INF);
cameFrom.fill(-1);
const heap = new MinHeap();
score[startIndex] = 0;
heap.push({ i: startIndex, f: 0 });
const maxExpanded = Math.min(MAP_W * MAP_H, 24000);
let goal = -1;
let expanded = 0;
while (heap.length && expanded++ < maxExpanded) {
const current = heap.pop();
if (!current || closed[current.i]) continue;
closed[current.i] = 1;
const [cx, cy] = xyOf(current.i);
const straightDistance = Math.hypot(cx - start.x, cy - start.y);
if (straightDistance > 3 && (accessInfluence[current.i] > 0.11 || localPenalty[current.i] > 0.06)) {
goal = current.i;
break;
}
if (straightDistance > 150) continue;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = cx + dx;
const ny = cy + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (closed[ni] || sea[ni]) continue;
const step = Math.hypot(dx, dy);
const highPenalty = Math.max(0, elevation[ni] - 0.64);
const targetAttraction = Math.max(accessInfluence[ni] * 2.4, localPenalty[ni] * 1.25, roadInfluence[ni] * 1.8, railInfluence2[ni] * 1.2);
const terrainCost =
1.0 +
slope[ni] * 1.20 +
ridgeField[ni] * 0.52 +
highPenalty * 1.55 -
plain[ni] * 0.38 -
valleyField[ni] * 0.42 -
coastalLowland[ni] * 0.18 +
Math.max(0, localPenalty[ni] - 0.18) * 0.38 -
targetAttraction;
const nd = score[current.i] + Math.max(0.16, terrainCost) * step;
if (nd < score[ni]) {
score[ni] = nd;
cameFrom[ni] = current.i;
heap.push({ i: ni, f: nd });
}
}
}
}
if (goal < 0) return [];
const path = [];
for (let p = goal; p >= 0; p = cameFrom[p]) {
path.push(xyOf(p));
if (p === startIndex) break;
}
path.reverse();
if (path.length < 4 || path.length > 112) return [];
return routeQualityAcceptable(path, { sea, elevation, slope, potential: populationDensity, penalty: localPenalty, highElevationThreshold: 0.78, steepThreshold: 0.52 }, {
minLength: 4,
maxLength: 112,
maxCompactness: 4.0,
maxHighElevationShare: 0.22,
maxSteepShare: 0.50,
}) ? path : [];
}
for (const center of candidates) {
const path = routeAccess(center);
debugLayers.unservedSettlements.push({ x: center.x, y: center.y, kind: "Municipal Center", mode: "municipal-access", repaired: path.length >= 4 });
if (path.length < 4) continue;
minorRoads.push(path);
addLocalPenalty(path);
debugLayers.repairedSegments.push({ mode: "municipal-local", path, from: "municipal-center", to: "network" });
}
}
addMunicipalCenterLocalAccess();
function pruneIsolatedFinalRoadComponents() {
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = {})) : {};
const groups = [
["minor", minorRoads],
["national", nationalRoads],
["external", externalRoads],
["expressway", expressways],
["externalExpressway", externalExpressways],
];
function rasterize(path, fn) {
for (let k = 0; k < (path?.length || 0); k++) {
const [x0, y0] = path[k];
const [x1, y1] = path[Math.min(k + 1, path.length - 1)];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(x0 + (x1 - x0) * t);
const y = Math.round(y0 + (y1 - y0) * t);
fn(x, y);
}
}
}
function splitPathOnSea(path) {
const chunks = [];
let chunk = [];
function pushPoint(x, y) {
if (!inside(x, y) || sea[indexOf(x, y)]) {
if (chunk.length >= 2) chunks.push(chunk);
chunk = [];
return;
}
if (!chunk.length || chunk[chunk.length - 1][0] !== x || chunk[chunk.length - 1][1] !== y) chunk.push([x, y]);
}
for (let k = 0; k < (path?.length || 0); k++) {
const [x0, y0] = path[k];
const [x1, y1] = path[Math.min(k + 1, path.length - 1)];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
pushPoint(Math.round(x0 + (x1 - x0) * t), Math.round(y0 + (y1 - y0) * t));
}
}
if (chunk.length >= 2) chunks.push(chunk);
return chunks;
}
// Keep sea-crossing cells in the stored path so the renderer can draw
// explicit bridge overlays. Connectivity analysis below ignores sea cells
// when rasterizing components, so preserving them here does not make islands
// falsely connected by ordinary land roads.
function pathNearAdminCenter(path, radius = 0.75) {
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y)) continue;
for (const [x, y] of path || []) {
if (Math.hypot(center.x - x, center.y - y) <= radius) return true;
}
}
return false;
}
function pathNearRequiredExpresswayCity(path) {
if (!path || path.length < 2) return false;
for (const city of modernCities || []) {
if (!city || (city.population || 0) < 100000 || !inside(city.x, city.y)) continue;
const inner = Math.max(7, (city.coreRadius || 4) + 4.5);
const outer = Math.max(inner + 7, (city.urbanRadius || 12) * 2.15);
let inBand = false;
let exits = false;
for (const [x, y] of path) {
const d = Math.hypot(city.x - x, city.y - y);
if (d >= inner && d <= outer) inBand = true;
if (d >= Math.max(22, (city.urbanRadius || 12) * 1.45)) exits = true;
if (inBand && exits) return true;
}
}
return false;
}
function components() {
const occ = new Uint8Array(MAP_W * MAP_H);
for (const [, paths] of groups) for (const path of paths || []) rasterize(path, (x, y) => {
if (inside(x, y) && !sea[indexOf(x, y)]) occ[indexOf(x, y)] = 1;
});
const seen = new Uint8Array(MAP_W * MAP_H);
const out = [];
for (let i = 0; i < occ.length; i++) {
if (!occ[i] || seen[i]) continue;
const queue = [i];
const cells = [];
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
const [x, y] = xyOf(cur);
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
if (!dx && !dy) continue;
if (dx * dx + dy * dy > 5) continue;
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!occ[ni] || seen[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
out.push({ cells, size: cells.length });
}
return out.sort((a, b) => b.size - a.size);
}
function buildLandComponentIds() {
const ids = new Int32Array(MAP_W * MAP_H);
ids.fill(-1);
let id = 0;
const q = [];
for (let i = 0; i < ids.length; i++) {
if (ids[i] >= 0 || sea[i]) continue;
ids[i] = id;
q.length = 0;
q.push(i);
for (let h = 0; h < q.length; h++) {
const cur = q[h];
const [x, y] = xyOf(cur);
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (sea[ni] || ids[ni] >= 0) continue;
ids[ni] = id;
q.push(ni);
}
}
id++;
}
return ids;
}
function majorityLandId(cells, landIds) {
const counts = new Map();
for (const i of cells || []) {
const id = landIds[i];
if (id < 0) continue;
counts.set(id, (counts.get(id) || 0) + 1);
}
let best = -1, bestN = 0;
for (const [id, n] of counts) if (n > bestN) { best = id; bestN = n; }
return best;
}
function componentNearAdminCenter(comp, radius = 3.2) {
if (!comp?.cells?.length) return false;
const mask = new Uint8Array(MAP_W * MAP_H);
for (const ci of comp.cells) mask[ci] = 1;
const r = Math.ceil(radius);
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y)) continue;
const cx = Math.round(center.x), cy = Math.round(center.y);
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy > radius * radius) continue;
const x = cx + dx, y = cy + dy;
if (inside(x, y) && mask[indexOf(x, y)]) return true;
}
}
return false;
}
function routeIsolatedComponentToMain(comp, mainMask, mainCentroid, landIds, landIdValue) {
if (!comp?.cells?.length || landIdValue < 0) return [];
const dist = new Float64Array(MAP_W * MAP_H);
dist.fill(INF);
const prev = new Int32Array(MAP_W * MAP_H);
prev.fill(-1);
const heap = new MinHeap();
let seeded = 0;
const stride = Math.max(1, Math.floor(comp.cells.length / 96));
for (let k = 0; k < comp.cells.length; k += stride) {
const i = comp.cells[k];
if (sea[i] || landIds[i] !== landIdValue) continue;
dist[i] = 0;
prev[i] = i;
const [x, y] = xyOf(i);
heap.push({ i, f: Math.hypot(x - mainCentroid.x, y - mainCentroid.y) * 0.22 });
seeded++;
}
if (!seeded) return [];
let goal = -1;
let expanded = 0;
const maxExpanded = 22000;
while (heap.length && expanded < maxExpanded) {
const current = heap.pop();
if (!current) break;
const cur = current.i;
expanded++;
if (mainMask[cur] && dist[cur] > 2) { goal = cur; break; }
const [x, y] = xyOf(cur);
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (sea[ni] || landIds[ni] !== landIdValue) continue;
const step = Math.hypot(dx, dy);
const terrainCost = 1.0 + (slope?.[ni] || 0) * 1.28 + (ridgeField?.[ni] || 0) * 0.66 + Math.max(0, (elevation?.[ni] || 0) - 0.66) * 1.75 - (valleyField?.[ni] || 0) * 0.42 - (plain?.[ni] || 0) * 0.18 - (coastalLowland?.[ni] || 0) * 0.08;
const nd = dist[cur] + step * Math.max(0.38, terrainCost);
if (nd >= dist[ni]) continue;
dist[ni] = nd;
prev[ni] = cur;
const h = Math.hypot(nx - mainCentroid.x, ny - mainCentroid.y) * 0.22;
heap.push({ i: ni, f: nd + h });
}
}
if (goal < 0) return [];
const path = [];
let cur = goal;
for (let guard = 0; guard < 240 && cur >= 0; guard++) {
const [x, y] = xyOf(cur);
path.push([x, y]);
if (prev[cur] === cur) break;
cur = prev[cur];
}
path.reverse();
if (path.length < 4 || path.length > 150) return [];
return routeQualityAcceptable(path, { sea, elevation, slope, potential: populationDensity, highElevationThreshold: 0.80, steepThreshold: 0.55 }, {
minLength: 4,
maxLength: 150,
maxCompactness: 5.4,
maxHighElevationShare: 0.42,
maxSteepShare: 0.66,
}) ? path : [];
}
function attemptConnectSameLandmassAdminRoadComponents(comps) {
const result = { attempted: 0, added: 0, skippedIsland: 0, failed: 0 };
if (!comps || comps.length <= 1) return result;
const landIds = buildLandComponentIds();
const mainLand = majorityLandId(comps[0].cells, landIds);
const mainMask = new Uint8Array(MAP_W * MAP_H);
let sx = 0, sy = 0, sn = 0;
for (const ci of comps[0].cells) {
const [cx, cy] = xyOf(ci);
sx += cx; sy += cy; sn++;
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
if (dx * dx + dy * dy > 5) continue;
const nx = cx + dx, ny = cy + dy;
if (inside(nx, ny)) mainMask[indexOf(nx, ny)] = 1;
}
}
const centroid = { x: sx / Math.max(1, sn), y: sy / Math.max(1, sn) };
for (const comp of comps.slice(1, 24)) {
if (!componentNearAdminCenter(comp)) continue;
const land = majorityLandId(comp.cells, landIds);
if (land !== mainLand) { result.skippedIsland++; continue; }
result.attempted++;
const path = routeIsolatedComponentToMain(comp, mainMask, centroid, landIds, land);
if (path.length >= 4) {
minorRoads.push(path);
result.added++;
} else {
result.failed++;
}
}
return result;
}
let comps = components();
const before = comps.length;
const mountainConnect = attemptConnectSameLandmassAdminRoadComponents(comps);
if (mountainConnect.added > 0) comps = components();
const pruned = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 };
for (let pass = 0; pass < 4 && comps.length > 1; pass++) {
const mainMask = new Uint8Array(MAP_W * MAP_H);
for (const ci of comps[0].cells) {
const [cx, cy] = xyOf(ci);
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
if (dx * dx + dy * dy > 5) continue;
const nx = cx + dx, ny = cy + dy;
if (inside(nx, ny)) mainMask[indexOf(nx, ny)] = 1;
}
}
function touchesMain(path) {
let hit = 0, n = 0;
rasterize(path, (x, y) => {
if (!inside(x, y) || sea[indexOf(x, y)]) return;
n++;
if (mainMask[indexOf(x, y)]) hit++;
});
return n > 0 && hit / n >= (pass === 0 ? 0.10 : 0.01);
}
for (const [key, paths] of groups) {
const kept = [];
for (const path of paths || []) {
if (touchesMain(path) || pathNearAdminCenter(path) || ((key === "expressway" || key === "externalExpressway") && pathNearRequiredExpresswayCity(path))) kept.push(path);
else pruned[key]++;
}
paths.length = 0;
paths.push(...kept);
}
comps = components();
}
debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned, mountainAdminConnections: mountainConnect };
}
pruneIsolatedFinalRoadComponents();
function ensureAdminCenterCellsAfterOutputPrune() {
let added = 0;
function roadTouches(center) {
for (const path of [...minorRoads, ...nationalRoads, ...externalRoads]) {
for (const [x, y] of path || []) if (Math.hypot(center.x - x, center.y - y) <= 0.65) return true;
}
return false;
}
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y) || sea[indexOf(center.x, center.y)]) continue;
if (roadTouches(center)) continue;
const x = Math.round(center.x);
const y = Math.round(center.y);
const horizontal = [[Math.max(0, x - 1), y], [x, y], [Math.min(MAP_W - 1, x + 1), y]]
.filter((pt, idx, arr) => idx === 0 || pt[0] !== arr[idx - 1][0] || pt[1] !== arr[idx - 1][1]);
const vertical = [[x, Math.max(0, y - 1)], [x, y], [x, Math.min(MAP_H - 1, y + 1)]]
.filter((pt, idx, arr) => idx === 0 || pt[0] !== arr[idx - 1][0] || pt[1] !== arr[idx - 1][1]);
minorRoads.push(horizontal.length >= 2 ? horizontal : vertical);
added++;
}
if (transportDebug) {
transportDebug.layers ||= {};
transportDebug.layers.adminCenterFinalStubs = added;
}
}
ensureAdminCenterCellsAfterOutputPrune();
function connectNearbyRoadEndpoints() {
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = {})) : {};
const ordinaryGroups = [
{ key: "minor", paths: minorRoads || [] },
{ key: "national", paths: nationalRoads || [] },
{ key: "external", paths: externalRoads || [] },
{ key: "ring", paths: ringRoads || [] },
];
const occ = new Uint8Array(MAP_W * MAP_H);
function rasterize(path, fn) {
for (let k = 1; k < (path?.length || 0); k++) {
const [x0, y0] = path[k - 1];
const [x1, y1] = path[k];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(x0 + (x1 - x0) * t);
const y = Math.round(y0 + (y1 - y0) * t);
if (inside(x, y) && !sea[indexOf(x, y)]) fn(x, y);
}
}
}
for (const group of ordinaryGroups) for (const path of group.paths || []) rasterize(path, (x, y) => { occ[indexOf(x, y)] = 1; });
const comp = new Int32Array(MAP_W * MAP_H);
comp.fill(-1);
let compId = 0;
const queue = [];
for (let i = 0; i < occ.length; i++) {
if (!occ[i] || comp[i] >= 0) continue;
comp[i] = compId;
queue.length = 0;
queue.push(i);
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const [x, y] = xyOf(cur);
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!occ[ni] || comp[ni] >= 0) continue;
comp[ni] = compId;
queue.push(ni);
}
}
compId++;
}
function endpointComponent(x, y) {
if (!inside(x, y) || sea[indexOf(x, y)]) return -1;
const here = comp[indexOf(x, y)];
if (here >= 0) return here;
for (let r = 1; r <= 2; r++) {
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny) || sea[indexOf(nx, ny)]) continue;
const id = comp[indexOf(nx, ny)];
if (id >= 0) return id;
}
}
return -1;
}
function directConnector(a, b) {
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
const path = [];
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!inside(x, y) || sea[indexOf(x, y)]) return [];
if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]);
}
return path.length >= 2 ? path : [];
}
const endpoints = [];
for (const group of ordinaryGroups) {
for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) {
const path = group.paths[pathIdx];
if (!path || path.length < 2) continue;
for (const end of [0, 1]) {
const raw = end === 0 ? path[0] : path[path.length - 1];
const x = Math.round(raw[0]), y = Math.round(raw[1]);
if (!inside(x, y) || sea[indexOf(x, y)]) continue;
const ci = indexOf(x, y);
const ruralBias = Math.max(0, 0.42 - (populationDensity?.[ci] || 0));
endpoints.push({ group: group.key, pathIdx, end, x, y, comp: endpointComponent(x, y), ruralBias });
}
}
}
const pairs = [];
for (let i = 0; i < endpoints.length; i++) {
const a = endpoints[i];
if (a.comp < 0) continue;
for (let j = i + 1; j < endpoints.length; j++) {
const b = endpoints[j];
if (b.comp < 0 || a.comp === b.comp) continue;
if (a.group === b.group && a.pathIdx === b.pathIdx) continue;
const d = Math.hypot(a.x - b.x, a.y - b.y);
const limit = (a.ruralBias + b.ruralBias) > 0.38 ? 6.5 : 4.4;
if (d < 1.1 || d > limit) continue;
const path = directConnector(a, b);
if (path.length < 2 || path.length > 9) continue;
pairs.push({ a, b, d, path, score: d - (a.ruralBias + b.ruralBias) * 1.25 + (a.group === "minor" && b.group === "minor" ? 0.25 : 0) });
}
}
pairs.sort((a, b) => a.score - b.score || a.d - b.d);
const used = new Set();
let added = 0;
for (const pair of pairs) {
if (added >= 180) break;
const ak = `${pair.a.group}:${pair.a.pathIdx}:${pair.a.end}`;
const bk = `${pair.b.group}:${pair.b.pathIdx}:${pair.b.end}`;
if (used.has(ak) || used.has(bk)) continue;
minorRoads.push(pair.path);
used.add(ak); used.add(bk);
added++;
}
debugLayers.nearbyRoadEndpointConnectorsAdded = added;
return added;
}
connectNearbyRoadEndpoints();
function renameInterchangesFromMunicipalities() {
if (!interchanges?.length || !adminCenters?.length || !adminId) return 0;
const centerByAdmin = new Map();
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y)) continue;
const id = adminId[indexOf(center.x, center.y)];
if (id >= 0 && !centerByAdmin.has(id)) centerByAdmin.set(id, center);
}
const allCenters = [...centerByAdmin.values()].filter((c) => c?.name);
const used = new Set();
const directionNames = ["北", "東", "南", "西", "中央", "上", "下", "新"];
let renamed = 0;
function cleanBase(name) {
return String(name || "").replace(/[ICインターチェンジ\s]+$/u, "").replace(/[市町村区]$/u, "");
}
for (const [idx, ic] of interchanges.entries()) {
if (!ic || !inside(ic.x, ic.y)) continue;
const cell = indexOf(ic.x, ic.y);
const admin = adminId[cell];
const primary = centerByAdmin.get(admin) || allCenters.slice().sort((a, b) => Math.hypot(a.x - ic.x, a.y - ic.y) - Math.hypot(b.x - ic.x, b.y - ic.y))[0];
const nearbyCenters = allCenters
.slice()
.sort((a, b) => Math.hypot(a.x - ic.x, a.y - ic.y) - Math.hypot(b.x - ic.x, b.y - ic.y));
const candidates = [];
if (primary?.name) candidates.push(`${cleanBase(primary.municipalityName || primary.name)}IC`);
for (const center of nearbyCenters.slice(0, 12)) {
const base = cleanBase(center.municipalityName || center.name);
if (base) candidates.push(`${base}IC`);
}
if (primary?.name) {
const base = cleanBase(primary.municipalityName || primary.name);
for (const dir of directionNames) candidates.push(`${base}${dir}IC`);
}
candidates.push(`自治${idx + 1}IC`);
let name = candidates.find((candidate) => candidate && !used.has(candidate));
if (!name) name = `自治${idx + 1}IC`;
ic.name = name;
ic.labelName = name;
ic.municipalityNameBased = true;
used.add(name);
renamed++;
}
if (transportDebug) {
transportDebug.layers ||= {};
transportDebug.layers.municipalityBasedInterchangeNames = renamed;
}
return renamed;
}
renameInterchangesFromMunicipalities();
nameDebug.maxDerivedPerBase = 0;
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters });
const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];
if (regionalDebug) {
regionalDebug.finalRegionalPrefectureBorderCount = regionalPrefectureBorders.length;
regionalDebug.regionalPrefectureBordersRebuiltFromFinalId = true;
regionalDebug.promotedPrefectureCapitals = promotedPrefectureCapitals;
}
outputProgress("final package");
const entitiesForNames = [
...modernCities,
...ports,
...markets,
...castles,
...stations,
...industrialZones,
...interchanges,
...logisticsParks,
...satelliteCities,
...newTowns,
...passes,
...crossings,
...adminCenters,
...externalGateways,
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
return applyOutputOptions({
width: MAP_W,
height: MAP_H,
originX: Number.isFinite(terrain?.originX) ? terrain.originX : (Number.isFinite(options?.originX) ? options.originX : 0),
originY: Number.isFinite(terrain?.originY) ? terrain.originY : (Number.isFinite(options?.originY) ? options.originY : 0),
generationContext: options?.generationContext || terrain?.generationContext || null,
cellSize: CELL_SIZE,
terrainTemplate,
seaLevel,
prefectureMask,
humanRegionMask,
prefectureBorder: regionalPrefectureBorders.length ? regionalPrefectureBorders : prefectureBorder,
prefectureRegionId,
municipalityToPrefectureId: coherentMunicipalityToPrefectureId,
prefectureRegions,
regionalDebug,
terrainDebug,
regionalPrefectureBorders,
geography,
geographyDebug: geography?.geographyDebug || null,
habitability: geography?.habitability || null,
accessibility: geography?.accessibility || null,
centrality: geography?.centrality || null,
naturalCentrality: geography?.naturalCentrality || null,
humanCentrality: geography?.humanCentrality || null,
transportAccessibility: geography?.transportAccessibility || null,
geographicBarrier: geography?.geographicBarrier || null,
geographicBarrierCost: geography?.geographicBarrierCost || geography?.barrierCost || null,
barrierCost: geography?.barrierCost || geography?.geographicBarrierCost || null,
corridorSuitability: geography?.corridorSuitability || null,
adminBoundaryPreference: geography?.adminBoundaryPreference || null,
boundaryAvoidance: geography?.boundaryAvoidance || null,
lowlandCapacity: geography?.lowlandCapacity || null,
valleyAccess: geography?.valleyAccess || null,
coastalAccess: geography?.coastalAccess || null,
geographicCompartmentProfiles: geography?.compartmentProfiles || [],
watershedProfiles: geography?.watershedProfiles || [],
elevation,
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
agriculture,
settlementCluster,
ridgeField,
valleyField,
visibleRavineField,
surfaceTextureField,
basinField,
coastalLowland,
flowAccum,
watershedId,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
naturalCompartmentId,
naturalCompartments,
villages,
geographicUrbanAnchors,
ports,
crossings,
passes,
markets,
castles,
castleTowns,
premodernRoads,
minorRoads,
modernCities,
prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || null,
totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0),
populationDensity,
railways,
branchRailways,
ringRailways,
externalRailways,
stations,
industrialZones,
nationalRoads,
ringRoads,
expressways,
ringExpressways,
icAccessRoads,
externalRoads,
externalExpressways,
interchanges,
logisticsParks,
satelliteCities,
newTowns,
landuse,
adminCenters,
adminId,
adminBorders,
adminDebug,
castleRuins,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
externalGateways,
transportDebug,
entitiesForNames,
nameDebug,
}, options);
}