diff --git a/app.js b/app.js
index e3ba1ac..772e569 100644
--- a/app.js
+++ b/app.js
@@ -20,6 +20,7 @@ const state = {
showFeatures: true,
showLabels: true,
map: null,
+ hoverEntities: [],
};
const canvas = document.getElementById("mapCanvas");
@@ -169,8 +170,8 @@ function renderStats(map) {
}
}
-function nearestEntity(map, x, y, maxDistance = 5) {
- const groups = [
+function buildHoverEntities(map) {
+ return [
...(map.modernCities || []),
...(map.ports || []),
...(map.stations || []),
@@ -183,9 +184,12 @@ function nearestEntity(map, x, y, maxDistance = 5) {
...(map.villages || []),
...(map.adminCenters || []),
];
+}
+
+function nearestEntity(items, x, y, maxDistance = 5) {
let best = null;
let bestD = maxDistance;
- for (const item of groups) {
+ for (const item of items) {
const d = Math.hypot(item.x - x, item.y - y);
if (d < bestD) { best = item; bestD = d; }
}
@@ -201,6 +205,11 @@ function adminName(map, adminId) {
return center?.name || (adminId >= 0 ? `Municipality ${adminId + 1}` : "-");
}
+function adminPopulation(map, adminId) {
+ const center = (map.adminCenters || [])[adminId];
+ return Number.isFinite(center?.municipalityPopulation) ? center.municipalityPopulation : null;
+}
+
function prefectureNameForCell(map, i) {
const id = map.prefectureRegionId?.[i] ?? -1;
const region = (map.prefectureRegions || []).find((p) => p.id === id);
@@ -217,13 +226,16 @@ function updateTooltip(event) {
return;
}
const i = y * state.map.width + x;
- const entity = nearestEntity(state.map, x, y);
+ const entity = nearestEntity(state.hoverEntities, x, y);
const elevation = state.map.elevation?.[i] ?? 0;
const density = state.map.populationDensity?.[i] ?? 0;
+ const hoveredAdminId = state.map.adminId?.[i] ?? -1;
+ const hoveredAdminPopulation = adminPopulation(state.map, hoveredAdminId);
const lines = [
`${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`}`,
`Prefecture: ${prefectureNameForCell(state.map, i)}`,
- `Admin: ${adminName(state.map, state.map.adminId?.[i] ?? -1)}`,
+ `Admin: ${adminName(state.map, hoveredAdminId)}`,
+ `Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`,
`Land: ${state.map.sea?.[i] ? "Sea" : landuseName(state.map.landuse?.[i])}`,
`Elevation: ${elevation.toFixed(3)} / Slope: ${(state.map.slope?.[i] ?? 0).toFixed(3)}`,
`River: ${(state.map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
@@ -241,7 +253,8 @@ function updateTooltip(event) {
tooltipEl.classList.add("visible");
}
-function renderModeButtons() { modeGrid.innerHTML = "";
+function renderModeButtons() {
+ modeGrid.innerHTML = "";
for (const [key, label] of modes) {
const button = document.createElement("button");
button.type = "button";
@@ -262,6 +275,7 @@ async function regenerate() {
await nextFrame();
try {
state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress });
+ state.hoverEntities = buildHoverEntities(state.map);
renderStats(state.map);
redraw();
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
diff --git a/index.html b/index.html
index f80d45e..7a0c4e4 100644
--- a/index.html
+++ b/index.html
@@ -13,10 +13,7 @@
diff --git a/mapAdminStage.js b/mapAdminStage.js
index fc031d8..284f792 100644
--- a/mapAdminStage.js
+++ b/mapAdminStage.js
@@ -105,9 +105,15 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
const id = adminId[i];
- if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, sx: 0, sy: 0 });
+ if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, sx: 0, sy: 0, touchesOutside: false });
const node = nodes.get(id);
const [x, y] = xyOf(i);
+ if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) node.touchesOutside = true;
+ for (const [ox, oy] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
+ if (!inside(ox, oy)) { node.touchesOutside = true; continue; }
+ const oi = indexOf(ox, oy);
+ if (!prefectureMask[oi] || sea[oi]) node.touchesOutside = true;
+ }
node.area++;
node.population += populationDensity?.[i] || 0;
node.sx += x;
@@ -141,7 +147,7 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
function choosePrefectureMunicipalitySeeds(nodes, seed) {
const active = [...nodes.values()].filter((node) => node.area > 0).sort((a, b) => b.area - a.area || a.id - b.id);
const totalArea = active.reduce((sum, node) => sum + node.area, 0);
- const targetCount = clamp(Math.round(totalArea / 2300), 5, 12);
+ const targetCount = clamp(Math.round(totalArea / 7200), 3, 6);
const seeds = [];
const first = active.sort((a, b) => (b.population * 1.8 + b.area * 0.08) - (a.population * 1.8 + a.area * 0.08) || a.id - b.id)[0];
if (first) seeds.push(first);
@@ -242,6 +248,189 @@ function repairPrefectureMunicipalityConnectivity(nodes, owner) {
return changed;
}
+function repairPrefectureMunicipalityEnclaves(nodes, owner, maxPasses = 6) {
+ let changed = 0;
+ for (let pass = 0; pass < maxPasses; pass++) {
+ let passChanged = 0;
+ const prefIds = [...new Set(owner.values())].filter((id) => id >= 0).sort((a, b) => a - b);
+ for (const prefId of prefIds) {
+ const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId);
+ const memberSet = new Set(members);
+ const seen = new Set();
+ for (const start of members) {
+ if (seen.has(start)) continue;
+ const queue = [start];
+ const comp = [];
+ seen.add(start);
+ let touchesOutside = false;
+ const boundaryPrefs = new Map();
+ for (let q = 0; q < queue.length; q++) {
+ const cur = queue[q];
+ comp.push(cur);
+ const node = nodes.get(cur);
+ if (node?.touchesOutside) touchesOutside = true;
+ for (const next of node?.adjacent.keys() || []) {
+ const nextOwner = owner.get(next);
+ if (nextOwner === prefId) {
+ if (!seen.has(next)) { seen.add(next); queue.push(next); }
+ } else if (nextOwner >= 0) {
+ boundaryPrefs.set(nextOwner, (boundaryPrefs.get(nextOwner) || 0) + 1);
+ }
+ }
+ }
+ if (touchesOutside || boundaryPrefs.size !== 1) continue;
+ const [targetPref] = boundaryPrefs.keys();
+ if (targetPref < 0 || targetPref === prefId) continue;
+ for (const id of comp) owner.set(id, targetPref);
+ passChanged += comp.length;
+ }
+ }
+ changed += passChanged;
+ if (!passChanged) break;
+ }
+ return changed;
+}
+
+function repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea, maxPasses = 5) {
+ let changed = 0;
+ const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
+ for (let pass = 0; pass < maxPasses; pass++) {
+ const prefId = new Int16Array(SIZE);
+ prefId.fill(-1);
+ for (let i = 0; i < SIZE; i++) {
+ if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
+ prefId[i] = owner.get(adminId[i]) ?? -1;
+ }
+ const seen = new Uint8Array(SIZE);
+ let passChanged = 0;
+ for (let i = 0; i < SIZE; i++) {
+ if (seen[i] || prefId[i] < 0) continue;
+ const id = prefId[i];
+ const queue = [i];
+ const comp = [];
+ seen[i] = 1;
+ let touchesOutside = false;
+ const boundaryCounts = new Map();
+ const adminCounts = new Map();
+ for (let q = 0; q < queue.length; q++) {
+ const cur = queue[q];
+ comp.push(cur);
+ const aid = adminId[cur];
+ if (aid >= 0) adminCounts.set(aid, (adminCounts.get(aid) || 0) + 1);
+ const [x, y] = xyOf(cur);
+ if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true;
+ for (const [dx, dy] of dirs) {
+ const nx = x + dx, ny = y + dy;
+ if (!inside(nx, ny)) { touchesOutside = true; continue; }
+ const ni = indexOf(nx, ny);
+ if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; }
+ const nid = prefId[ni];
+ if (nid === id) {
+ if (!seen[ni]) { seen[ni] = 1; queue.push(ni); }
+ } else if (nid >= 0) {
+ boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1);
+ }
+ }
+ }
+ if (touchesOutside || boundaryCounts.size !== 1) continue;
+ const [targetPref] = boundaryCounts.keys();
+ if (targetPref < 0 || targetPref === id) continue;
+ for (const aid of adminCounts.keys()) {
+ if (owner.get(aid) !== targetPref) { owner.set(aid, targetPref); passChanged++; }
+ }
+ }
+ changed += passChanged;
+ if (!passChanged) break;
+ }
+ return changed;
+}
+
+function repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, maxPasses = 5) {
+ let changed = 0;
+ const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
+ for (let pass = 0; pass < maxPasses; pass++) {
+ const seen = new Uint8Array(SIZE);
+ let passChanged = 0;
+ for (let i = 0; i < SIZE; i++) {
+ if (seen[i] || !prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
+ const id = adminId[i];
+ const queue = [i];
+ const comp = [];
+ seen[i] = 1;
+ let touchesOutside = false;
+ const boundaryCounts = new Map();
+ for (let q = 0; q < queue.length; q++) {
+ const cur = queue[q];
+ comp.push(cur);
+ const [x, y] = xyOf(cur);
+ if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true;
+ for (const [dx, dy] of dirs) {
+ const nx = x + dx, ny = y + dy;
+ if (!inside(nx, ny)) { touchesOutside = true; continue; }
+ const ni = indexOf(nx, ny);
+ if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; }
+ const nid = adminId[ni];
+ if (nid === id) {
+ if (!seen[ni]) { seen[ni] = 1; queue.push(ni); }
+ } else if (nid >= 0) {
+ boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1);
+ }
+ }
+ }
+ if (touchesOutside || boundaryCounts.size !== 1) continue;
+ const [targetId] = boundaryCounts.keys();
+ if (targetId < 0 || targetId === id) continue;
+ for (const ci of comp) adminId[ci] = targetId;
+ passChanged += comp.length;
+ }
+ changed += passChanged;
+ if (!passChanged) break;
+ }
+ return changed;
+}
+
+function lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 950) {
+ if (!landuse || !populationDensity) return 0;
+ const seen = new Uint8Array(SIZE);
+ const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
+ let changed = 0;
+ const isUrban = (i) => !sea[i] && prefectureMask[i] && (populationDensity[i] > 0.34 || [2, 3, 4, 7, 8].includes(landuse[i]));
+ for (let i = 0; i < SIZE; i++) {
+ if (seen[i] || !isUrban(i) || adminId[i] < 0) continue;
+ const queue = [i];
+ const comp = [];
+ seen[i] = 1;
+ const counts = new Map();
+ for (let q = 0; q < queue.length; q++) {
+ const cur = queue[q];
+ comp.push(cur);
+ const id = adminId[cur];
+ if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + (populationDensity[cur] || 0) * 2.0);
+ const [x, y] = xyOf(cur);
+ for (const [dx, dy] of dirs) {
+ const nx = x + dx, ny = y + dy;
+ if (!inside(nx, ny)) continue;
+ const ni = indexOf(nx, ny);
+ if (seen[ni] || !isUrban(ni)) continue;
+ seen[ni] = 1;
+ queue.push(ni);
+ }
+ }
+ if (comp.length < 8 || comp.length > maxCells || counts.size <= 1 || counts.size > 7) continue;
+ let best = -1, bestScore = -INF;
+ let total = 0;
+ for (const [id, score] of counts) {
+ total += score;
+ if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
+ }
+ if (best < 0 || bestScore / Math.max(1, total) < 0.38) continue;
+ for (const ci of comp) {
+ if (adminId[ci] !== best) { adminId[ci] = best; changed++; }
+ }
+ }
+ return changed;
+}
+
function mergeTinyMunicipalityPrefectures(nodes, owner) {
let changed = 0;
for (let pass = 0; pass < 6; pass++) {
@@ -277,6 +466,111 @@ function mergeTinyMunicipalityPrefectures(nodes, owner) {
return changed;
}
+
+function splitOversizedCompartmentMunicipalities(adminId, centers, compartments, prefectureMask, sea, fields = {}, seed = 0) {
+ if (!compartments?.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 };
+ const compOwner = new Int16Array(compartments.length);
+ compOwner.fill(-1);
+ for (const comp of compartments) {
+ if (!comp || !comp.cells?.length) continue;
+ const counts = new Map();
+ for (const i of comp.cells) {
+ if (!prefectureMask[i] || sea[i]) continue;
+ const id = adminId[i];
+ if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
+ }
+ let best = -1, bestCount = -1;
+ for (const [id, count] of counts) if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; }
+ compOwner[comp.id] = best;
+ }
+ const byOwner = new Map();
+ for (const comp of compartments) {
+ if (!comp || !comp.cells?.length) continue;
+ const owner = compOwner[comp.id];
+ if (owner < 0) continue;
+ if (!byOwner.has(owner)) byOwner.set(owner, []);
+ byOwner.get(owner).push(comp);
+ }
+ const areas = [...byOwner.values()].map((list) => list.reduce((sum, comp) => sum + comp.area, 0)).sort((a, b) => a - b);
+ if (!areas.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 };
+ const median = areas[Math.floor(areas.length / 2)] || 1;
+ const total = areas.reduce((sum, value) => sum + value, 0);
+ const maxArea = Math.max(360, Math.min(total * 0.11, Math.max(median * 2.65, total / Math.max(12, Math.round(total / 520)))));
+ let changedCells = 0;
+ let splitMunicipalities = 0;
+ let addedCenters = 0;
+ const elevation = fields.elevation;
+ const slope = fields.slope;
+ const ridgeField = fields.ridgeField;
+ const plain = fields.plain;
+ const agriculture = fields.agriculture;
+ const basinField = fields.basinField;
+ const coastalLowland = fields.coastalLowland;
+ const populationDensity = fields.populationDensity;
+ for (const [owner, list] of [...byOwner.entries()].sort((a, b) => a[0] - b[0])) {
+ const area = list.reduce((sum, comp) => sum + comp.area, 0);
+ if (area <= maxArea || list.length < 4) continue;
+ const desiredParts = clamp(Math.ceil(area / Math.max(1, maxArea)), 2, 9);
+ const splitCount = desiredParts - 1;
+ if (splitCount <= 0) continue;
+ const candidates = list.map((comp) => {
+ let sx = 0, sy = 0, n = 0, score = 0, bestI = -1, bestScore = -INF;
+ for (const i of comp.cells) {
+ if (!prefectureMask[i] || sea[i]) continue;
+ const [x, y] = xyOf(i);
+ sx += x; sy += y; n++;
+ const cellScore =
+ (plain?.[i] || 0) * 0.22 +
+ (agriculture?.[i] || 0) * 0.24 +
+ (basinField?.[i] || 0) * 0.14 +
+ (coastalLowland?.[i] || 0) * 0.10 +
+ (populationDensity?.[i] || 0) * 0.24 -
+ (slope?.[i] || 0) * 0.20 -
+ (ridgeField?.[i] || 0) * 0.18 -
+ Math.max(0, (elevation?.[i] || 0) - 0.62) * 0.38;
+ score += cellScore;
+ if (cellScore > bestScore) { bestScore = cellScore; bestI = i; }
+ }
+ const [x, y] = bestI >= 0 ? xyOf(bestI) : [Math.round(sx / Math.max(1, n)), Math.round(sy / Math.max(1, n))];
+ return { comp, x, y, score: score / Math.max(1, n) + Math.sqrt(comp.area) * 0.025 + hash2(seed + owner, comp.id) * 0.03 };
+ }).sort((a, b) => b.score - a.score || a.comp.id - b.comp.id);
+ const newSeeds = [];
+ for (const cand of candidates) {
+ if (newSeeds.length >= splitCount) break;
+ if (newSeeds.every((s) => Math.hypot(s.x - cand.x, s.y - cand.y) >= 9)) newSeeds.push(cand);
+ }
+ if (!newSeeds.length) continue;
+ const seedIds = newSeeds.map((cand) => {
+ const id = centers.length;
+ centers.push({ x: cand.x, y: cand.y, score: cand.score, invisibleLowlandAdminSeed: true, seedKind: "oversizedMunicipalitySplit", splitFromAdminId: owner });
+ addedCenters++;
+ return id;
+ });
+ const oldCenter = centers[owner] || candidates[0] || { x: list[0].x || 0, y: list[0].y || 0, score: 0 };
+ const owners = [{ id: owner, x: oldCenter.x, y: oldCenter.y, score: oldCenter.score || 0 }, ...newSeeds.map((cand, k) => ({ id: seedIds[k], x: cand.x, y: cand.y, score: cand.score }))];
+ const targetArea = area / Math.max(1, owners.length);
+ const claimedArea = new Map(owners.map((entry) => [entry.id, 0]));
+ for (const cand of candidates) {
+ let bestSeed = owner;
+ let bestCost = INF;
+ for (const entry of owners) {
+ const d = Math.hypot(cand.x - entry.x, cand.y - entry.y);
+ const pressure = Math.max(0, ((claimedArea.get(entry.id) || 0) + cand.comp.area - targetArea * 1.25) / Math.max(1, targetArea));
+ const cost = d + pressure * 18 - cand.score * 2.5 + hash2(entry.id, cand.comp.id) * 0.05;
+ if (cost < bestCost) { bestCost = cost; bestSeed = entry.id; }
+ }
+ claimedArea.set(bestSeed, (claimedArea.get(bestSeed) || 0) + cand.comp.area);
+ if (bestSeed === owner) continue;
+ for (const i of cand.comp.cells) {
+ if (!prefectureMask[i] || sea[i]) continue;
+ if (adminId[i] !== bestSeed) { adminId[i] = bestSeed; changedCells++; }
+ }
+ }
+ splitMunicipalities++;
+ }
+ return { changedCells, splitMunicipalities, addedCenters, maxArea };
+}
+
function extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefectureId, prefectureMask, sea) {
const segments = [];
for (let y = 0; y < MAP_H; y++) {
@@ -306,7 +600,11 @@ function generatePrefecturesFromMunicipalities(context, adminResult) {
const seeds = choosePrefectureMunicipalitySeeds(graph.nodes, seed + 91001);
const owner = assignMunicipalitiesToPrefectures(graph.nodes, seeds);
const changedForTinyMerge = mergeTinyMunicipalityPrefectures(graph.nodes, owner);
- const changedForConnectivity = repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
+ let changedForConnectivity = repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
+ let changedForEnclaveRepair = repairPrefectureMunicipalityEnclaves(graph.nodes, owner);
+ changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
+ changedForEnclaveRepair += repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea);
+ changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]);
const municipalityToPrefectureId = new Int16Array(maxAdminId + 1);
municipalityToPrefectureId.fill(-1);
@@ -338,6 +636,7 @@ function generatePrefecturesFromMunicipalities(context, adminResult) {
prefectureMunicipalitySeedCount: seeds.length,
prefectureTinyMergeChangedMunicipalities: changedForTinyMerge,
prefectureConnectivityRepairChangedMunicipalities: changedForConnectivity,
+ prefectureEnclaveRepairChangedMunicipalities: changedForEnclaveRepair,
finalRegionalMaxAreaShare: totalArea ? Math.max(0, ...areaByPref.values()) / totalArea : 0,
finalRegionalTinyPrefectureCount: [...areaByPref.values()].filter((area) => area < 520).length,
finalRegionalPrefectureBorderCount: regionalPrefectureBorders.length,
@@ -601,13 +900,13 @@ function municipalityCountBoundsForRegion(landCells, meta = {}) {
// Use the same administrative density curve for the highlighted prefecture
// and neighboring prefectures. Only clipped slivers get a low floor.
let min = 1;
- if (landCells >= 420) min = 2;
- if (landCells >= 850) min = 3;
- if (landCells >= 1500) min = 5;
- if (landCells >= 2500) min = 8;
- if (landCells >= 3800) min = 12;
- if (landCells >= 5600) min = 16;
- const max = clamp(Math.round(landCells / 230 + 4), Math.max(min, 3), 46);
+ if (landCells >= 360) min = 2;
+ if (landCells >= 750) min = 4;
+ if (landCells >= 1400) min = 7;
+ if (landCells >= 2400) min = 11;
+ if (landCells >= 3800) min = 16;
+ if (landCells >= 5600) min = 22;
+ const max = clamp(Math.round(landCells / 160 + 6), Math.max(min, 4), 72);
return { min, max };
}
@@ -635,11 +934,11 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope,
}
}
const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length;
- const settlementWeight = modernCities.length * 1.6 + markets.length * 1.0 + ports.length * 0.8 + independentSatellites * 0.7 + villages.length * 0.25;
+ const settlementWeight = modernCities.length * 1.7 + markets.length * 1.15 + ports.length * 0.8 + independentSatellites * 0.7 + villages.length * 0.32;
const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520);
const mountainRatio = landCells ? mountainCells / landCells : 0;
const lowlandBonus = Math.min(7, lowlandCells / 430);
- const rawTarget = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2);
+ const rawTarget = Math.round(habitableCells / 175 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.65 + lowlandBonus * 1.15 - mountainRatio * 1.8);
const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta);
return clamp(rawTarget, min, max);
}
@@ -1076,7 +1375,12 @@ function generateAdminLayoutForMask({
});
const adminId = compartmentAssignment.adminId;
if (naturalCompartmentId && naturalCompartments) {
+ const oversizedCompartmentSplit = splitOversizedCompartmentMunicipalities(adminId, adminCentersRaw, compartmentAssignment.compartments, prefectureMask, sea, {
+ elevation, slope, ridgeField: boundaryRidgeField, plain, agriculture, basinField, coastalLowland, populationDensity,
+ }, seed + 21900);
const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
+ const changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980);
+ const changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6);
const compacted = compactWholeCompartmentMunicipalities(adminId, adminCentersRaw, prefectureMask, sea, { populationDensity, plain, slope });
const adminBorders = extractAdminBorderSegments(compacted.adminId, prefectureMask);
const actualMunicipalityCount = compacted.activeMunicipalityCount;
@@ -1089,11 +1393,21 @@ function generateAdminLayoutForMask({
finalMunicipalityCount: actualMunicipalityCount,
candidateSeedCount: adminCentersRaw.length,
municipalOfficePointCount: compacted.adminCentersRaw.length,
+ seedCellRevivalCount: 0,
+ survivedSeedCount: compacted.activeMunicipalityCount,
+ pendingSeedCount: 0,
+ absorbedSeedCount: Math.max(0, adminCentersRaw.length - compacted.activeMunicipalityCount),
targetNaturalCompartmentCount: targetCompartmentCount,
naturalCompartmentCount: compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length,
compartmentCount: compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length,
changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0,
changedAfterFinalCompartmentOwnership,
+ changedAfterUrbanUnification,
+ changedAfterAdminEnclaveRepair,
+ changedAfterOversizedCompartmentSplit: oversizedCompartmentSplit.changedCells,
+ oversizedCompartmentMunicipalitiesSplit: oversizedCompartmentSplit.splitMunicipalities,
+ oversizedCompartmentSplitAddedCenters: oversizedCompartmentSplit.addedCenters,
+ oversizedCompartmentSplitMaxArea: oversizedCompartmentSplit.maxArea || 0,
finalTinyMunicipalityCount: [...municipalityAreaById(compacted.adminId, prefectureMask, sea).values()].filter((area) => area > 0 && area < 8).length,
compartmentBorders: compartmentAssignment.debug?.compartmentBorders || [],
borderNaturalBarrierAverage: compartmentAssignment.debug?.finalBorderNaturalBarrierAverage || 0,
@@ -1318,6 +1632,9 @@ function generateAdminLayoutForMask({
adminDebug.changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 220);
adminDebug.changedAfterPostCompartmentExclaveRemoval = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
+ adminDebug.changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980);
+ adminDebug.changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6);
+ adminDebug.changedAfterFinalCompartmentOwnership += enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const satelliteAreas = [];
diff --git a/mapFeatures.js b/mapFeatures.js
index 705a692..0855ec6 100644
--- a/mapFeatures.js
+++ b/mapFeatures.js
@@ -132,23 +132,24 @@ export function generateMapFeatures(seed, terrain) {
spine * 0.12
);
const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16;
- settlementCluster[i] = clamp((developable[i] * 0.52 + valleySettlement[i] * 0.28 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.22) * clusterNoise);
+ settlementCluster[i] = clamp((developable[i] * 0.50 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.34 + plain[i] * 0.12) * clusterNoise);
ruralSuitability[i] = clamp(
- agriculture[i] * 0.42 +
- developable[i] * 0.28 +
+ agriculture[i] * 0.54 +
+ developable[i] * 0.30 +
valleySettlement[i] * 0.24 +
coastalSettlement[i] * 0.15 +
- settlementCluster[i] * 0.24 -
+ settlementCluster[i] * 0.30 -
Math.max(0, elevation[i] - 0.64) * 0.56
);
townSuitability[i] = clamp(
- developable[i] * 0.40 +
- valleySettlement[i] * 0.26 +
+ developable[i] * 0.38 +
+ agriculture[i] * 0.18 +
+ valleySettlement[i] * 0.24 +
coastalSettlement[i] * 0.20 +
confluence * 0.34 +
basinField[i] * 0.16 +
- plain[i] * 0.12 +
- settlementCluster[i] * 0.16 -
+ plain[i] * 0.18 +
+ settlementCluster[i] * 0.22 -
slope[i] * 0.34 -
ridgeField[i] * 0.17 -
spine * 0.10
@@ -303,21 +304,21 @@ export function generateMapFeatures(seed, terrain) {
const villageScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
- villageScore[i] = clamp(ruralSuitability[i] * 0.68 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + settlementCluster[i] * 0.08);
+ villageScore[i] = clamp(ruralSuitability[i] * 0.60 + agriculture[i] * 0.30 + plain[i] * 0.15 + valleySettlement[i] * 0.24 + coastalSettlement[i] * 0.16 + settlementCluster[i] * 0.16);
}
const villages = pickRegionalPoints(villageScore, {
stride: 2,
- threshold: 0.25 + rand(seed, 1031) * 0.04,
- totalMax: 140,
- minDistance: 5,
+ threshold: 0.18 + rand(seed, 1031) * 0.030,
+ totalMax: 280,
+ minDistance: 4,
seedOffset: 1030,
kind: "Village",
quotaForRegion: (regionId, st) => {
if (!st || st.developableCells < 10) return 0;
const vf = visibilityFactor(regionId, st);
- const raw = (st.developableCells / 65 + st.valleyCells / 44 + st.coastCells / 55 + 1.2) * vf;
- const min = st.area > 2600 ? 7 : st.area > 1400 ? 4 : st.area > 520 ? 2 : st.area > 220 ? 1 : 0;
- const max = st.area > 3600 ? 24 : st.area > 2200 ? 17 : st.area > 900 ? 9 : 4;
+ const raw = (st.developableCells / 28 + st.plainCells / 42 + st.valleyCells / 34 + st.coastCells / 46 + 3.2) * vf;
+ const min = st.area > 2600 ? 20 : st.area > 1400 ? 12 : st.area > 520 ? 5 : st.area > 220 ? 2 : 0;
+ const max = st.area > 3600 ? 72 : st.area > 2200 ? 50 : st.area > 900 ? 25 : 10;
return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max));
},
}).map((p, n) => {
@@ -341,8 +342,10 @@ export function generateMapFeatures(seed, terrain) {
);
const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0;
marketScore[i] = clamp(
- townSuitability[i] * 0.62 +
- villageInfluence[i] * 0.38 +
+ townSuitability[i] * 0.58 +
+ agriculture[i] * 0.16 +
+ plain[i] * 0.14 +
+ villageInfluence[i] * 0.40 +
featurePull +
valleyMouth +
basinField[i] * 0.12 +
@@ -356,17 +359,17 @@ export function generateMapFeatures(seed, terrain) {
const markets = pickRegionalPoints(marketScore, {
stride: 2,
- threshold: 0.31 + rand(seed, 1041) * 0.045,
- totalMax: 52,
- minDistance: 9,
+ threshold: 0.245 + rand(seed, 1041) * 0.035,
+ totalMax: 110,
+ minDistance: 6,
seedOffset: 1040,
kind: "Market Town",
quotaForRegion: (regionId, st) => {
if (!st || st.townCells < 8) return 0;
const vf = visibilityFactor(regionId, st);
- const raw = (st.developableCells / 260 + st.valleyCells / 150 + st.coastCells / 160 + 0.8) * vf;
- const min = st.area > 2600 ? 3 : st.area > 1200 ? 2 : st.area > 520 ? 1 : 0;
- const max = st.area > 3600 ? 9 : st.area > 2200 ? 7 : st.area > 800 ? 4 : 2;
+ const raw = (st.developableCells / 105 + st.plainCells / 125 + st.valleyCells / 105 + st.coastCells / 130 + 2.1) * vf;
+ const min = st.area > 2600 ? 9 : st.area > 1200 ? 5 : st.area > 520 ? 2 : 0;
+ const max = st.area > 3600 ? 30 : st.area > 2200 ? 22 : st.area > 800 ? 10 : 5;
return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max));
},
extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 8 ? 0.07 : 0) + confluenceField[i] * 0.08,
@@ -779,8 +782,36 @@ export function generateMapFeatures(seed, terrain) {
const satelliteCities = [];
const newTowns = [];
- const logisticsParks = [];
const interchanges = [];
+ const logisticsScore = new Float32Array(SIZE);
+ for (let y = 2; y < MAP_H - 2; y++) {
+ for (let x = 2; x < MAP_W - 2; x++) {
+ const i = indexOf(x, y);
+ if (sea[i]) continue;
+ const flatAgriculturalCorridor = clamp(
+ agriculture[i] * 0.34 +
+ plain[i] * 0.24 +
+ developable[i] * 0.20 +
+ basinField[i] * 0.12 +
+ coastalLowland[i] * 0.08 +
+ roadInfluence[i] * 0.26 +
+ railInfluence2[i] * 0.18 +
+ stationInfluence[i] * 0.10 -
+ slope[i] * 0.44 -
+ ridgeField[i] * 0.32
+ );
+ const nearMajorCity = modernCities.some((c) => Math.hypot(c.x - x, c.y - y) < 4);
+ logisticsScore[i] = nearMajorCity ? 0 : flatAgriculturalCorridor;
+ }
+ }
+ const logisticsParks = pickGlobalPoints(logisticsScore, {
+ threshold: 0.34,
+ max: 18,
+ minDistance: 12,
+ seedOffset: 1450,
+ predicate: (x, y, i) => logisticsScore[i] > 0.30 && (roadInfluence[i] > 0.10 || railInfluence2[i] > 0.08 || stationInfluence[i] > 0.08),
+ }).map((p) => ({ ...p, kind: "Logistics Park", score: logisticsScore[indexOf(p.x, p.y)], population: 0 }));
+ const logisticsInfluence = influenceFromPoints(logisticsParks, 4.8, () => 1.0);
var landuse = new Uint8Array(SIZE);
// Re-run land-use classification after landuse allocation. The loop above is
@@ -856,6 +887,10 @@ export function generateMapFeatures(seed, terrain) {
landuse[i] = LANDUSE.INDUSTRIAL;
continue;
}
+ if (logisticsInfluence[i] > 0.24 && urbanCapacity[i] > 0.08 && (roadInfluence[i] > 0.08 || railInfluence2[i] > 0.06)) {
+ landuse[i] = LANDUSE.LOGISTICS;
+ continue;
+ }
if (core > 0.38 && urbanCapacity[i] > 0.10) {
landuse[i] = LANDUSE.CBD;
continue;
@@ -873,10 +908,11 @@ export function generateMapFeatures(seed, terrain) {
landuse[i] = LANDUSE.SUBURB;
} else if (roadLanduseInfluence[i] > 0.30 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.05 || cityInfluence[i] > 0.11)) {
landuse[i] = LANDUSE.SUBURB;
- } else if (agriculture[i] > 0.24 || rural > 0.22 || (developable[i] > 0.18 && plain[i] > 0.18)) {
+ } else if (agriculture[i] > 0.16 || rural > 0.18 || (developable[i] > 0.13 && plain[i] > 0.13) || (basinField[i] > 0.18 && slope[i] < 0.34) || (coastalLowland[i] > 0.16 && slope[i] < 0.32)) {
landuse[i] = LANDUSE.FARMLAND;
} else {
- landuse[i] = elevation[i] > 0.52 || slope[i] > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL;
+ const usablePlain = slope[i] < 0.30 && (plain[i] > 0.18 || developable[i] > 0.20 || basinField[i] > 0.20 || coastalLowland[i] > 0.18);
+ landuse[i] = elevation[i] > 0.58 || slope[i] > 0.38 ? LANDUSE.FOREST : usablePlain ? LANDUSE.FARMLAND : LANDUSE.RURAL;
}
}
}
@@ -916,6 +952,22 @@ export function generateMapFeatures(seed, terrain) {
}
}
+ for (const park of logisticsParks) {
+ const r = 3;
+ for (let dy = -r; dy <= r; dy++) {
+ for (let dx = -r; dx <= r; dx++) {
+ const x = park.x + dx;
+ const y = park.y + dy;
+ if (!inside(x, y)) continue;
+ const i = indexOf(x, y);
+ if (sea[i] || landuse[i] === LANDUSE.CBD || landuse[i] === LANDUSE.FOREST) continue;
+ if (Math.hypot(dx, dy) <= r && (agriculture[i] > 0.18 || plain[i] > 0.15 || roadInfluence[i] > 0.06 || railInfluence2[i] > 0.05)) {
+ landuse[i] = LANDUSE.LOGISTICS;
+ }
+ }
+ }
+ }
+
if (maxDensity > 0) {
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
@@ -923,6 +975,8 @@ export function generateMapFeatures(seed, terrain) {
let floor = ruralDensityFloor[i];
if (lu === LANDUSE.FARMLAND) {
floor = Math.max(floor, clamp(0.024 + agriculture[i] * 0.044 + ruralSuitability[i] * 0.024 + roadDensityInfluence[i] * 0.030 + stationDensityInfluence[i] * 0.026 + villageInfluence[i] * 0.018, 0, 0.110));
+ } else if (lu === LANDUSE.LOGISTICS) {
+ floor = Math.max(floor, clamp(0.018 + roadDensityInfluence[i] * 0.026 + railInfluence2[i] * 0.014 + logisticsInfluence[i] * 0.012, 0, 0.060));
} else if (lu === LANDUSE.RURAL) {
floor = Math.max(floor, clamp(0.012 + ruralSuitability[i] * 0.020 + developable[i] * 0.014 + roadDensityInfluence[i] * 0.024 + stationDensityInfluence[i] * 0.020, 0, 0.070));
} else if (lu === LANDUSE.FOREST) {
diff --git a/mapOutput.js b/mapOutput.js
index fb78d1b..63904f6 100644
--- a/mapOutput.js
+++ b/mapOutput.js
@@ -24,6 +24,28 @@ function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`;
}
+
+function assignMunicipalityPopulations(adminCenters, adminId, fields) {
+ if (!adminCenters?.length || !adminId) return;
+ const totals = new Float64Array(adminCenters.length);
+ for (let i = 0; i < adminId.length; i++) {
+ const id = adminId[i];
+ if (id < 0 || id >= totals.length || fields.sea?.[i]) continue;
+ 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;
+ totals[id] += density * builtWeight + ruralFloor;
+ }
+ for (let id = 0; id < adminCenters.length; id++) {
+ const raw = totals[id] || 0;
+ const rounded = raw >= 10000 ? Math.round(raw / 1000) * 1000 : Math.round(raw / 100) * 100;
+ adminCenters[id].municipalityPopulation = Math.max(0, rounded);
+ }
+}
+
function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames, nameDebug) {
if (!prefectureRegionId) return [];
const byId = new Map();
@@ -264,19 +286,24 @@ export function finishMapOutput({
center.municipalityId = index;
let candidate = center.canonicalSettlementName || municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
- if (!center.canonicalSettlementName && usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
+ if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
candidate = generated;
}
- if (!center.canonicalSettlementName && usedAdminNames.has(candidate)) {
+ if (usedAdminNames.has(candidate)) {
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index);
- const base = String(center.generatedMunicipalityName || center.municipalityRootName || center.name || `自治${index + 1}`).replace(/[市町村区]$/u, "");
- candidate = `${base}${index + 1}${suffix}`;
+ const root = String(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name || `自治${index + 1}`).replace(/[市町村区]$/u, "");
+ const directions = ["東", "西", "南", "北", "上", "下", "中"];
+ for (let attempt = 0; attempt < directions.length + 3 && usedAdminNames.has(candidate); attempt++) {
+ const prefix = directions[attempt % directions.length];
+ candidate = attempt < directions.length ? `${prefix}${root}${suffix}` : `${root}${index + 1}${suffix}`;
+ }
}
center.name = candidate;
center.labelName = candidate;
center.municipalityName = candidate;
usedAdminNames.add(center.name);
}
+ assignMunicipalityPopulations(adminCenters, adminId, nameFields);
nameDebug.maxDerivedPerBase = 0;
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug);
const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];
@@ -311,7 +338,7 @@ export function finishMapOutput({
seaLevel,
prefectureMask,
humanRegionMask,
- prefectureBorder,
+ prefectureBorder: regionalPrefectureBorders.length ? regionalPrefectureBorders : prefectureBorder,
prefectureRegionId,
municipalityToPrefectureId,
prefectureRegions,
diff --git a/renderer.js b/renderer.js
index ed2ed61..053096f 100644
--- a/renderer.js
+++ b/renderer.js
@@ -1,9 +1,11 @@
-import { CELL_SIZE, MAP_H, MAP_W, clamp, fbm, indexOf, valueNoise } from "./mapUtils.js";
+import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js";
const segmentVectorCache = new WeakMap();
const pathVectorCache = new WeakMap();
const coastlineCache = new WeakMap();
+const baseImageCache = new WeakMap();
+const MAX_BASE_CACHE_IMAGES = 4;
function pointKey(p) {
return `${p[0]},${p[1]}`;
@@ -423,7 +425,25 @@ function discreteColor(map, x, y, mode) {
return blendOutside(color, Boolean(map.prefectureMask[i]));
}
-function drawBase(ctx, map, mode, continuousTerrain) {
+function baseCacheKey(mode, continuousTerrain) {
+ const continuousModes = ["terrain", "development", "all"];
+ if (continuousTerrain && continuousModes.includes(mode)) {
+ return `continuous:${mode === "all" ? "terrain" : mode}`;
+ }
+ return `discrete:${mode}`;
+}
+
+function getCachedBaseImage(ctx, map, mode, continuousTerrain) {
+ let cache = baseImageCache.get(map);
+ if (!cache) {
+ cache = new Map();
+ baseImageCache.set(map, cache);
+ }
+
+ const key = baseCacheKey(mode, continuousTerrain);
+ let image = cache.get(key);
+ if (image) return image;
+
const width = MAP_W * CELL_SIZE;
const height = MAP_H * CELL_SIZE;
const img = ctx.createImageData(width, height);
@@ -460,7 +480,13 @@ function drawBase(ctx, map, mode, continuousTerrain) {
}
}
}
- ctx.putImageData(img, 0, 0);
+ cache.set(key, img);
+ if (cache.size > MAX_BASE_CACHE_IMAGES) cache.delete(cache.keys().next().value);
+ return img;
+}
+
+function drawBase(ctx, map, mode, continuousTerrain) {
+ ctx.putImageData(getCachedBaseImage(ctx, map, mode, continuousTerrain), 0, 0);
}
function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
@@ -838,8 +864,9 @@ export function drawMap(canvas, map, options) {
}
if (!showPrefectureRegions) {
- drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
- drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
+ const finalPrefectureBorders = (map.regionalPrefectureBorders && map.regionalPrefectureBorders.length) ? map.regionalPrefectureBorders : map.prefectureBorder;
+ drawVectorSegments(ctx, finalPrefectureBorders, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
+ drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
}
if (!showFeatures) return;
@@ -896,7 +923,10 @@ export function drawMap(canvas, map, options) {
if (showModern) {
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
const allLayerTowns = mode === "all"
- ? (map.markets || []).filter((p) => (p.population || 0) >= 25000 && !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
+ ? [
+ ...(map.markets || []).filter((p) => (p.population || 0) >= 5000),
+ ...(map.villages || []).filter((p) => (p.population || 0) >= 5000),
+ ].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
: [];
for (const p of allLayerTowns) dot(ctx, p, 3.1, "rgba(244, 164, 118, 0.92)", "rgba(255,255,255,0.88)");
for (const p of map.modernCities) {
@@ -906,6 +936,7 @@ export function drawMap(canvas, map, options) {
else if (p.isRegionalCapital) dot(ctx, p, popRadius + 2.4, "transparent", "rgba(190,95,95,0.62)");
}
for (const p of map.interchanges || []) dot(ctx, p, 2.7, "rgba(250, 250, 245, 0.95)", "rgba(95, 125, 95, 0.95)");
+ for (const p of map.logisticsParks || []) dot(ctx, p, 2.4, "rgba(235, 238, 230, 0.95)", "rgba(105, 125, 105, 0.88)");
if (mode === "admin-debug" || mode === "borders-debug") {
for (const p of map.externalGateways || []) dot(ctx, p, 5.5, "rgba(255,255,255,0.95)", "rgba(40,80,170,0.95)");
for (const p of map.transportDebug?.missingRequiredNodes || []) dot(ctx, p, 6.5, "rgba(255,80,80,0.95)", "rgba(80,20,20,0.95)");
@@ -925,15 +956,21 @@ export function drawMap(canvas, map, options) {
return;
}
const allLayerTowns = mode === "all"
- ? (map.markets || []).filter((p) => (p.population || 0) >= 25000 && !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y)).map((p) => ({ ...p, labelPriorityBase: 120 }))
+ ? [
+ ...(map.markets || []).filter((p) => (p.population || 0) >= 5000),
+ ...(map.villages || []).filter((p) => (p.population || 0) >= 5000),
+ ]
+ .filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
+ .map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: p.kind === "Village" || p.kind === "Valley Village" || p.kind === "Coastal Village" ? 75 : 135 }))
: [];
const important = [
...prefectureLabels,
...map.modernCities,
...map.ports,
+ ...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })),
...(map.satelliteCities || []),
...allLayerTowns,
- ].filter((p) => p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 25000);
+ ].filter((p) => p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
drawLabels(ctx, important, mode === "all" ? 85 : 60);
}
drawScaleBar(ctx);
diff --git a/styles.css b/styles.css
index eea9652..d1a403e 100644
--- a/styles.css
+++ b/styles.css
@@ -2,23 +2,23 @@
body{margin:0;background:#f0f2f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
button,input{font:inherit}
code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
-.app{min-height:100vh;padding:24px}
-.layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:20px;max-width:1400px;margin:0 auto}
-.header{margin-bottom:16px}
+.app{min-height:100vh;padding:16px}
+.layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:16px;max-width:1360px;margin:0 auto}
+.header{margin-bottom:12px}
.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a;font-weight:700}
.header p{margin:0;color:#5a5a5a;line-height:1.6;font-size:14px}
-.canvas-shell,.card{background:#ffffff;border:1px solid rgba(0,0,0,0.08);border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,0.04)}
+.canvas-shell,.card{background:#ffffff;border:1px solid rgba(0,0,0,0.08);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.04)}
.canvas-shell{padding:12px;overflow:auto;position:relative}
.map-canvas{display:block;border-radius:8px;background:#f8f9fa}
-.sidebar{display:flex;flex-direction:column;gap:16px}
-.card{padding:18px}
+.sidebar{display:flex;flex-direction:column;gap:12px}
+.card{padding:14px}
.label,.card-title{display:block;margin-bottom:12px;color:#1a1a1a;font-size:14px;font-weight:600}
.input{width:100%;border:1px solid rgba(0,0,0,0.15);background:#fff;color:#2c2c2c;border-radius:8px;padding:10px 12px;outline:none;transition:border-color 0.2s}
.input:focus{border-color:#1a73e8;box-shadow:0 0 0 3px rgba(26,115,232,0.15)}
.primary-button,.mode-button{border:0;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;transition:background 0.2s}
.primary-button{margin-top:12px;width:100%;background:#1a73e8;color:#fff}
.primary-button:hover{background:#1557b0}
-.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
+.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.mode-button{background:#f1f3f4;color:#3c4043;border:1px solid transparent}
.mode-button:hover{background:#e8eaed}
.mode-button.active{background:#e8f0fe;color:#1a73e8;border:1px solid #1a73e8}