1378 lines
58 KiB
JavaScript
1378 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 });
|
|
const patchMode = options?.patchMode === true || options?.generationContext?.hasBoundaryWorld === true;
|
|
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);
|
|
let targetCap = cap;
|
|
if (patchMode) {
|
|
// Patch candidates should not regularly introduce a new top-center-scale
|
|
// metropolis. Existing cities in the world are preserved by mapPatch; this
|
|
// only affects newly generated candidate cities before they are merged.
|
|
const patchCap = city.isPrefecturalCapital ? 820000 : city.isRegionalCapital ? 680000 : 540000;
|
|
targetCap = Math.min(targetCap, patchCap);
|
|
}
|
|
if (targetCap < INF && (city.population || 0) > targetCap) {
|
|
city.population = Math.round(targetCap / 1000) * 1000;
|
|
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, patchMode ? 14 : 16);
|
|
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, patchMode ? 4.8 : 5.2);
|
|
city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, patchMode ? 1.86 : 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);
|
|
}
|