diff --git a/app.js b/app.js
index a5dfe93..ad57d2d 100644
--- a/app.js
+++ b/app.js
@@ -1,6 +1,10 @@
import { generateMapAsync } from "./mapGenerator.js";
import { drawMap } from "./renderer.js";
import { landuseLabel } from "./landuseCodes.js";
+import { CELL_SIZE, MAP_H, MAP_W } from "./mapUtils.js";
+import { clampCameraToWorld, createInitialCamera, createWorldMap } from "./worldMap.js";
+import { getViewportMap } from "./worldViewport.js";
+import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildPatchRects, generatePatch, validatePatchRect } from "./mapPatch.js";
const modes = [
["all", "All"],
@@ -21,19 +25,28 @@ const state = {
showFeatures: true,
showLabels: true,
map: null,
+ world: null,
+ camera: { x: 0, y: 0 },
+ viewportMap: null,
hoverEntities: [],
+ selectionRect: null,
+ lastPatchResult: null,
};
const canvas = document.getElementById("mapCanvas");
const canvasShell = document.querySelector(".canvas-shell");
const seedInput = document.getElementById("seed");
const generationTypeInput = document.getElementById("generationType");
+const patchTerrainTypeInput = document.getElementById("patchTerrainType");
+const generatePatchButton = document.getElementById("generatePatch");
+const patchStatusEl = document.getElementById("patchStatus");
const randomSeedButton = document.getElementById("randomSeed");
const showFeaturesInput = document.getElementById("showFeatures");
const showLabelsInput = document.getElementById("showLabels");
const modeGrid = document.getElementById("modeGrid");
const statsEl = document.getElementById("stats");
const tooltipEl = document.getElementById("mapTooltip");
+const selectionEl = document.getElementById("mapSelection");
const progressEl = document.getElementById("generationProgress");
const progressStageEl = document.getElementById("generationProgressStage");
const progressTimingsEl = document.getElementById("generationProgressTimings");
@@ -41,66 +54,253 @@ let generationStartedAt = 0;
let generationCurrentStage = "";
let generationTimer = null;
-const panState = { keys: new Set(), raf: null, lastTime: 0, speedPxPerSecond: 520 };
+const dragState = {
+ mode: null,
+ pointerId: null,
+ startClientX: 0,
+ startClientY: 0,
+ startCameraX: 0,
+ startCameraY: 0,
+ selectStart: null,
+ selectEnd: null,
+ pendingCamera: null,
+ panRaf: null,
+};
+
+function activeMap() {
+ return state.viewportMap || state.map;
+}
function mapClientToCell(event) {
- if (!state.map) return null;
+ const map = activeMap();
+ if (!map) return null;
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const relX = (event.clientX - rect.left) / rect.width;
const relY = (event.clientY - rect.top) / rect.height;
return {
- x: Math.floor(relX * state.map.width),
- y: Math.floor(relY * state.map.height),
+ x: Math.floor(relX * map.width),
+ y: Math.floor(relY * map.height),
};
}
-function isEditableTarget(target) {
- if (!target) return false;
- const tag = target.tagName?.toLowerCase?.();
- return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
+function viewportCellToWorldCell(cell) {
+ if (!cell || !state.camera) return null;
+ return {
+ x: Math.round(state.camera.x || 0) + cell.x,
+ y: Math.round(state.camera.y || 0) + cell.y,
+ };
}
-function panFrame(time) {
- if (!canvasShell || panState.keys.size === 0) {
- panState.raf = null;
- panState.lastTime = 0;
+function clampCanvasPoint(event) {
+ const rect = canvas.getBoundingClientRect();
+ return {
+ x: Math.min(Math.max(event.clientX - rect.left, 0), rect.width),
+ y: Math.min(Math.max(event.clientY - rect.top, 0), rect.height),
+ };
+}
+
+function updateSelectionOverlay() {
+ if (!selectionEl || !dragState.selectStart || !dragState.selectEnd) return;
+ const x0 = Math.min(dragState.selectStart.x, dragState.selectEnd.x);
+ const y0 = Math.min(dragState.selectStart.y, dragState.selectEnd.y);
+ const x1 = Math.max(dragState.selectStart.x, dragState.selectEnd.x);
+ const y1 = Math.max(dragState.selectStart.y, dragState.selectEnd.y);
+ selectionEl.style.display = "block";
+ selectionEl.style.left = `${canvas.offsetLeft + x0}px`;
+ selectionEl.style.top = `${canvas.offsetTop + y0}px`;
+ selectionEl.style.width = `${Math.max(1, x1 - x0)}px`;
+ selectionEl.style.height = `${Math.max(1, y1 - y0)}px`;
+ const liveRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd);
+ const validation = validatePatchRect(liveRect, state.world);
+ selectionEl.classList.toggle("invalid", !validation.ok);
+ if (generatePatchButton) generatePatchButton.disabled = true;
+ if (patchStatusEl) {
+ const current = validation.rect || liveRect;
+ patchStatusEl.textContent = validation.ok
+ ? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.`
+ : `${validation.reason} Current: ${formatRectSize(current)}.`;
+ patchStatusEl.classList.toggle("invalid", !validation.ok);
+ }
+}
+
+function updateSelectionOverlayFromWorldRect() {
+ if (!selectionEl || !state.selectionRect || !state.camera || !activeMap()) return;
+ const rect = canvas.getBoundingClientRect();
+ if (!rect.width || !rect.height) return;
+ const map = activeMap();
+ const cameraX = Math.round(state.camera.x || 0);
+ const cameraY = Math.round(state.camera.y || 0);
+ const vx0 = (state.selectionRect.x0 - cameraX) / map.width * rect.width;
+ const vy0 = (state.selectionRect.y0 - cameraY) / map.height * rect.height;
+ const vx1 = (state.selectionRect.x1 - cameraX) / map.width * rect.width;
+ const vy1 = (state.selectionRect.y1 - cameraY) / map.height * rect.height;
+ const x0 = Math.min(Math.max(Math.min(vx0, vx1), 0), rect.width);
+ const y0 = Math.min(Math.max(Math.min(vy0, vy1), 0), rect.height);
+ const x1 = Math.min(Math.max(Math.max(vx0, vx1), 0), rect.width);
+ const y1 = Math.min(Math.max(Math.max(vy0, vy1), 0), rect.height);
+ if (x1 - x0 < 1 || y1 - y0 < 1) {
+ selectionEl.style.display = "none";
return;
}
- const dt = panState.lastTime ? Math.min(0.05, (time - panState.lastTime) / 1000) : 0;
- panState.lastTime = time;
- let dx = 0;
- let dy = 0;
- if (panState.keys.has("a")) dx -= 1;
- if (panState.keys.has("d")) dx += 1;
- if (panState.keys.has("w")) dy -= 1;
- if (panState.keys.has("s")) dy += 1;
- if (dx || dy) {
- const normalizer = dx && dy ? Math.SQRT1_2 : 1;
- const amount = panState.speedPxPerSecond * dt;
- canvasShell.scrollLeft += dx * normalizer * amount;
- canvasShell.scrollTop += dy * normalizer * amount;
- tooltipEl?.classList.remove("visible");
+ selectionEl.style.display = "block";
+ selectionEl.style.left = `${canvas.offsetLeft + x0}px`;
+ selectionEl.style.top = `${canvas.offsetTop + y0}px`;
+ selectionEl.style.width = `${Math.max(1, x1 - x0)}px`;
+ selectionEl.style.height = `${Math.max(1, y1 - y0)}px`;
+ const validation = validatePatchRect(state.selectionRect, state.world);
+ selectionEl.classList.toggle("invalid", !validation.ok);
+}
+
+function formatRectSize(rect) {
+ if (!rect) return "-";
+ const w = Math.max(0, rect.x1 - rect.x0);
+ const h = Math.max(0, rect.y1 - rect.y0);
+ return `${w} x ${h} cells / ${(w * h).toLocaleString()} cells`;
+}
+
+function updatePatchControls() {
+ if (!patchStatusEl && !generatePatchButton) return;
+ const validation = validatePatchRect(state.selectionRect, state.world);
+ if (generatePatchButton) generatePatchButton.disabled = !validation.ok;
+ if (!patchStatusEl) return;
+ if (!state.selectionRect) {
+ patchStatusEl.textContent = `Right-drag an area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells.`;
+ patchStatusEl.classList.toggle("invalid", false);
+ return;
}
- panState.raf = requestAnimationFrame(panFrame);
+ if (!validation.ok) {
+ patchStatusEl.textContent = `${validation.reason} Current: ${formatRectSize(validation.rect || state.selectionRect)}.`;
+ patchStatusEl.classList.toggle("invalid", true);
+ return;
+ }
+ const rects = buildPatchRects(validation.rect, state.world);
+ const patchText = state.lastPatchResult
+ ? ` Last patch: ${state.lastPatchResult.label}, terrain ${state.lastPatchResult.updatedCells.toLocaleString()} cells, coast ${state.lastPatchResult.coastCellsChanged || 0}, natural ${state.lastPatchResult.naturalRegionsUpdated || 0}${state.lastPatchResult.humanGeography?.ok ? `, connectors ${(state.lastPatchResult.humanGeography.roadConnectorsCreated || 0) + (state.lastPatchResult.humanGeography.railwayConnectorsCreated || 0)}, admin ${state.lastPatchResult.humanGeography.adminCellsReassigned || 0}, invalid ports ${state.lastPatchResult.humanGeography.invalidPortsRemoved || 0}` : ""}.`
+ : "";
+ patchStatusEl.textContent = `Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`;
+ patchStatusEl.classList.toggle("invalid", false);
}
-function startKeyboardPan() {
- if (panState.raf == null) panState.raf = requestAnimationFrame(panFrame);
+function clearDragMode() {
+ dragState.mode = null;
+ dragState.pointerId = null;
+ dragState.pendingCamera = null;
+ if (dragState.panRaf != null) {
+ cancelAnimationFrame(dragState.panRaf);
+ dragState.panRaf = null;
+ }
+ canvasShell?.classList.remove("panning", "selecting");
}
-function handlePanKeyDown(event) {
- const key = event.key?.toLowerCase?.();
- if (!key || !"wasd".includes(key) || isEditableTarget(event.target)) return;
- panState.keys.add(key);
- startKeyboardPan();
+function schedulePanRedraw(camera) {
+ dragState.pendingCamera = camera;
+ if (dragState.panRaf != null) return;
+ dragState.panRaf = requestAnimationFrame(() => {
+ dragState.panRaf = null;
+ if (!dragState.pendingCamera) return;
+ const next = dragState.pendingCamera;
+ dragState.pendingCamera = null;
+ if (next.x === state.camera.x && next.y === state.camera.y) return;
+ state.camera = next;
+ redraw({ fastTerrain: true });
+ });
+}
+
+function hideSelectionOverlay() {
+ dragState.selectStart = null;
+ dragState.selectEnd = null;
+ state.selectionRect = null;
+ if (selectionEl) selectionEl.style.display = "none";
+ updatePatchControls();
+}
+
+function selectionPixelsToCells(start, end) {
+ const map = activeMap();
+ if (!map || !start || !end) return null;
+ const rect = canvas.getBoundingClientRect();
+ if (!rect.width || !rect.height) return null;
+ const localX0 = Math.floor(Math.min(start.x, end.x) / rect.width * map.width);
+ const localY0 = Math.floor(Math.min(start.y, end.y) / rect.height * map.height);
+ const localX1 = Math.ceil(Math.max(start.x, end.x) / rect.width * map.width);
+ const localY1 = Math.ceil(Math.max(start.y, end.y) / rect.height * map.height);
+ const cameraX = Math.round(state.camera?.x || 0);
+ const cameraY = Math.round(state.camera?.y || 0);
+ return {
+ x0: cameraX + Math.min(Math.max(localX0, 0), map.width - 1),
+ y0: cameraY + Math.min(Math.max(localY0, 0), map.height - 1),
+ x1: cameraX + Math.min(Math.max(localX1, 1), map.width),
+ y1: cameraY + Math.min(Math.max(localY1, 1), map.height),
+ };
+}
+
+function handleMapPointerDown(event) {
+ if (!state.world || !canvasShell) return;
+ if (event.button !== 0 && event.button !== 2) return;
+ dragState.pointerId = event.pointerId;
+ dragState.startClientX = event.clientX;
+ dragState.startClientY = event.clientY;
+ dragState.startCameraX = state.camera.x;
+ dragState.startCameraY = state.camera.y;
+ tooltipEl?.classList.remove("visible");
+
+ if (event.button === 0) {
+ dragState.mode = "pan";
+ canvasShell.classList.add("panning");
+ } else {
+ dragState.mode = "select";
+ dragState.selectStart = clampCanvasPoint(event);
+ dragState.selectEnd = dragState.selectStart;
+ canvasShell.classList.add("selecting");
+ updateSelectionOverlay();
+ }
+
+ canvas.setPointerCapture?.(event.pointerId);
event.preventDefault();
}
-function handlePanKeyUp(event) {
- const key = event.key?.toLowerCase?.();
- if (!key || !"wasd".includes(key)) return;
- panState.keys.delete(key);
+function handleMapPointerMove(event) {
+ if (!dragState.mode || dragState.pointerId !== event.pointerId || !canvasShell) return;
+ tooltipEl?.classList.remove("visible");
+
+ if (dragState.mode === "pan") {
+ const dxCells = Math.round((event.clientX - dragState.startClientX) / CELL_SIZE);
+ const dyCells = Math.round((event.clientY - dragState.startClientY) / CELL_SIZE);
+ const nextCamera = clampCameraToWorld({
+ x: dragState.startCameraX - dxCells,
+ y: dragState.startCameraY - dyCells,
+ }, state.world, MAP_W, MAP_H);
+ schedulePanRedraw(nextCamera);
+ } else if (dragState.mode === "select") {
+ dragState.selectEnd = clampCanvasPoint(event);
+ updateSelectionOverlay();
+ }
+
+ event.preventDefault();
+}
+
+function handleMapPointerUp(event) {
+ if (dragState.pointerId !== event.pointerId) return;
+ const wasPanning = dragState.mode === "pan";
+ if (dragState.mode === "select") {
+ dragState.selectEnd = clampCanvasPoint(event);
+ const width = Math.abs(dragState.selectEnd.x - dragState.selectStart.x);
+ const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y);
+ if (width >= 4 && height >= 4) {
+ state.selectionRect = selectionPixelsToCells(dragState.selectStart, dragState.selectEnd);
+ updateSelectionOverlayFromWorldRect();
+ updatePatchControls();
+ } else {
+ hideSelectionOverlay();
+ }
+ }
+ canvas.releasePointerCapture?.(event.pointerId);
+ if (wasPanning && dragState.pendingCamera) {
+ state.camera = dragState.pendingCamera;
+ dragState.pendingCamera = null;
+ }
+ clearDragMode();
+ if (wasPanning) redraw({ fastTerrain: false });
event.preventDefault();
}
@@ -283,29 +483,35 @@ function prefectureNameForCell(map, i) {
}
function updateTooltip(event) {
- if (!state.map || !tooltipEl) return;
+ const map = activeMap();
+ if (!map || !tooltipEl || dragState.mode) return;
const rect = canvas.getBoundingClientRect();
const cell = mapClientToCell(event);
if (!cell) return;
const { x, y } = cell;
- if (x < 0 || y < 0 || x >= state.map.width || y >= state.map.height) {
+ if (x < 0 || y < 0 || x >= map.width || y >= map.height) {
tooltipEl.classList.remove("visible");
return;
}
- const i = y * state.map.width + x;
+ const i = y * map.width + x;
+ const worldCell = viewportCellToWorldCell({ 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 elevation = map.elevation?.[i] ?? 0;
+ const density = map.populationDensity?.[i] ?? 0;
+ const hoveredAdminId = map.adminId?.[i] ?? -1;
+ const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId);
+ const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`;
+ const entityTitle = entity
+ ? `${entity.name || entity.facilityLabel || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
+ : coordinateText;
const lines = [
- `${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`} `,
- `Prefecture: ${prefectureNameForCell(state.map, i)}`,
- `Admin: ${adminName(state.map, hoveredAdminId)}`,
+ `${entityTitle} `,
+ `Prefecture: ${prefectureNameForCell(map, i)}`,
+ `Admin: ${adminName(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)}`,
+ `Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`,
+ `Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`,
+ `River: ${(map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
];
if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`);
tooltipEl.innerHTML = lines.join(" ");
@@ -343,7 +549,10 @@ async function regenerate() {
await nextFrame();
try {
state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress, terrainType: state.generationType });
- state.hoverEntities = buildHoverEntities(state.map);
+ state.world = createWorldMap(state.map);
+ state.camera = createInitialCamera(state.world);
+ state.lastPatchResult = null;
+ hideSelectionOverlay();
renderStats(state.map);
redraw();
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
@@ -355,13 +564,62 @@ async function regenerate() {
}
}
-function redraw() {
- if (!state.map) return;
- drawMap(canvas, state.map, {
+
+function derivePatchSeed(rect, terrainType) {
+ let h = parseSeed(state.seedText) ^ 0x9e3779b9;
+ h = Math.imul(h ^ (rect.x0 | 0), 1664525) >>> 0;
+ h = Math.imul(h ^ (rect.y0 | 0), 1013904223) >>> 0;
+ h = Math.imul(h ^ (rect.x1 | 0), 2246822519) >>> 0;
+ h = Math.imul(h ^ (rect.y1 | 0), 3266489917) >>> 0;
+ for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
+ return h >>> 0;
+}
+
+async function generateSelectedPatch() {
+ const validation = validatePatchRect(state.selectionRect, state.world);
+ if (!validation.ok) {
+ updatePatchControls();
+ return;
+ }
+ const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto";
+ const seed = derivePatchSeed(validation.rect, terrainType);
+ setProgressVisible(true, "Generating selected patch...");
+ await nextFrame();
+ try {
+ const result = generatePatch(state.world, validation.rect, { terrainType, seed });
+ if (!result.ok) {
+ if (progressStageEl) progressStageEl.textContent = `Patch failed: ${result.reason || "invalid selection"}`;
+ updatePatchControls();
+ window.setTimeout(() => setProgressVisible(false), 1200);
+ return;
+ }
+ state.lastPatchResult = result;
+ redraw();
+ renderStats(state.map);
+ updatePatchControls();
+ const human = result.humanGeography;
+ const humanText = human?.ok ? ` / human: ${human.modernCities || 0} cities, ${human.ports || 0} ports, ${human.villages || 0} villages, ${(human.roadConnectorsCreated || 0) + (human.railwayConnectorsCreated || 0)} connectors, admin ${human.adminCellsReassigned || 0}, invalid ports ${human.invalidPortsRemoved || 0}` : "";
+ if (progressStageEl) progressStageEl.textContent = `Patch generated: ${result.label} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}`;
+ renderTimingRows([]);
+ window.setTimeout(() => setProgressVisible(false), 900);
+ } catch (error) {
+ if (progressStageEl) progressStageEl.textContent = `Patch failed: ${error?.message || error}`;
+ throw error;
+ }
+}
+
+function redraw(options = {}) {
+ if (!state.world) return;
+ state.camera = clampCameraToWorld(state.camera, state.world, MAP_W, MAP_H);
+ state.viewportMap = getViewportMap(state.world, state.camera, MAP_W, MAP_H);
+ state.hoverEntities = buildHoverEntities(state.viewportMap);
+ drawMap(canvas, state.viewportMap, {
mode: state.mode,
showFeatures: state.showFeatures,
- showLabels: state.showLabels,
+ showLabels: state.showLabels && !options.fastTerrain,
+ continuousTerrain: !options.fastTerrain,
});
+ if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect();
}
function init() {
@@ -373,6 +631,8 @@ function init() {
});
generationTypeInput?.addEventListener("change", regenerate);
+ patchTerrainTypeInput?.addEventListener("change", updatePatchControls);
+ generatePatchButton?.addEventListener("click", generateSelectedPatch);
randomSeedButton.addEventListener("click", () => {
seedInput.value = String(Math.floor(Math.random() * 9999999));
@@ -390,13 +650,17 @@ function init() {
});
canvasShell?.setAttribute("tabindex", "0");
- window.addEventListener("keydown", handlePanKeyDown);
- window.addEventListener("keyup", handlePanKeyUp);
+ canvas.addEventListener("contextmenu", (event) => event.preventDefault());
+ canvas.addEventListener("pointerdown", handleMapPointerDown);
+ canvas.addEventListener("pointermove", handleMapPointerMove);
+ canvas.addEventListener("pointerup", handleMapPointerUp);
+ canvas.addEventListener("pointercancel", handleMapPointerUp);
canvas.addEventListener("mousemove", updateTooltip);
canvas.addEventListener("mouseleave", () => {
tooltipEl?.classList.remove("visible");
});
+ updatePatchControls();
regenerate();
}
diff --git a/index.html b/index.html
index a60db97..3ace528 100644
--- a/index.html
+++ b/index.html
@@ -13,12 +13,13 @@
+
Generating map...
Preparing
@@ -38,12 +39,30 @@
Tohoku spine
Chubu mountain
Setouchi inland sea
+
Oceanic archipelago
Kanto alluvial plain
Mixed archipelago
Generate Random Seed
+
+
+ Patch Generation
+ Patch Terrain Type
+
+ Auto
+ Tohoku spine
+ Chubu mountain
+ Setouchi inland sea
+ Oceanic archipelago
+ Kanto alluvial plain
+ Mixed archipelago
+
+ Generate Selected Area
+ Right-drag an area to enable patch generation.
+
+
Display Layers
@@ -86,7 +105,7 @@
Notes
- Open index.html with Live Server. Open test.html to run browser tests.
+ Open index.html with Live Server. Left-drag pans the viewport; right-drag selects a regeneration area; use Patch Generation to write terrain into that area.
Add preferred reusable place names in CUSTOM_NAME_LIST inside names.js.
diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js
index 911b05c..68a3b16 100644
--- a/mapGeneratorHelpers.js
+++ b/mapGeneratorHelpers.js
@@ -1327,6 +1327,18 @@ export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nam
return points.map((p, i) => {
const id = `${prefix}-${i}`;
const kind = kindOverride || p.kind;
+ if (prefix === "logistics") {
+ return {
+ ...p,
+ id,
+ name: null,
+ facilityLabel: p.facilityLabel || "Logistics Park",
+ kind,
+ labelStyle: "facility",
+ suppressSettlementLabel: true,
+ insidePrefecture: Boolean(p.insidePrefecture),
+ };
+ }
const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug);
if (usedNames) usedNames.add(name);
return {
diff --git a/mapHumanPatch.js b/mapHumanPatch.js
new file mode 100644
index 0000000..f1594a2
--- /dev/null
+++ b/mapHumanPatch.js
@@ -0,0 +1,1177 @@
+import { clamp, hash2, MinHeap, pickEntities } from "./mapUtils.js";
+import { LANDUSE } from "./landuseCodes.js";
+import { generateEntityName } from "./names.js";
+
+const POINT_LAYER_KEYS = [
+ "villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations",
+ "interchanges", "industrialZones", "logisticsParks", "newTowns",
+];
+
+const PATH_LAYER_KEYS = [
+ "premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
+ "railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
+];
+
+const SEGMENT_LAYER_KEYS = ["adminBorders"];
+
+const FLOAT_FIELD_KEYS = [
+ "settlementScore", "populationDensity", "stationInfluence", "roadInfluence", "railInfluence2", "villageInfluence",
+];
+
+const INT_FIELD_DEFAULTS = new Map([
+ ["adminId", -1],
+ ["municipalityId", -1],
+]);
+
+function worldIndex(world, x, y) {
+ if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1;
+ return y * world.width + x;
+}
+
+function rectWidth(rect) {
+ return Math.max(0, Math.floor(rect.x1) - Math.floor(rect.x0));
+}
+
+function rectHeight(rect) {
+ return Math.max(0, Math.floor(rect.y1) - Math.floor(rect.y0));
+}
+
+function rectArea(rect) {
+ return rectWidth(rect) * rectHeight(rect);
+}
+
+function insideRect(x, y, rect) {
+ return rect && x >= rect.x0 && y >= rect.y0 && x < rect.x1 && y < rect.y1;
+}
+
+function distanceToRectEdge(x, y, rect) {
+ return Math.min(x - rect.x0, y - rect.y0, rect.x1 - 1 - x, rect.y1 - 1 - y);
+}
+
+function expandRect(rect, margin, world) {
+ return {
+ x0: Math.max(0, rect.x0 - margin),
+ y0: Math.max(0, rect.y0 - margin),
+ x1: Math.min(world.width, rect.x1 + margin),
+ y1: Math.min(world.height, rect.y1 + margin),
+ };
+}
+
+function worldToSourcePoint(world, x, y) {
+ return { x: x - world.originX, y: y - world.originY };
+}
+
+function pointWorldX(world, p) {
+ if (Number.isFinite(p?.worldX)) return p.worldX;
+ return (p?.x || 0) + (world?.originX || 0);
+}
+
+function pointWorldY(world, p) {
+ if (Number.isFinite(p?.worldY)) return p.worldY;
+ return (p?.y || 0) + (world?.originY || 0);
+}
+
+function tupleWorldX(world, tuple) {
+ return (tuple?.[0] || 0) + (world?.originX || 0);
+}
+
+function tupleWorldY(world, tuple) {
+ return (tuple?.[1] || 0) + (world?.originY || 0);
+}
+
+function ensureSourceArray(sourceMap, key) {
+ if (!Array.isArray(sourceMap[key])) sourceMap[key] = [];
+ return sourceMap[key];
+}
+
+function ensureField(world, key, Constructor = Float32Array, fallback = 0) {
+ if (!world.fields[key] || world.fields[key].length !== world.width * world.height) {
+ world.fields[key] = new Constructor(world.width * world.height);
+ if (fallback !== 0) world.fields[key].fill(fallback);
+ }
+ return world.fields[key];
+}
+
+function seeded(seed, x, y, salt = 0) {
+ return hash2((x | 0) + salt * 8191, (y | 0) - salt * 131, seed >>> 0);
+}
+
+function localId(rect, x, y, salt = 0) {
+ return `${rect.x0}:${rect.y0}:${x}:${y}:${salt}`;
+}
+
+function makeName(seed, rect, x, y, entity, usedNames, salt = 0) {
+ const id = localId(rect, x, y, salt);
+ const name = generateEntityName(seed, id, entity, null, usedNames);
+ if (name) usedNames?.add(name);
+ return name || `隨ャ${Math.max(1, Math.floor(seeded(seed, x, y, salt) * 99))}逕コ`;
+}
+
+function isLand(world, x, y) {
+ const i = worldIndex(world, x, y);
+ return i >= 0 && !world.fields.sea?.[i];
+}
+
+function seaNeighbors(world, x, y, radius = 1) {
+ let count = 0;
+ for (let dy = -radius; dy <= radius; dy++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ if (!dx && !dy) continue;
+ const i = worldIndex(world, x + dx, y + dy);
+ if (i >= 0 && world.fields.sea?.[i]) count++;
+ }
+ }
+ return count;
+}
+
+function candidateScore(world, x, y, seed) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || world.fields.sea?.[i]) return -Infinity;
+ const elevation = world.fields.elevation?.[i] || 0;
+ const slope = world.fields.slope?.[i] || 0;
+ const plain = world.fields.plain?.[i] || 0;
+ const agriculture = world.fields.agriculture?.[i] || 0;
+ const river = world.fields.river?.[i] || 0;
+ const floodplain = world.fields.floodplain?.[i] || 0;
+ const coast = seaNeighbors(world, x, y, 2) > 0 ? 0.26 : 0;
+ const lowland = Math.max(0, 1 - Math.abs(elevation - 0.33) * 2.0);
+ const noise = seeded(seed, x, y, 47) * 0.18;
+ return plain * 0.72 + agriculture * 0.72 + floodplain * 0.45 + river * 0.30 + coast + lowland * 0.38 - slope * 1.35 + noise;
+}
+
+function collectLandCandidates(world, rect, seed, stride = 3) {
+ const candidates = [];
+ for (let y = rect.y0 + 2; y < rect.y1 - 2; y += stride) {
+ for (let x = rect.x0 + 2; x < rect.x1 - 2; x += stride) {
+ const score = candidateScore(world, x, y, seed);
+ if (score > 0.28) candidates.push({ x, y, score });
+ }
+ }
+ return candidates;
+}
+
+function collectPortCandidates(world, rect, seed) {
+ const out = [];
+ for (let y = rect.y0 + 2; y < rect.y1 - 2; y += 2) {
+ for (let x = rect.x0 + 2; x < rect.x1 - 2; x += 2) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || world.fields.sea?.[i]) continue;
+ const seaN = seaNeighbors(world, x, y, 2);
+ if (seaN < 3) continue;
+ const slope = world.fields.slope?.[i] || 0;
+ const suit = world.fields.portSuitability?.[i] || 0;
+ const score = seaN * 0.09 + suit * 1.10 + (1 - slope) * 0.35 + seeded(seed, x, y, 71) * 0.20;
+ if (score > 0.55) out.push({ x, y, score });
+ }
+ }
+ return out;
+}
+
+function usedNameSet(sourceMap) {
+ const out = new Set();
+ for (const key of ["villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations", "industrialZones", "logisticsParks", "newTowns", "adminCenters"]) {
+ for (const p of sourceMap[key] || []) if (p?.name) out.add(p.name);
+ }
+ return out;
+}
+
+function hideAdminCentersInside(sourceMap, world, rect) {
+ if (!Array.isArray(sourceMap.adminCenters)) return 0;
+ let hidden = 0;
+ for (const p of sourceMap.adminCenters) {
+ if (!p || p.patchHidden) continue;
+ const wx = pointWorldX(world, p);
+ const wy = pointWorldY(world, p);
+ if (!insideRect(wx, wy, rect)) continue;
+ p.patchHidden = true;
+ p.patchHiddenAt = Date.now();
+ p.x = -100000;
+ p.y = -100000;
+ hidden++;
+ }
+ return hidden;
+}
+
+function pointLayerPreservedCount(sourceMap, world, userRect) {
+ let preserved = 0;
+ for (const key of [...POINT_LAYER_KEYS, "adminCenters"]) {
+ const arr = sourceMap[key];
+ if (!Array.isArray(arr)) continue;
+ for (const p of arr) {
+ if (!p || p.patchHidden) continue;
+ const wx = pointWorldX(world, p);
+ const wy = pointWorldY(world, p);
+ if (!insideRect(wx, wy, userRect)) preserved++;
+ }
+ }
+ return preserved;
+}
+
+function portStillValid(world, p, rect) {
+ const wx = Math.round(pointWorldX(world, p));
+ const wy = Math.round(pointWorldY(world, p));
+ const land = nearestLand(world, wx, wy, rect, 5);
+ return !!land && seaNeighbors(world, land.x, land.y, 2) >= 2;
+}
+
+function prunePointLayers(sourceMap, world, rects) {
+ let removed = 0;
+ let invalidPortsRemoved = 0;
+ const userRect = rects.writeRect || rects.userRect;
+ const blendRect = rects.coreRect || rects.blendRect;
+ for (const key of POINT_LAYER_KEYS) {
+ const arr = sourceMap[key];
+ if (!Array.isArray(arr)) continue;
+ const kept = [];
+ for (const p of arr) {
+ const wx = pointWorldX(world, p);
+ const wy = pointWorldY(world, p);
+ const inBlend = insideRect(wx, wy, blendRect);
+ const inUser = insideRect(wx, wy, userRect);
+ const invalidTransitionPort = key === "ports" && inUser && !inBlend && !portStillValid(world, p, userRect);
+ if (inBlend || invalidTransitionPort) {
+ removed++;
+ if (invalidTransitionPort) invalidPortsRemoved++;
+ } else {
+ kept.push(p);
+ }
+ }
+ sourceMap[key] = kept;
+ }
+ removed += hideAdminCentersInside(sourceMap, world, blendRect);
+ return { removedPoints: removed, invalidPortsRemoved };
+}
+
+function segmentTouchesRect(world, seg, rect) {
+ if (!Array.isArray(seg) || seg.length < 2) return false;
+ return insideRect(tupleWorldX(world, seg[0]), tupleWorldY(world, seg[0]), rect)
+ || insideRect(tupleWorldX(world, seg[1]), tupleWorldY(world, seg[1]), rect);
+}
+
+function pathLayerPreservedCount(sourceMap, world, userRect) {
+ let preserved = 0;
+ for (const key of PATH_LAYER_KEYS) {
+ const arr = sourceMap[key];
+ if (!Array.isArray(arr)) continue;
+ for (const path of arr) {
+ if (!Array.isArray(path) || !path.length) continue;
+ if (path.every((tuple) => !insideRect(tupleWorldX(world, tuple), tupleWorldY(world, tuple), userRect))) preserved++;
+ }
+ }
+ return preserved;
+}
+
+function clipPathToOutsideAndAnchors(world, path, rects, mode, layerKey) {
+ const outsideParts = [];
+ const anchors = [];
+ let current = [];
+ let removedInside = 0;
+ let lastOutside = null;
+ let lastInside = null;
+
+ for (const tuple of path || []) {
+ const wx = tupleWorldX(world, tuple);
+ const wy = tupleWorldY(world, tuple);
+ const p = { x: Math.round(wx), y: Math.round(wy) };
+ const inUser = insideRect(p.x, p.y, rects.userRect);
+ if (!inUser) {
+ if (lastInside) anchors.push({ ...p, kind: mode, layerKey, source: "boundary" });
+ current.push(tuple);
+ lastOutside = p;
+ } else {
+ removedInside++;
+ if (lastOutside) anchors.push({ ...lastOutside, kind: mode, layerKey, source: "boundary" });
+ if (current.length >= 2) outsideParts.push(current);
+ current = [];
+ lastInside = p;
+ }
+ }
+ if (current.length >= 2) outsideParts.push(current);
+
+ const unique = [];
+ const seen = new Set();
+ for (const anchor of anchors) {
+ const land = nearestLand(world, anchor.x, anchor.y, rects.userRect, 10);
+ if (!land) continue;
+ const key = `${land.x},${land.y},${mode}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ unique.push({ ...anchor, x: land.x, y: land.y });
+ }
+
+ return { outsideParts, anchors: unique, removedInside };
+}
+
+function pruneLinearLayers(sourceMap, world, rects) {
+ let removed = 0;
+ const roadAnchors = [];
+ const railAnchors = [];
+ for (const key of PATH_LAYER_KEYS) {
+ const arr = sourceMap[key];
+ if (!Array.isArray(arr)) continue;
+ const kept = [];
+ for (const path of arr) {
+ const touchesUser = (path || []).some((tuple) => insideRect(tupleWorldX(world, tuple), tupleWorldY(world, tuple), rects.userRect));
+ if (!touchesUser) {
+ kept.push(path);
+ continue;
+ }
+ const mode = key.includes("Rail") || key.includes("rail") ? "rail" : "road";
+ const clipped = clipPathToOutsideAndAnchors(world, path, rects, mode, key);
+ kept.push(...clipped.outsideParts);
+ if (mode === "rail") railAnchors.push(...clipped.anchors);
+ else roadAnchors.push(...clipped.anchors);
+ removed++;
+ }
+ sourceMap[key] = kept;
+ }
+ for (const key of SEGMENT_LAYER_KEYS) {
+ const arr = sourceMap[key];
+ if (!Array.isArray(arr)) continue;
+ const kept = [];
+ for (const seg of arr) {
+ if (segmentTouchesRect(world, seg, rects.writeRect || rects.userRect)) removed++;
+ else kept.push(seg);
+ }
+ sourceMap[key] = kept;
+ }
+ return { removedLines: removed, roadAnchors, railAnchors };
+}
+
+function resetHumanFields(world, rect) {
+ for (const key of FLOAT_FIELD_KEYS) {
+ const field = ensureField(world, key, Float32Array, 0);
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i >= 0) field[i] = 0;
+ }
+ }
+ }
+ const landuse = ensureField(world, "landuse", Uint8Array, LANDUSE.RURAL);
+ const adminId = ensureField(world, "adminId", Int32Array, -1);
+ const municipalityId = ensureField(world, "municipalityId", Int32Array, -1);
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ landuse[i] = world.fields.sea?.[i] ? LANDUSE.RURAL : ((world.fields.slope?.[i] || 0) > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL);
+ adminId[i] = -1;
+ municipalityId[i] = -1;
+ }
+ }
+}
+
+function snapshotFields(world, rect, keys) {
+ const width = rectWidth(rect);
+ const out = { rect: { ...rect }, width, fields: {} };
+ for (const key of keys) {
+ const field = world.fields[key];
+ if (!field) continue;
+ const Constructor = field.constructor;
+ const copy = new Constructor(width * rectHeight(rect));
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const wi = worldIndex(world, x, y);
+ const si = (y - rect.y0) * width + (x - rect.x0);
+ if (wi >= 0) copy[si] = field[wi];
+ }
+ }
+ out.fields[key] = copy;
+ }
+ return out;
+}
+
+function snapshotValue(snapshot, key, x, y) {
+ const field = snapshot?.fields?.[key];
+ const rect = snapshot?.rect;
+ if (!field || !insideRect(x, y, rect)) return undefined;
+ return field[(y - rect.y0) * snapshot.width + (x - rect.x0)];
+}
+
+function blendWeightFromRects(x, y, rects) {
+ if (insideRect(x, y, rects.blendRect)) return 1;
+ if (!insideRect(x, y, rects.repairRect)) return 0;
+ const band = Math.max(1, Math.min(
+ rects.blendRect.x0 - rects.repairRect.x0,
+ rects.blendRect.y0 - rects.repairRect.y0,
+ rects.repairRect.x1 - rects.blendRect.x1,
+ rects.repairRect.y1 - rects.blendRect.y1,
+ ));
+ return clamp(distanceToRectEdge(x, y, rects.repairRect) / band);
+}
+
+function reconcileTransitionFields(world, rects, snapshot) {
+ let landUseCellsUpdated = 0;
+ const fields = ["settlementScore", "populationDensity", "stationInfluence", "roadInfluence", "railInfluence2", "villageInfluence"];
+ for (let y = rects.repairRect.y0; y < rects.repairRect.y1; y++) {
+ for (let x = rects.repairRect.x0; x < rects.repairRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ const w = blendWeightFromRects(x, y, rects);
+ for (const key of fields) {
+ const oldValue = snapshotValue(snapshot, key, x, y);
+ if (oldValue === undefined || !world.fields[key]) continue;
+ world.fields[key][i] = oldValue * (1 - w) + world.fields[key][i] * w;
+ }
+ const oldLanduse = snapshotValue(snapshot, "landuse", x, y);
+ if (oldLanduse !== undefined && world.fields.landuse && w < 0.48) world.fields.landuse[i] = oldLanduse;
+ if (world.fields.landuse) landUseCellsUpdated++;
+ const oldAdmin = snapshotValue(snapshot, "adminId", x, y);
+ const oldMunicipality = snapshotValue(snapshot, "municipalityId", x, y);
+ if (oldAdmin !== undefined && world.fields.adminId && w < 0.35) world.fields.adminId[i] = oldAdmin;
+ if (oldMunicipality !== undefined && world.fields.municipalityId && w < 0.35) world.fields.municipalityId[i] = oldMunicipality;
+ }
+ }
+ return landUseCellsUpdated;
+}
+
+function sourcePoint(world, p) {
+ const src = worldToSourcePoint(world, p.x, p.y);
+ return { ...p, x: src.x, y: src.y, worldX: p.x, worldY: p.y, insidePrefecture: true, patchGenerated: true };
+}
+
+function sourcePath(world, path) {
+ return path.map(([x, y]) => [x - world.originX, y - world.originY]);
+}
+
+function addInfluence(world, point, radius, amount, fields, clipRect = null) {
+ const r = Math.max(1, Math.floor(radius));
+ for (let y = Math.max(0, point.y - r); y <= Math.min(world.height - 1, point.y + r); y++) {
+ for (let x = Math.max(0, point.x - r); x <= Math.min(world.width - 1, point.x + r); x++) {
+ const d = Math.hypot(x - point.x, y - point.y);
+ if (d > r) continue;
+ if (clipRect && !insideRect(x, y, clipRect)) continue;
+ const i = worldIndex(world, x, y);
+ if (i < 0 || world.fields.sea?.[i]) continue;
+ const w = (1 - d / r) ** 2 * amount;
+ for (const [key, mult] of fields) {
+ const field = ensureField(world, key, Float32Array, 0);
+ field[i] = clamp(field[i] + w * mult, 0, 1.8);
+ }
+ }
+ }
+}
+
+function setLanduseAround(world, point, radius, landuseCode, strength = 1, clipRect = null) {
+ const landuse = ensureField(world, "landuse", Uint8Array, LANDUSE.RURAL);
+ const r = Math.max(1, Math.floor(radius));
+ for (let y = Math.max(0, point.y - r); y <= Math.min(world.height - 1, point.y + r); y++) {
+ for (let x = Math.max(0, point.x - r); x <= Math.min(world.width - 1, point.x + r); x++) {
+ const d = Math.hypot(x - point.x, y - point.y);
+ if (d > r) continue;
+ if (clipRect && !insideRect(x, y, clipRect)) continue;
+ const i = worldIndex(world, x, y);
+ if (i < 0 || world.fields.sea?.[i]) continue;
+ const p = 1 - d / r;
+ if (p * strength < 0.22) continue;
+ if (landuseCode > landuse[i] || p > 0.62) landuse[i] = landuseCode;
+ }
+ }
+}
+
+function assignFarmlandAndForest(world, rect) {
+ const landuse = ensureField(world, "landuse", Uint8Array, LANDUSE.RURAL);
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || world.fields.sea?.[i]) continue;
+ if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) continue;
+ const slope = world.fields.slope?.[i] || 0;
+ const agriculture = world.fields.agriculture?.[i] || 0;
+ const plain = world.fields.plain?.[i] || 0;
+ if (slope > 0.42 || (world.fields.elevation?.[i] || 0) > 0.56) landuse[i] = LANDUSE.FOREST;
+ else if (agriculture > 0.28 || plain > 0.42) landuse[i] = LANDUSE.FARMLAND;
+ else landuse[i] = LANDUSE.RURAL;
+ }
+ }
+}
+
+function nextAdminId(sourceMap, world) {
+ let maxId = -1;
+ const field = world.fields.adminId;
+ if (field) {
+ for (let i = 0; i < field.length; i++) if (field[i] > maxId) maxId = field[i];
+ }
+ for (let i = 0; i < (sourceMap.adminCenters || []).length; i++) if (sourceMap.adminCenters[i]) maxId = Math.max(maxId, i);
+ return maxId + 1;
+}
+
+function assignLocalAdmin(world, sourceMap, rect, centers, seed, usedNames) {
+ const adminIdField = ensureField(world, "adminId", Int32Array, -1);
+ const municipalityField = ensureField(world, "municipalityId", Int32Array, -1);
+ const adminCenters = ensureSourceArray(sourceMap, "adminCenters");
+ let id = nextAdminId(sourceMap, world);
+ const centerRecords = [];
+
+ for (const center of centers) {
+ const population = center.population || 3500 + Math.round(seeded(seed, center.x, center.y, 221) * 21000 / 1000) * 1000;
+ const base = {
+ x: center.x,
+ y: center.y,
+ kind: "Municipal Center",
+ population,
+ municipalityPopulation: Math.max(population, Math.round(population * (1.8 + seeded(seed, center.x, center.y, 229) * 3.2))),
+ };
+ const name = center.name || makeName(seed, rect, center.x, center.y, { ...base, kind: "Municipal Center" }, usedNames, 230 + id);
+ const record = sourcePoint(world, { ...base, id, name, municipalityId: id, adminId: id, labelPriorityBase: 420 + Math.sqrt(population) });
+ adminCenters[id] = record;
+ centerRecords.push({ ...center, id, name, population: record.population, municipalityPopulation: record.municipalityPopulation });
+ id++;
+ }
+
+ if (!centerRecords.length) return centerRecords;
+
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || world.fields.sea?.[i]) continue;
+ let best = centerRecords[0];
+ let bestD = Infinity;
+ for (const c of centerRecords) {
+ const d = Math.hypot(x - c.x, y - c.y) * (1 + (world.fields.slope?.[i] || 0) * 1.8) - seeded(seed, x, y, c.id) * 2.2;
+ if (d < bestD) {
+ bestD = d;
+ best = c;
+ }
+ }
+ adminIdField[i] = best.id;
+ municipalityField[i] = best.id;
+ }
+ }
+ return centerRecords;
+}
+
+function collectExistingAdminCenters(world, sourceMap, rect) {
+ const centers = [];
+ for (const p of sourceMap.adminCenters || []) {
+ if (!p || p.patchHidden) continue;
+ const x = Math.round(pointWorldX(world, p));
+ const y = Math.round(pointWorldY(world, p));
+ if (insideRect(x, y, rect)) continue;
+ const id = Number.isFinite(p.adminId) ? p.adminId : Number.isFinite(p.municipalityId) ? p.municipalityId : Number.isFinite(p.id) ? p.id : null;
+ if (id === null || id < 0) continue;
+ const distance = Math.max(rect.x0 - x, x - (rect.x1 - 1), rect.y0 - y, y - (rect.y1 - 1), 0);
+ if (distance <= 48) centers.push({ x, y, id, name: p.name, population: p.population || p.municipalityPopulation || 3000, external: true });
+ }
+ return centers;
+}
+
+function reassignAdminRepair(world, sourceMap, rects, localCenters, seed) {
+ const adminIdField = ensureField(world, "adminId", Int32Array, -1);
+ const municipalityField = ensureField(world, "municipalityId", Int32Array, -1);
+ const externalCenters = collectExistingAdminCenters(world, sourceMap, rects.userRect);
+ const centers = [...externalCenters, ...(localCenters || [])].filter((p) => Number.isFinite(p?.id));
+ if (!centers.length) return { adminCellsReassigned: 0, adminBoundarySmoothed: 0 };
+
+ let reassigned = 0;
+ for (let y = rects.repairRect.y0; y < rects.repairRect.y1; y++) {
+ for (let x = rects.repairRect.x0; x < rects.repairRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || world.fields.sea?.[i]) continue;
+ let best = null;
+ let bestScore = Infinity;
+ for (const c of centers) {
+ const d = Math.hypot(x - c.x, y - c.y);
+ const externalBias = c.external ? -4.5 * (1 - blendWeightFromRects(x, y, rects)) : 0;
+ const score = d * (1 + (world.fields.slope?.[i] || 0) * 1.25) + externalBias - seeded(seed, x, y, c.id + 1300) * 1.5;
+ if (score < bestScore) {
+ bestScore = score;
+ best = c;
+ }
+ }
+ if (best && adminIdField[i] !== best.id) {
+ adminIdField[i] = best.id;
+ municipalityField[i] = best.id;
+ reassigned++;
+ }
+ }
+ }
+
+ let smoothed = 0;
+ for (let pass = 0; pass < 2; pass++) {
+ const changes = [];
+ for (let y = rects.repairRect.y0 + 1; y < rects.repairRect.y1 - 1; y++) {
+ for (let x = rects.repairRect.x0 + 1; x < rects.repairRect.x1 - 1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || world.fields.sea?.[i] || blendWeightFromRects(x, y, rects) < 0.2) continue;
+ const counts = new Map();
+ for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
+ const ni = worldIndex(world, x + dx, y + dy);
+ const id = ni >= 0 ? adminIdField[ni] : -1;
+ if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
+ }
+ const current = adminIdField[i];
+ const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
+ if (best && best[0] !== current && best[1] >= 3) changes.push([i, best[0]]);
+ }
+ }
+ for (const [i, id] of changes) {
+ adminIdField[i] = id;
+ municipalityField[i] = id;
+ smoothed++;
+ }
+ }
+ return { adminCellsReassigned: reassigned, adminBoundarySmoothed: smoothed };
+}
+
+function buildAdminBorders(world, rect) {
+ const adminId = world.fields.adminId;
+ const sea = world.fields.sea;
+ if (!adminId) return [];
+ const segments = [];
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || sea?.[i]) continue;
+ const id = adminId[i];
+ if (id < 0) continue;
+ const east = worldIndex(world, x + 1, y);
+ if (x + 1 < rect.x1 && east >= 0 && !sea?.[east] && adminId[east] >= 0 && adminId[east] !== id) {
+ segments.push(sourcePath(world, [[x + 1, y], [x + 1, y + 1]]));
+ }
+ const south = worldIndex(world, x, y + 1);
+ if (y + 1 < rect.y1 && south >= 0 && !sea?.[south] && adminId[south] >= 0 && adminId[south] !== id) {
+ segments.push(sourcePath(world, [[x, y + 1], [x + 1, y + 1]]));
+ }
+ }
+ }
+ return segments;
+}
+
+function clampToRect(x, y, rect) {
+ return {
+ x: Math.max(rect.x0, Math.min(rect.x1 - 1, Math.round(x))),
+ y: Math.max(rect.y0, Math.min(rect.y1 - 1, Math.round(y))),
+ };
+}
+
+function nearestLand(world, x, y, rect, maxRadius = 10) {
+ const start = clampToRect(x, y, rect);
+ if (isLand(world, start.x, start.y)) return start;
+ for (let r = 1; r <= maxRadius; r++) {
+ let best = null;
+ let bestD = Infinity;
+ for (let dy = -r; dy <= r; dy++) {
+ for (let dx = -r; dx <= r; dx++) {
+ if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
+ const p = clampToRect(start.x + dx, start.y + dy, rect);
+ if (!isLand(world, p.x, p.y)) continue;
+ const d = Math.hypot(p.x - x, p.y - y);
+ if (d < bestD) {
+ best = p;
+ bestD = d;
+ }
+ }
+ }
+ if (best) return best;
+ }
+ return null;
+}
+
+function findPath(world, startInput, endInput, rect, options = {}) {
+ const margin = options.margin ?? 8;
+ const searchRect = expandRect(rect, margin, world);
+ const start = nearestLand(world, startInput.x, startInput.y, searchRect, 12);
+ const end = nearestLand(world, endInput.x, endInput.y, searchRect, 12);
+ if (!start || !end) return [];
+ if (start.x === end.x && start.y === end.y) return [[start.x, start.y]];
+
+ const w = rectWidth(searchRect);
+ const h = rectHeight(searchRect);
+ const localIndex = (x, y) => (y - searchRect.y0) * w + (x - searchRect.x0);
+ const total = w * h;
+ const g = new Float32Array(total);
+ g.fill(Infinity);
+ const prev = new Int32Array(total);
+ prev.fill(-1);
+ const startIdx = localIndex(start.x, start.y);
+ const endIdx = localIndex(end.x, end.y);
+ const open = new MinHeap();
+ g[startIdx] = 0;
+ open.push({ i: startIdx, x: start.x, y: start.y, f: Math.hypot(end.x - start.x, end.y - start.y) });
+ const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
+ let iterations = 0;
+ const maxIterations = Math.min(total * 8, 90000);
+
+ while (open.length && iterations++ < maxIterations) {
+ const current = open.pop();
+ if (!current) break;
+ if (current.i === endIdx) break;
+ if (current.f > g[current.i] + Math.hypot(end.x - current.x, end.y - current.y) * 1.45 + 100) continue;
+
+ for (const [dx, dy] of dirs) {
+ const nx = current.x + dx;
+ const ny = current.y + dy;
+ if (nx < searchRect.x0 || ny < searchRect.y0 || nx >= searchRect.x1 || ny >= searchRect.y1) continue;
+ const wi = worldIndex(world, nx, ny);
+ if (wi < 0 || world.fields.sea?.[wi]) continue;
+ const ni = localIndex(nx, ny);
+ const step = Math.hypot(dx, dy);
+ const slope = world.fields.slope?.[wi] || 0;
+ const river = world.fields.river?.[wi] || 0;
+ const plain = world.fields.plain?.[wi] || 0;
+ const roadEase = options.rail ? Math.max(0, slope - 0.10) * 9.5 : slope * 4.5;
+ const cost = step * (1 + roadEase - plain * 0.18 - river * 0.07);
+ const ng = g[current.i] + Math.max(0.2, cost);
+ if (ng >= g[ni]) continue;
+ g[ni] = ng;
+ prev[ni] = current.i;
+ const heuristic = Math.hypot(end.x - nx, end.y - ny) * (options.rail ? 1.20 : 1.05);
+ open.push({ i: ni, x: nx, y: ny, f: ng + heuristic });
+ }
+ }
+
+ if (!Number.isFinite(g[endIdx])) return straightFallback(world, start, end, searchRect);
+ const path = [];
+ let cursor = endIdx;
+ for (let guard = 0; cursor >= 0 && guard < total; guard++) {
+ const lx = cursor % w;
+ const ly = Math.floor(cursor / w);
+ path.push([searchRect.x0 + lx, searchRect.y0 + ly]);
+ if (cursor === startIdx) break;
+ cursor = prev[cursor];
+ }
+ path.reverse();
+ return simplifyPath(path);
+}
+
+function straightFallback(world, start, end, rect) {
+ const steps = Math.max(2, Math.ceil(Math.hypot(end.x - start.x, end.y - start.y)));
+ const path = [];
+ for (let k = 0; k <= steps; k++) {
+ const t = k / steps;
+ const p = nearestLand(world, start.x + (end.x - start.x) * t, start.y + (end.y - start.y) * t, rect, 5);
+ if (!p) continue;
+ if (!path.length || path[path.length - 1][0] !== p.x || path[path.length - 1][1] !== p.y) path.push([p.x, p.y]);
+ }
+ return simplifyPath(path);
+}
+
+function simplifyPath(path) {
+ if (!Array.isArray(path) || path.length <= 2) return path || [];
+ const out = [path[0]];
+ let lastDx = null;
+ let lastDy = null;
+ for (let i = 1; i < path.length - 1; i++) {
+ const prev = out[out.length - 1];
+ const cur = path[i];
+ const next = path[i + 1];
+ const dx1 = Math.sign(cur[0] - prev[0]);
+ const dy1 = Math.sign(cur[1] - prev[1]);
+ const dx2 = Math.sign(next[0] - cur[0]);
+ const dy2 = Math.sign(next[1] - cur[1]);
+ if (dx1 !== dx2 || dy1 !== dy2 || i % 8 === 0) out.push(cur);
+ lastDx = dx1;
+ lastDy = dy1;
+ }
+ out.push(path[path.length - 1]);
+ return out;
+}
+
+function writePathInfluence(world, path, key, radius, amount, clipRect = null) {
+ const field = ensureField(world, key, Float32Array, 0);
+ const r = Math.max(1, radius | 0);
+ for (const [px, py] of path || []) {
+ for (let y = Math.max(0, py - r); y <= Math.min(world.height - 1, py + r); y++) {
+ for (let x = Math.max(0, px - r); x <= Math.min(world.width - 1, px + r); x++) {
+ const d = Math.hypot(x - px, y - py);
+ if (d > r) continue;
+ if (clipRect && !insideRect(x, y, clipRect)) continue;
+ const i = worldIndex(world, x, y);
+ if (i < 0 || world.fields.sea?.[i]) continue;
+ field[i] = clamp(field[i] + (1 - d / r) * amount, 0, 1.6);
+ }
+ }
+ }
+}
+
+function chooseLocalCounts(rect, landCount, coastCount) {
+ const area = rectArea(rect);
+ const landArea = Math.max(0, landCount);
+ const scale = Math.sqrt(Math.max(1, area) / 3000);
+ const landScale = Math.sqrt(Math.max(1, landArea) / 3000);
+ return {
+ admin: Math.max(1, Math.min(10, Math.round(1 + landScale * 2.2))),
+ modern: Math.max(0, Math.min(7, Math.round(landScale * 1.35))),
+ markets: Math.max(1, Math.min(10, Math.round(landScale * 2.0))),
+ villages: Math.max(3, Math.min(22, Math.round(landScale * 5.2))),
+ ports: Math.max(0, Math.min(6, Math.round(Math.sqrt(Math.max(0, coastCount)) / 8))),
+ castles: Math.max(0, Math.min(4, Math.round(scale * 0.8))),
+ industrial: Math.max(0, Math.min(4, Math.round(landScale * 0.65))),
+ logistics: Math.max(0, Math.min(4, Math.round(landScale * 0.70))),
+ newTowns: Math.max(0, Math.min(4, Math.round(landScale * 0.55))),
+ };
+}
+
+function countLandAndCoast(world, rect) {
+ let land = 0;
+ let coast = 0;
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ if (!isLand(world, x, y)) continue;
+ land++;
+ if (seaNeighbors(world, x, y, 1) > 0) coast++;
+ }
+ }
+ return { land, coast };
+}
+
+function topN(points, n) {
+ return [...points].sort((a, b) => (b.score || 0) - (a.score || 0)).slice(0, Math.max(0, n));
+}
+
+function buildSettlementLayers(world, sourceMap, rect, seed, usedNames) {
+ const landStats = countLandAndCoast(world, rect);
+ if (landStats.land < 24) return { counts: {}, centers: [] };
+ const counts = chooseLocalCounts(rect, landStats.land, landStats.coast);
+ const landCandidates = collectLandCandidates(world, rect, seed, rectArea(rect) > 16000 ? 4 : 3);
+ const portCandidates = collectPortCandidates(world, rect, seed);
+
+ const ports = pickEntities(portCandidates, { max: counts.ports, minDistance: 18, threshold: 0.55, seed: seed + 110, jitter: 0.08 }).map((p, idx) => {
+ const portClass = idx === 0 && p.score > 1.05 ? "regional" : "fishing";
+ const pop = portClass === "regional" ? 9000 + Math.round(seeded(seed, p.x, p.y, 301) * 22000 / 1000) * 1000 : 1600 + Math.round(seeded(seed, p.x, p.y, 302) * 5200 / 100) * 100;
+ const point = { ...p, kind: portClass === "regional" ? "Regional Port" : "Fishing Port", portClass, population: pop, labelPriorityBase: portClass === "regional" ? 360 : 160 };
+ return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 300 + idx) });
+ });
+
+ const citySeeds = pickEntities(landCandidates, { max: counts.modern, minDistance: 24, threshold: 0.50, seed: seed + 120, jitter: 0.12 });
+ const modernCities = citySeeds.map((p, idx) => {
+ const rank = idx === 0 && rectArea(rect) > 9000 ? "Regional City" : "Local City";
+ const popBase = rank === "Regional City" ? 52000 : 18000;
+ const popSpan = rank === "Regional City" ? 140000 : 52000;
+ const population = popBase + Math.round(seeded(seed, p.x, p.y, 401) * popSpan / 1000) * 1000;
+ const point = { ...p, kind: rank, rank, population, labelPriorityBase: rank === "Regional City" ? 760 : 520, isRegionalCapital: rank === "Regional City" && idx === 0 };
+ return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 400 + idx) });
+ });
+
+ const marketSeeds = pickEntities(landCandidates, { max: counts.markets, minDistance: 14, threshold: 0.42, seed: seed + 130, jitter: 0.10 })
+ .filter((p) => citySeeds.every((c) => Math.hypot(p.x - c.x, p.y - c.y) >= 9));
+ const markets = marketSeeds.map((p, idx) => {
+ const population = 3000 + Math.round(seeded(seed, p.x, p.y, 501) * 13000 / 500) * 500;
+ const point = { ...p, kind: "Market Town", population, labelPriorityBase: 230 };
+ return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 500 + idx) });
+ });
+
+ const villageSeeds = pickEntities(landCandidates, { max: counts.villages, minDistance: 8, threshold: 0.31, seed: seed + 140, jitter: 0.12 })
+ .filter((p) => [...citySeeds, ...marketSeeds].every((c) => Math.hypot(p.x - c.x, p.y - c.y) >= 6));
+ const villages = villageSeeds.map((p, idx) => {
+ const population = 700 + Math.round(seeded(seed, p.x, p.y, 601) * 5200 / 100) * 100;
+ const point = { ...p, kind: "Village", population, labelPriorityBase: 80 };
+ return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 600 + idx) });
+ });
+
+ const castleSeeds = pickEntities(landCandidates.map((p) => ({ ...p, score: p.score + (world.fields.elevation?.[worldIndex(world, p.x, p.y)] || 0) * 0.4 })), { max: counts.castles, minDistance: 20, threshold: 0.52, seed: seed + 150, jitter: 0.15 });
+ const castles = castleSeeds.map((p, idx) => {
+ const point = { ...p, kind: "Castle", population: 0, labelPriorityBase: 190 };
+ return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 700 + idx) });
+ });
+
+ for (const p of ports) ensureSourceArray(sourceMap, "ports").push(p);
+ for (const p of modernCities) ensureSourceArray(sourceMap, "modernCities").push(p);
+ for (const p of markets) ensureSourceArray(sourceMap, "markets").push(p);
+ for (const p of villages) ensureSourceArray(sourceMap, "villages").push(p);
+ for (const p of castles) ensureSourceArray(sourceMap, "castles").push(p);
+
+ const centers = topN([
+ ...modernCities.map((p) => ({ x: p.worldX, y: p.worldY, name: p.name, population: p.population, score: 2 + (p.population || 0) / 70000 })),
+ ...markets.map((p) => ({ x: p.worldX, y: p.worldY, name: p.name, population: p.population, score: 1 + (p.population || 0) / 28000 })),
+ ...ports.map((p) => ({ x: p.worldX, y: p.worldY, name: p.name, population: p.population, score: 0.8 + (p.population || 0) / 26000 })),
+ ...villages.map((p) => ({ x: p.worldX, y: p.worldY, name: p.name, population: p.population, score: 0.4 + (p.population || 0) / 16000 })),
+ ], counts.admin);
+
+ return {
+ counts: {
+ ports: ports.length,
+ modernCities: modernCities.length,
+ markets: markets.length,
+ villages: villages.length,
+ castles: castles.length,
+ },
+ centers,
+ localPoints: { ports, modernCities, markets, villages, castles },
+ };
+}
+
+function buildTransportLayers(world, sourceMap, rect, seed, localPoints, connectors = {}) {
+ const cities = (localPoints.modernCities || []).map((p) => ({ ...p, x: p.worldX, y: p.worldY }));
+ const ports = (localPoints.ports || []).map((p) => ({ ...p, x: p.worldX, y: p.worldY }));
+ const markets = (localPoints.markets || []).map((p) => ({ ...p, x: p.worldX, y: p.worldY }));
+ const villages = (localPoints.villages || []).map((p) => ({ ...p, x: p.worldX, y: p.worldY }));
+ const roadAnchors = (connectors.roadAnchors || []).map((p) => ({ ...p, score: 1.3 }));
+ const railAnchors = (connectors.railAnchors || []).map((p) => ({ ...p, score: 1.2 }));
+ const trunkNodes = topN([...cities, ...ports, ...markets], Math.min(7, Math.max(2, cities.length + ports.length + 1)));
+ let nationalRoads = 0;
+ let minorRoads = 0;
+ let railways = 0;
+ let stations = 0;
+ let roadConnectorsCreated = 0;
+ let railwayConnectorsCreated = 0;
+
+ for (let i = 1; i < trunkNodes.length; i++) {
+ const target = trunkNodes[i];
+ const previous = trunkNodes.slice(0, i).sort((a, b) => Math.hypot(a.x - target.x, a.y - target.y) - Math.hypot(b.x - target.x, b.y - target.y))[0];
+ const path = findPath(world, previous, target, rect, { margin: 10 });
+ if (path.length >= 3) {
+ ensureSourceArray(sourceMap, "nationalRoads").push(sourcePath(world, path));
+ writePathInfluence(world, path, "roadInfluence", 2, 0.36);
+ nationalRoads++;
+ }
+ }
+
+ const connectorTargets = trunkNodes.length ? trunkNodes : topN([...cities, ...ports, ...markets, ...villages], 4);
+ for (const anchor of roadAnchors.slice(0, 10)) {
+ if (!connectorTargets.length) break;
+ const nearest = connectorTargets
+ .filter((node) => Math.hypot(anchor.x - node.x, anchor.y - node.y) >= 4)
+ .sort((a, b) => Math.hypot(anchor.x - a.x, anchor.y - a.y) - Math.hypot(anchor.x - b.x, anchor.y - b.y))[0];
+ if (!nearest) continue;
+ const path = findPath(world, anchor, nearest, rect, { margin: 4 });
+ if (path.length >= 3) {
+ ensureSourceArray(sourceMap, "nationalRoads").push(sourcePath(world, path));
+ writePathInfluence(world, path, "roadInfluence", 2, 0.34);
+ nationalRoads++;
+ roadConnectorsCreated++;
+ }
+ }
+
+ for (const node of [...markets, ...villages]) {
+ const anchors = trunkNodes.length ? trunkNodes : cities;
+ if (!anchors.length) continue;
+ const nearest = anchors.sort((a, b) => Math.hypot(a.x - node.x, a.y - node.y) - Math.hypot(b.x - node.x, b.y - node.y))[0];
+ if (!nearest || Math.hypot(nearest.x - node.x, nearest.y - node.y) < 3) continue;
+ const path = findPath(world, node, nearest, rect, { margin: 8 });
+ if (path.length >= 3) {
+ ensureSourceArray(sourceMap, "minorRoads").push(sourcePath(world, path));
+ writePathInfluence(world, path, "roadInfluence", 1, 0.16);
+ minorRoads++;
+ }
+ }
+
+ const railNodes = topN([...cities, ...ports], Math.min(4, cities.length + ports.length));
+ for (let i = 1; i < railNodes.length; i++) {
+ const path = findPath(world, railNodes[i - 1], railNodes[i], rect, { margin: 12, rail: true });
+ if (path.length >= 6) {
+ ensureSourceArray(sourceMap, i === 1 ? "railways" : "branchRailways").push(sourcePath(world, path));
+ writePathInfluence(world, path, "railInfluence2", 2, 0.42);
+ railways++;
+ for (let k = 0; k < path.length; k += 14) {
+ const [x, y] = path[k];
+ if (!insideRect(x, y, rect) || !isLand(world, x, y)) continue;
+ const point = sourcePoint(world, { x, y, kind: k === 0 || k >= path.length - 14 ? "Major Station" : "Station", name: `Station ${Math.max(1, stations + 1)}`, population: 0, labelPriorityBase: 120 });
+ ensureSourceArray(sourceMap, "stations").push(point);
+ addInfluence(world, { x, y }, 5, 0.38, [["stationInfluence", 1.0], ["populationDensity", 0.25]]);
+ stations++;
+ }
+ }
+ }
+
+ const railTargets = railNodes.length ? railNodes : topN([...cities, ...ports], 3);
+ for (const anchor of railAnchors.slice(0, 6)) {
+ if (!railTargets.length) break;
+ const nearest = railTargets
+ .filter((node) => Math.hypot(anchor.x - node.x, anchor.y - node.y) >= 8)
+ .sort((a, b) => Math.hypot(anchor.x - a.x, anchor.y - a.y) - Math.hypot(anchor.x - b.x, anchor.y - b.y))[0];
+ if (!nearest) continue;
+ const path = findPath(world, anchor, nearest, rect, { margin: 6, rail: true });
+ if (path.length >= 6) {
+ ensureSourceArray(sourceMap, "branchRailways").push(sourcePath(world, path));
+ writePathInfluence(world, path, "railInfluence2", 2, 0.38);
+ railways++;
+ railwayConnectorsCreated++;
+ }
+ }
+
+ return { nationalRoads, minorRoads, railways, stations, roadConnectorsCreated, railwayConnectorsCreated };
+}
+
+function buildDevelopmentLayers(world, sourceMap, rect, seed, localPoints, usedNames) {
+ const bases = [
+ ...(localPoints.modernCities || []).map((p) => ({ x: p.worldX, y: p.worldY, score: 1.4, name: p.name })),
+ ...(localPoints.ports || []).map((p) => ({ x: p.worldX, y: p.worldY, score: 1.1, name: p.name })),
+ ...(localPoints.markets || []).map((p) => ({ x: p.worldX, y: p.worldY, score: 0.8, name: p.name })),
+ ];
+ const landCandidates = collectLandCandidates(world, rect, seed + 900, 4)
+ .map((p) => ({ ...p, score: p.score + (world.fields.roadInfluence?.[worldIndex(world, p.x, p.y)] || 0) * 0.9 + (world.fields.railInfluence2?.[worldIndex(world, p.x, p.y)] || 0) * 0.8 }));
+ const counts = chooseLocalCounts(rect, countLandAndCoast(world, rect).land, countLandAndCoast(world, rect).coast);
+ const industrialSeeds = pickEntities(landCandidates, { max: counts.industrial, minDistance: 18, threshold: 0.52, seed: seed + 910, jitter: 0.12 });
+ const logisticsSeeds = pickEntities(landCandidates, { max: counts.logistics, minDistance: 16, threshold: 0.50, seed: seed + 920, jitter: 0.12 });
+ const newTownSeeds = pickEntities(landCandidates, { max: counts.newTowns, minDistance: 18, threshold: 0.46, seed: seed + 930, jitter: 0.12 });
+
+ const industrialZones = industrialSeeds.map((p, idx) => {
+ const point = { ...p, kind: "Industrial Zone", population: 0, labelPriorityBase: 90 };
+ return sourcePoint(world, { ...point, name: `${makeName(seed, rect, p.x, p.y, point, usedNames, 910 + idx)} Industrial` });
+ });
+ const logisticsParks = logisticsSeeds.map((p, idx) => {
+ const point = { ...p, kind: "Logistics Park", population: 0, labelPriorityBase: 80 };
+ return sourcePoint(world, { ...point, name: null, facilityLabel: "Logistics Park", labelStyle: "facility", suppressSettlementLabel: true });
+ });
+ const newTowns = newTownSeeds.map((p, idx) => {
+ const population = 6000 + Math.round(seeded(seed, p.x, p.y, 931) * 26000 / 1000) * 1000;
+ const point = { ...p, kind: "New Town", population, labelPriorityBase: 180 };
+ return sourcePoint(world, { ...point, name: `${makeName(seed, rect, p.x, p.y, point, usedNames, 930 + idx)} New Town` });
+ });
+
+ for (const p of industrialZones) {
+ ensureSourceArray(sourceMap, "industrialZones").push(p);
+ setLanduseAround(world, { x: p.worldX, y: p.worldY }, 5, LANDUSE.INDUSTRIAL, 1.0);
+ addInfluence(world, { x: p.worldX, y: p.worldY }, 6, 0.25, [["populationDensity", 0.2]]);
+ }
+ for (const p of logisticsParks) {
+ ensureSourceArray(sourceMap, "logisticsParks").push(p);
+ setLanduseAround(world, { x: p.worldX, y: p.worldY }, 4, LANDUSE.LOGISTICS, 1.0);
+ }
+ for (const p of newTowns) {
+ ensureSourceArray(sourceMap, "newTowns").push(p);
+ setLanduseAround(world, { x: p.worldX, y: p.worldY }, 6, LANDUSE.NEW_TOWN, 1.0);
+ addInfluence(world, { x: p.worldX, y: p.worldY }, 8, 0.42, [["populationDensity", 1.0], ["settlementScore", 0.5]]);
+ }
+
+ return { industrialZones: industrialZones.length, logisticsParks: logisticsParks.length, newTowns: newTowns.length };
+}
+
+function applySettlementInfluence(world, localPoints) {
+ for (const p of localPoints.modernCities || []) {
+ const wp = { x: p.worldX, y: p.worldY };
+ setLanduseAround(world, wp, 5, LANDUSE.CBD, 1.0);
+ setLanduseAround(world, wp, 10, LANDUSE.SUBURB, 0.74);
+ addInfluence(world, wp, 13, 0.85, [["populationDensity", 1.0], ["settlementScore", 0.9]]);
+ }
+ for (const p of localPoints.markets || []) {
+ const wp = { x: p.worldX, y: p.worldY };
+ setLanduseAround(world, wp, 4, LANDUSE.OLD_URBAN, 0.86);
+ addInfluence(world, wp, 8, 0.52, [["populationDensity", 0.7], ["settlementScore", 0.8]]);
+ }
+ for (const p of localPoints.ports || []) {
+ const wp = { x: p.worldX, y: p.worldY };
+ setLanduseAround(world, wp, p.portClass === "regional" ? 5 : 3, LANDUSE.OLD_URBAN, 0.8);
+ addInfluence(world, wp, 7, 0.44, [["populationDensity", 0.55], ["settlementScore", 0.55]]);
+ }
+ for (const p of localPoints.villages || []) {
+ const wp = { x: p.worldX, y: p.worldY };
+ setLanduseAround(world, wp, 2, LANDUSE.FARMLAND, 0.72);
+ addInfluence(world, wp, 5, 0.28, [["populationDensity", 0.35], ["settlementScore", 0.45], ["villageInfluence", 1.0]]);
+ }
+}
+
+function applyPreservedInfluence(world, sourceMap, rect) {
+ const expanded = expandRect(rect, 20, world);
+ let pointsApplied = 0;
+ const pointConfigs = [
+ ["modernCities", 13, 0.72, [["populationDensity", 1.0], ["settlementScore", 0.8]], LANDUSE.SUBURB],
+ ["satelliteCities", 10, 0.55, [["populationDensity", 0.8], ["settlementScore", 0.65]], LANDUSE.SUBURB],
+ ["markets", 8, 0.42, [["populationDensity", 0.65], ["settlementScore", 0.7]], LANDUSE.OLD_URBAN],
+ ["ports", 7, 0.40, [["populationDensity", 0.55], ["settlementScore", 0.5]], LANDUSE.OLD_URBAN],
+ ["villages", 5, 0.24, [["populationDensity", 0.32], ["settlementScore", 0.38], ["villageInfluence", 0.9]], LANDUSE.FARMLAND],
+ ["stations", 5, 0.30, [["stationInfluence", 1.0], ["populationDensity", 0.20]], LANDUSE.ROADSIDE],
+ ["industrialZones", 6, 0.22, [["populationDensity", 0.18]], LANDUSE.INDUSTRIAL],
+ ["logisticsParks", 5, 0.18, [["roadInfluence", 0.25]], LANDUSE.LOGISTICS],
+ ["newTowns", 8, 0.35, [["populationDensity", 0.85], ["settlementScore", 0.45]], LANDUSE.NEW_TOWN],
+ ];
+ for (const [key, radius, amount, fields, landuseCode] of pointConfigs) {
+ for (const p of sourceMap[key] || []) {
+ if (!p || p.patchHidden) continue;
+ const point = { x: Math.round(pointWorldX(world, p)), y: Math.round(pointWorldY(world, p)) };
+ if (!insideRect(point.x, point.y, expanded)) continue;
+ addInfluence(world, point, radius, amount, fields, rect);
+ setLanduseAround(world, point, Math.max(2, Math.floor(radius * 0.45)), landuseCode, 0.45, rect);
+ pointsApplied++;
+ }
+ }
+ return pointsApplied;
+}
+
+function applyPreservedPathInfluence(world, sourceMap, rect) {
+ let roadPaths = 0;
+ let railPaths = 0;
+ for (const key of PATH_LAYER_KEYS) {
+ const isRail = key.includes("Rail") || key.includes("rail");
+ const influenceKey = isRail ? "railInfluence2" : "roadInfluence";
+ const radius = isRail ? 2 : 2;
+ const amount = isRail ? 0.30 : 0.22;
+ for (const path of sourceMap[key] || []) {
+ const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]);
+ if (!worldPath.some(([x, y]) => insideRect(x, y, expandRect(rect, 8, world)))) continue;
+ writePathInfluence(world, worldPath, influenceKey, radius, amount, rect);
+ if (isRail) railPaths++;
+ else roadPaths++;
+ }
+ }
+ return { roadPaths, railPaths };
+}
+
+function updatePopulationSummary(sourceMap) {
+ const popArrays = ["modernCities", "markets", "villages", "ports", "satelliteCities", "newTowns"];
+ let total = 0;
+ for (const key of popArrays) {
+ for (const p of sourceMap[key] || []) total += Number.isFinite(p?.population) ? p.population : 0;
+ }
+ sourceMap.totalPopulation = Math.max(0, Math.round(total));
+}
+
+export function regenerateHumanGeographyPatch(world, rects, options = {}) {
+ if (!world?.sourceMap || !rects?.repairRect || !rects?.blendRect) {
+ return { ok: false, reason: "World/source map/patch rects are missing." };
+ }
+ const sourceMap = world.sourceMap;
+ const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world.seed || 0) ^ 0xa511e9b3) >>> 0;
+ const userRect = rects.writeRect || rects.userRect;
+ const buildRect = rects.coreRect || rects.blendRect;
+ const repairRect = rects.repairRect;
+ const usedNames = usedNameSet(sourceMap);
+ const preservedExternalEntities = pointLayerPreservedCount(sourceMap, world, userRect) + pathLayerPreservedCount(sourceMap, world, userRect);
+
+ for (const [key, fallback] of INT_FIELD_DEFAULTS.entries()) ensureField(world, key, Int32Array, fallback);
+ ensureField(world, "landuse", Uint8Array, LANDUSE.RURAL);
+ for (const key of FLOAT_FIELD_KEYS) ensureField(world, key, Float32Array, 0);
+
+ const snapshot = snapshotFields(world, repairRect, [...FLOAT_FIELD_KEYS, "landuse", "adminId", "municipalityId"]);
+ const pointPrune = prunePointLayers(sourceMap, world, rects);
+ const linePrune = pruneLinearLayers(sourceMap, world, rects);
+ resetHumanFields(world, repairRect);
+ assignFarmlandAndForest(world, repairRect);
+ const preservedInfluencePoints = applyPreservedInfluence(world, sourceMap, repairRect);
+ const preservedInfluencePaths = applyPreservedPathInfluence(world, sourceMap, repairRect);
+
+ const settlements = buildSettlementLayers(world, sourceMap, buildRect, seed, usedNames);
+ const adminCenters = assignLocalAdmin(world, sourceMap, buildRect, settlements.centers || [], seed, usedNames);
+ const adminRepair = reassignAdminRepair(world, sourceMap, rects, adminCenters, seed);
+ const borders = buildAdminBorders(world, repairRect);
+ if (borders.length) ensureSourceArray(sourceMap, "adminBorders").push(...borders);
+ applySettlementInfluence(world, settlements.localPoints || {});
+ const transport = buildTransportLayers(world, sourceMap, repairRect, seed, settlements.localPoints || {}, linePrune);
+ const development = buildDevelopmentLayers(world, sourceMap, buildRect, seed, settlements.localPoints || {}, usedNames);
+ const landUseCellsUpdated = reconcileTransitionFields(world, rects, snapshot);
+ updatePopulationSummary(sourceMap);
+
+ const result = {
+ ok: true,
+ seed,
+ removedPoints: pointPrune.removedPoints,
+ removedLines: linePrune.removedLines,
+ removedLocalEntities: pointPrune.removedPoints + linePrune.removedLines,
+ preservedExternalEntities,
+ roadBoundaryAnchors: linePrune.roadAnchors.length,
+ railwayBoundaryAnchors: linePrune.railAnchors.length,
+ invalidPortsRemoved: pointPrune.invalidPortsRemoved,
+ logisticsLabelsFixed: true,
+ disconnectedRoadsRailsDetected: linePrune.removedLines,
+ preservedInfluencePoints,
+ preservedInfluenceRoads: preservedInfluencePaths.roadPaths,
+ preservedInfluenceRails: preservedInfluencePaths.railPaths,
+ landUseCellsUpdated,
+ ...adminRepair,
+ adminCenters: adminCenters.length,
+ adminBorders: borders.length,
+ ...settlements.counts,
+ ...transport,
+ ...development,
+ buildRect: { ...buildRect },
+ repairRect: { ...repairRect },
+ userRect: { ...userRect },
+ };
+
+ world.lastHumanPatchResult = result;
+ world.humanPatchHistory = [...(world.humanPatchHistory || []), { ...result, createdAt: Date.now() }];
+ return result;
+}
diff --git a/mapPatch.js b/mapPatch.js
new file mode 100644
index 0000000..6254889
--- /dev/null
+++ b/mapPatch.js
@@ -0,0 +1,898 @@
+import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, smoothstep } from "./mapUtils.js";
+import { generateMap } from "./mapPipeline.js";
+import { LANDUSE } from "./landuseCodes.js";
+
+export const PATCH_MIN_WIDTH = 48;
+export const PATCH_MIN_HEIGHT = 48;
+export const PATCH_MIN_AREA = 3000;
+
+const POINT_LAYER_KEYS = [
+ "villages", "geographicUrbanAnchors", "markets", "castles", "castleTowns", "castleRuins",
+ "ports", "crossings", "passes", "modernCities", "satelliteCities", "stations",
+ "interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
+ "externalGateways", "prefectureRegions",
+];
+
+const PATH_LAYER_KEYS = [
+ "premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
+ "railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
+ "icAccessRoads", "mainRivers", "tributaryRivers", "smallStreams", "riverPaths",
+];
+
+const ROAD_LAYER_KEYS = new Set(["premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads", "expressways", "externalExpressways", "icAccessRoads"]);
+const RAIL_LAYER_KEYS = new Set(["railways", "branchRailways", "ringRailways", "externalRailways"]);
+const RIVER_LAYER_KEYS = new Set(["mainRivers", "tributaryRivers", "smallStreams", "riverPaths"]);
+
+const SEGMENT_LAYER_KEYS = ["adminBorders", "regionalPrefectureBorders", "prefectureBorder"];
+
+const ID_FIELD_OFFSETS = new Map([
+ ["adminId", 100000],
+ ["municipalityId", 100000],
+ ["prefectureRegionId", 200000],
+ ["regionId", 300000],
+ ["naturalCompartmentId", 400000],
+ ["watershedId", 500000],
+]);
+
+const DISCRETE_FIELD_NAMES = new Set([
+ "sea", "ocean", "lake", "prefectureMask", "landMask", "landuse",
+ "adminId", "municipalityId", "prefectureRegionId", "regionId", "naturalCompartmentId", "watershedId",
+]);
+
+const SKIP_CELL_FIELDS = new Set(["flowTo"]);
+
+function worldIndex(world, x, y) {
+ if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1;
+ return y * world.width + x;
+}
+
+function sourceIndex(x, y) {
+ if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) return -1;
+ return y * MAP_W + x;
+}
+
+function isCellField(value) {
+ return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
+}
+
+function rectWidth(rect) {
+ return Math.max(0, Math.floor(rect.x1) - Math.floor(rect.x0));
+}
+
+function rectHeight(rect) {
+ return Math.max(0, Math.floor(rect.y1) - Math.floor(rect.y0));
+}
+
+function rectArea(rect) {
+ return rectWidth(rect) * rectHeight(rect);
+}
+
+function normalizeRect(rect) {
+ if (!rect) return null;
+ const x0 = Math.floor(Math.min(rect.x0, rect.x1));
+ const y0 = Math.floor(Math.min(rect.y0, rect.y1));
+ const x1 = Math.ceil(Math.max(rect.x0, rect.x1));
+ const y1 = Math.ceil(Math.max(rect.y0, rect.y1));
+ return { x0, y0, x1, y1 };
+}
+
+function insideRect(x, y, rect) {
+ return !!rect && x >= rect.x0 && y >= rect.y0 && x < rect.x1 && y < rect.y1;
+}
+
+function expandRect(rect, margin, world = null) {
+ return {
+ x0: Math.max(0, rect.x0 - margin),
+ y0: Math.max(0, rect.y0 - margin),
+ x1: Math.min(world?.width ?? Infinity, rect.x1 + margin),
+ y1: Math.min(world?.height ?? Infinity, rect.y1 + margin),
+ };
+}
+
+function distanceToRectEdge(x, y, rect) {
+ return Math.min(x - rect.x0, y - rect.y0, rect.x1 - 1 - x, rect.y1 - 1 - y);
+}
+
+function defaultForField(name, Constructor) {
+ if (name === "sea" || name === "ocean") return 1;
+ if (name === "elevation") return 0.08;
+ if (ID_FIELD_OFFSETS.has(name)) return -1;
+ if (Constructor === Float32Array || Constructor === Float64Array) return 0;
+ return 0;
+}
+
+function ensureWorldField(world, name, source) {
+ if (!source || !isCellField(source)) return null;
+ const Constructor = source.constructor;
+ const expected = world.width * world.height;
+ if (!world.fields[name] || world.fields[name].length !== expected) {
+ world.fields[name] = new Constructor(expected);
+ const fallback = defaultForField(name, Constructor);
+ if (fallback !== 0) world.fields[name].fill(fallback);
+ }
+ return world.fields[name];
+}
+
+export function clipPatchRect(rect, world) {
+ const normalized = normalizeRect(rect);
+ if (!normalized || !world) return null;
+ return {
+ x0: Math.min(Math.max(normalized.x0, 0), world.width),
+ y0: Math.min(Math.max(normalized.y0, 0), world.height),
+ x1: Math.min(Math.max(normalized.x1, 0), world.width),
+ y1: Math.min(Math.max(normalized.y1, 0), world.height),
+ };
+}
+
+export function validatePatchRect(rect, world) {
+ const clipped = clipPatchRect(rect, world);
+ if (!clipped) return { ok: false, rect: null, reason: "No selected area." };
+ const width = rectWidth(clipped);
+ const height = rectHeight(clipped);
+ const area = width * height;
+ if (width < PATCH_MIN_WIDTH || height < PATCH_MIN_HEIGHT) {
+ const parts = [];
+ if (width < PATCH_MIN_WIDTH) parts.push(`minimum width ${PATCH_MIN_WIDTH} cells`);
+ if (height < PATCH_MIN_HEIGHT) parts.push(`minimum height ${PATCH_MIN_HEIGHT} cells`);
+ return {
+ ok: false,
+ rect: clipped,
+ width,
+ height,
+ area,
+ reason: `Selection is too small: ${parts.join(", ")} required. Current ${width} x ${height} cells, ${area.toLocaleString()} cells total.`,
+ };
+ }
+ if (area < PATCH_MIN_AREA) {
+ return {
+ ok: false,
+ rect: clipped,
+ width,
+ height,
+ area,
+ reason: `Selection area is too small: minimum area ${PATCH_MIN_AREA.toLocaleString()} cells required. Current ${area.toLocaleString()} cells.`,
+ };
+ }
+ return { ok: true, rect: clipped, width, height, area, reason: "" };
+}
+
+export function buildPatchRects(userRect, world = null) {
+ const coreRect = normalizeRect(userRect);
+ const width = rectWidth(coreRect);
+ const height = rectHeight(coreRect);
+ const shortSide = Math.max(1, Math.min(width, height));
+ const desiredWrite = Math.min(96, Math.max(28, Math.floor(shortSide * 0.42)));
+ const maxBySource = Math.max(0, Math.floor(Math.min((MAP_W - width) / 2, (MAP_H - height) / 2)));
+ const writeMargin = Math.max(0, Math.min(desiredWrite, maxBySource));
+ const desiredRepair = Math.min(120, writeMargin + Math.max(8, Math.floor(shortSide * 0.12)));
+ const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource));
+ const writeRect = expandRect(coreRect, writeMargin, world);
+ const repairRect = expandRect(coreRect, repairMargin, world);
+ return {
+ coreRect,
+ writeRect,
+ repairRect,
+ contextRect: repairRect,
+ blendRect: coreRect,
+ userRect: writeRect,
+ selectedRect: coreRect,
+ writeMargin,
+ repairMargin,
+ outerMargin: writeMargin,
+ innerMargin: 0,
+ };
+}
+
+function patchAlpha(x, y, rects, seed = 0) {
+ const writeRect = rects.writeRect || rects.userRect;
+ if (!insideRect(x, y, writeRect)) return 0;
+ const edge = distanceToRectEdge(x, y, writeRect);
+ const margin = Math.max(1, rects.writeMargin || 1);
+ const low = hash2(Math.floor(x / 18), Math.floor(y / 18), seed ^ 0x7153a9d1) - 0.5;
+ const mid = hash2(Math.floor(x / 7), Math.floor(y / 7), seed ^ 0x9e3779b9) - 0.5;
+ const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16;
+ const base = smoothstep(clamp(noisyEdge / margin));
+ // Keep the expanded repair band as the actual seam. The user's selected core
+ // is still dominant, but the write edge is irregular, so coastlines and land-use
+ // no longer inherit the rectangular user selection as a hard boundary.
+ return clamp(base);
+}
+
+function sourceWindowForRects(rects) {
+ const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2;
+ const cy = (rects.coreRect.y0 + rects.coreRect.y1 - 1) / 2;
+ return {
+ worldCenterX: cx,
+ worldCenterY: cy,
+ sourceCenterX: (MAP_W - 1) / 2,
+ sourceCenterY: (MAP_H - 1) / 2,
+ };
+}
+
+function sourceCoordForWorld(window, x, y) {
+ return {
+ x: Math.round(x - window.worldCenterX + window.sourceCenterX),
+ y: Math.round(y - window.worldCenterY + window.sourceCenterY),
+ };
+}
+
+function worldCoordForSource(window, sx, sy) {
+ return {
+ x: Math.round(sx - window.sourceCenterX + window.worldCenterX),
+ y: Math.round(sy - window.sourceCenterY + window.worldCenterY),
+ };
+}
+
+function fieldIdOffset(name, seed) {
+ const base = ID_FIELD_OFFSETS.get(name) || 0;
+ if (!base) return 0;
+ return base + ((seed >>> 0) % 997) * 10000;
+}
+
+function copyFullPipelineFields(world, candidate, rects, seed) {
+ const window = sourceWindowForRects(rects);
+ const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
+ let updatedCells = 0;
+ let coastCellsChanged = 0;
+ let terrainCellsFullyReplaced = 0;
+ let naturalRegionsUpdated = 0;
+ let adminCellsReassigned = 0;
+ let landUseCellsUpdated = 0;
+
+ for (const [name, source] of Object.entries(candidate || {})) {
+ if (SKIP_CELL_FIELDS.has(name) || !isCellField(source)) continue;
+ const dest = ensureWorldField(world, name, source);
+ if (!dest) continue;
+ const isFloat = source.constructor === Float32Array || source.constructor === Float64Array;
+ const isDiscrete = DISCRETE_FIELD_NAMES.has(name) || !isFloat;
+ const idOffset = fieldIdOffset(name, seed);
+
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const wi = worldIndex(world, x, y);
+ if (wi < 0) continue;
+ const s = sourceCoordForWorld(window, x, y);
+ const si = sourceIndex(s.x, s.y);
+ if (si < 0) continue;
+ const alpha = patchAlpha(x, y, rects, seed);
+ if (alpha <= 0.005) continue;
+
+ if (isDiscrete) {
+ const thresholdNoise = hash2(Math.floor(x / 6), Math.floor(y / 6), seed ^ 0x21f0aaad) - 0.5;
+ const threshold = clamp(0.46 + thresholdNoise * 0.20, 0.28, 0.68);
+ if (alpha >= threshold) {
+ const raw = source[si];
+ const value = idOffset && raw >= 0 ? raw + idOffset : raw;
+ if (name === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++;
+ if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++;
+ if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++;
+ if (name === "landuse" && dest[wi] !== value) landUseCellsUpdated++;
+ dest[wi] = value;
+ }
+ } else {
+ const before = dest[wi] || 0;
+ dest[wi] = lerp(before, source[si] || 0, alpha);
+ }
+
+ if (name === "elevation") {
+ updatedCells++;
+ if (alpha > 0.94) terrainCellsFullyReplaced++;
+ }
+ }
+ }
+ }
+
+ // Keep water fields coherent after all continuous fields have been blended.
+ const sea = world.fields.sea;
+ const ocean = world.fields.ocean;
+ const lake = world.fields.lake;
+ const landuse = world.fields.landuse;
+ if (sea) {
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ if (sea[i]) {
+ if (ocean) ocean[i] = 1;
+ if (lake) lake[i] = 0;
+ if (landuse) landuse[i] = LANDUSE.WATER || 0;
+ } else {
+ if (ocean) ocean[i] = 0;
+ if (lake) lake[i] = 0;
+ }
+ }
+ }
+ }
+
+ return { window, updatedCells, terrainCellsFullyReplaced, coastCellsChanged, naturalRegionsUpdated, adminCellsReassigned, landUseCellsUpdated };
+}
+
+function smoothWaterTopology(world, rect, seaLevel = 0.30) {
+ const sea = world.fields.sea;
+ const ocean = world.fields.ocean;
+ const lake = world.fields.lake;
+ const elevation = world.fields.elevation;
+ if (!sea || !elevation) return { coastCellsChanged: 0 };
+ let changed = 0;
+ for (let pass = 0; pass < 3; pass++) {
+ const flips = [];
+ for (let y = rect.y0 + 1; y < rect.y1 - 1; y++) {
+ for (let x = rect.x0 + 1; x < rect.x1 - 1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ let seaN = 0;
+ let landN = 0;
+ for (let dy = -1; dy <= 1; dy++) {
+ for (let dx = -1; dx <= 1; dx++) {
+ if (!dx && !dy) continue;
+ const ni = worldIndex(world, x + dx, y + dy);
+ if (ni < 0) continue;
+ if (sea[ni]) seaN++; else landN++;
+ }
+ }
+ if (sea[i] && seaN <= 1 && elevation[i] > seaLevel - 0.035) flips.push([i, 0]);
+ else if (!sea[i] && seaN >= 7 && elevation[i] < seaLevel + 0.055) flips.push([i, 1]);
+ }
+ }
+ for (const [i, nextSea] of flips) {
+ if (sea[i] === nextSea) continue;
+ sea[i] = nextSea;
+ if (ocean) ocean[i] = nextSea;
+ if (lake) lake[i] = 0;
+ if (nextSea) elevation[i] = Math.min(elevation[i], seaLevel - 0.004);
+ else elevation[i] = Math.max(elevation[i], seaLevel + 0.006);
+ changed++;
+ }
+ }
+ return { coastCellsChanged: changed };
+}
+
+function recomputeSlopeAndWaterDependentFields(world, rect, seaLevel = 0.30) {
+ const fields = world.fields;
+ const { elevation, sea } = fields;
+ if (!elevation || !sea) return;
+ if (!fields.slope) fields.slope = new Float32Array(world.width * world.height);
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ if (sea[i]) {
+ for (const key of ["slope", "river", "floodplain", "plain", "agriculture", "ridgeField", "valleyField", "coastalLowland", "naturalBarrierScore", "populationDensity", "settlementScore", "roadInfluence", "railInfluence2", "stationInfluence", "villageInfluence"]) {
+ if (fields[key]) fields[key][i] = 0;
+ }
+ continue;
+ }
+ if (x > 0 && y > 0 && x < world.width - 1 && y < world.height - 1) {
+ const gx = elevation[worldIndex(world, x + 1, y)] - elevation[worldIndex(world, x - 1, y)];
+ const gy = elevation[worldIndex(world, x, y + 1)] - elevation[worldIndex(world, x, y - 1)];
+ fields.slope[i] = clamp(Math.hypot(gx, gy) * 8.2);
+ }
+ if (fields.plain) fields.plain[i] = clamp((fields.plain[i] || 0) * 0.75 + (1 - (fields.slope[i] || 0)) * clamp((0.62 - elevation[i]) * 1.8) * 0.25);
+ if (fields.agriculture && fields.plain) fields.agriculture[i] = clamp((fields.agriculture[i] || 0) * 0.72 + fields.plain[i] * 0.28);
+ }
+ }
+}
+
+function seaNeighbors(world, x, y, radius = 1) {
+ let count = 0;
+ for (let dy = -radius; dy <= radius; dy++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ if (!dx && !dy) continue;
+ const i = worldIndex(world, x + dx, y + dy);
+ if (i >= 0 && world.fields.sea?.[i]) count++;
+ }
+ }
+ return count;
+}
+
+function isLand(world, x, y) {
+ const i = worldIndex(world, x, y);
+ return i >= 0 && !world.fields.sea?.[i];
+}
+
+function nearestLand(world, x, y, rect, radius = 10) {
+ if (insideRect(x, y, rect) && isLand(world, x, y)) return { x, y };
+ for (let r = 1; r <= radius; r++) {
+ let best = null;
+ let bestScore = Infinity;
+ for (let yy = y - r; yy <= y + r; yy++) {
+ for (let xx = x - r; xx <= x + r; xx++) {
+ if (Math.abs(xx - x) !== r && Math.abs(yy - y) !== r) continue;
+ if (!insideRect(xx, yy, rect) || !isLand(world, xx, yy)) continue;
+ const i = worldIndex(world, xx, yy);
+ const score = Math.hypot(xx - x, yy - y) + (world.fields.slope?.[i] || 0) * 3;
+ if (score < bestScore) { bestScore = score; best = { x: xx, y: yy }; }
+ }
+ }
+ if (best) return best;
+ }
+ return null;
+}
+
+function pointWorldX(world, p) {
+ if (Number.isFinite(p?.worldX)) return p.worldX;
+ return (p?.x || 0) + (world?.originX || 0);
+}
+
+function pointWorldY(world, p) {
+ if (Number.isFinite(p?.worldY)) return p.worldY;
+ return (p?.y || 0) + (world?.originY || 0);
+}
+
+function tupleWorldX(world, tuple) {
+ return (tuple?.[0] || 0) + (world?.originX || 0);
+}
+
+function tupleWorldY(world, tuple) {
+ return (tuple?.[1] || 0) + (world?.originY || 0);
+}
+
+function sourcePointFromWorld(world, point) {
+ return { ...point, x: point.x - world.originX, y: point.y - world.originY, worldX: point.x, worldY: point.y, patchGenerated: true };
+}
+
+function sourcePathFromWorld(world, path) {
+ return path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]);
+}
+
+function transformCandidatePoint(world, window, p, key, seed = 0) {
+ if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null;
+ const w = worldCoordForSource(window, p.x, p.y);
+ if (key === "ports") {
+ const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 8);
+ if (!land || seaNeighbors(world, land.x, land.y, 2) < 2) return null;
+ w.x = land.x; w.y = land.y;
+ } else if (!["crossings", "passes", "externalGateways", "prefectureRegions"].includes(key) && !isLand(world, w.x, w.y)) {
+ const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 5);
+ if (!land) return null;
+ w.x = land.x; w.y = land.y;
+ }
+ const out = sourcePointFromWorld(world, { ...p, x: w.x, y: w.y });
+ if (key === "adminCenters") {
+ const offset = fieldIdOffset("adminId", seed);
+ if (Number.isFinite(out.id)) out.id += offset;
+ if (Number.isFinite(out.adminId)) out.adminId += offset;
+ if (Number.isFinite(out.adminNumericId)) out.adminNumericId += offset;
+ if (Number.isFinite(out.municipalityId)) out.municipalityId += offset;
+ }
+ if (key === "prefectureRegions") {
+ const offset = fieldIdOffset("prefectureRegionId", seed);
+ if (Number.isFinite(out.id)) out.id += offset;
+ if (Number.isFinite(out.prefectureRegionId)) out.prefectureRegionId += offset;
+ }
+ if (key === "logisticsParks") sanitizeLogisticsPark(out);
+ return out;
+}
+
+function sanitizeLogisticsPark(p) {
+ if (!p) return p;
+ p.name = null;
+ p.labelName = null;
+ p.facilityLabel = p.facilityLabel || "Logistics Park";
+ p.labelStyle = "facility";
+ p.suppressSettlementLabel = true;
+ p.kind = "Logistics Park";
+ p.population = 0;
+ return p;
+}
+
+function sanitizeExistingLogistics(sourceMap) {
+ let migrated = 0;
+ if (!Array.isArray(sourceMap.logisticsParks)) return 0;
+ for (const p of sourceMap.logisticsParks) {
+ if (!p) continue;
+ if (p.name || p.labelName || !p.suppressSettlementLabel) migrated++;
+ sanitizeLogisticsPark(p);
+ }
+ for (const key of ["villages", "markets", "modernCities", "satelliteCities", "newTowns", "adminCenters"]) {
+ const arr = sourceMap[key];
+ if (!Array.isArray(arr)) continue;
+ for (const p of arr) {
+ if (!p?.name || !/\bLogistics\b/i.test(String(p.name))) continue;
+ p.name = String(p.name).replace(/\s*Logistics\b/ig, "").trim() || null;
+ p.labelName = p.name;
+ migrated++;
+ }
+ }
+ return migrated;
+}
+
+function transformCandidatePath(window, path) {
+ const out = [];
+ for (const tuple of path || []) {
+ if (!Array.isArray(tuple) || tuple.length < 2) continue;
+ const p = worldCoordForSource(window, tuple[0], tuple[1]);
+ out.push([p.x, p.y]);
+ }
+ return out;
+}
+
+function splitWorldPathByRect(path, rect, keepInside) {
+ const chunks = [];
+ let current = [];
+ for (const p of path || []) {
+ const inside = insideRect(Math.round(p[0]), Math.round(p[1]), rect);
+ if (inside === keepInside) current.push([Math.round(p[0]), Math.round(p[1])]);
+ else {
+ if (current.length >= 2) chunks.push(current);
+ current = [];
+ }
+ }
+ if (current.length >= 2) chunks.push(current);
+ return chunks;
+}
+
+function pruneOldPathLayer(world, paths, rect, mode) {
+ const kept = [];
+ const anchors = [];
+ let clipped = 0;
+ for (const path of paths || []) {
+ const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]);
+ const touches = worldPath.some(([x, y]) => insideRect(x, y, rect));
+ if (!touches) {
+ kept.push(path);
+ continue;
+ }
+ clipped++;
+ let lastOutside = null;
+ let wasInside = false;
+ for (const [x, y] of worldPath) {
+ const inside = insideRect(x, y, rect);
+ if (!inside) {
+ if (wasInside) anchors.push({ x, y, mode });
+ lastOutside = { x, y, mode };
+ } else if (lastOutside && !wasInside) {
+ anchors.push(lastOutside);
+ }
+ wasInside = inside;
+ }
+ for (const chunk of splitWorldPathByRect(worldPath, rect, false)) kept.push(sourcePathFromWorld(world, chunk));
+ }
+ return { kept, anchors, clipped };
+}
+
+function pathCost(world, x, y, mode) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || world.fields.sea?.[i]) return Infinity;
+ const slope = world.fields.slope?.[i] || 0;
+ const river = world.fields.river?.[i] || 0;
+ const plain = world.fields.plain?.[i] || 0;
+ const roadInfluence = world.fields.roadInfluence?.[i] || 0;
+ const pop = world.fields.populationDensity?.[i] || 0;
+ const slopeMult = mode === "rail" ? 15 : 7;
+ return 1 + slope * slopeMult - plain * 0.35 - roadInfluence * 0.28 - pop * 0.18 + river * 0.18;
+}
+
+function localPathfind(world, start, goal, rect, mode = "road", maxExpanded = 24000) {
+ const sx = Math.round(start.x), sy = Math.round(start.y), gx = Math.round(goal.x), gy = Math.round(goal.y);
+ if (!insideRect(sx, sy, rect) || !insideRect(gx, gy, rect)) return null;
+ if (!isLand(world, sx, sy) || !isLand(world, gx, gy)) return null;
+ const w = rectWidth(rect);
+ const h = rectHeight(rect);
+ const n = w * h;
+ const dist = new Float64Array(n); dist.fill(Infinity);
+ const prev = new Int32Array(n); prev.fill(-1);
+ const local = (x, y) => (y - rect.y0) * w + (x - rect.x0);
+ const heap = new MinHeap();
+ const startId = local(sx, sy);
+ dist[startId] = 0;
+ heap.push({ x: sx, y: sy, f: Math.hypot(sx - gx, sy - gy), id: startId });
+ let expanded = 0;
+ let found = -1;
+ const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
+ while (heap.items.length && expanded < maxExpanded) {
+ const cur = heap.pop();
+ if (!cur) break;
+ if (cur.x === gx && cur.y === gy) { found = cur.id; break; }
+ expanded++;
+ for (const [dx, dy] of dirs) {
+ const nx = cur.x + dx, ny = cur.y + dy;
+ if (!insideRect(nx, ny, rect)) continue;
+ const nid = local(nx, ny);
+ const c = pathCost(world, nx, ny, mode);
+ if (!Number.isFinite(c)) continue;
+ const step = (dx && dy ? 1.42 : 1) * c;
+ const nd = dist[cur.id] + step;
+ if (nd >= dist[nid]) continue;
+ dist[nid] = nd;
+ prev[nid] = cur.id;
+ heap.push({ x: nx, y: ny, id: nid, f: nd + Math.hypot(nx - gx, ny - gy) * 1.05 });
+ }
+ }
+ if (found < 0) return null;
+ const rev = [];
+ let at = found;
+ while (at >= 0) {
+ const x = rect.x0 + (at % w);
+ const y = rect.y0 + Math.floor(at / w);
+ rev.push([x, y]);
+ at = prev[at];
+ }
+ return rev.reverse();
+}
+
+function simplifyPath(path, keepEvery = 2) {
+ if (!path || path.length <= 2) return path || [];
+ const out = [path[0]];
+ for (let i = 1; i < path.length - 1; i++) if (i % keepEvery === 0) out.push(path[i]);
+ out.push(path[path.length - 1]);
+ return out;
+}
+
+function collectInternalNetworkPoints(world, sourceMap, keys, rect) {
+ const points = [];
+ for (const key of keys) {
+ for (const path of sourceMap[key] || []) {
+ for (let i = 0; i < path.length; i += 6) {
+ const x = Math.round(tupleWorldX(world, path[i]));
+ const y = Math.round(tupleWorldY(world, path[i]));
+ if (insideRect(x, y, rect) && isLand(world, x, y)) points.push({ x, y, key });
+ }
+ }
+ }
+ return points;
+}
+
+function connectAnchors(world, sourceMap, anchors, mode, rect) {
+ const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"];
+ const targets = collectInternalNetworkPoints(world, sourceMap, keys, rect);
+ if (!targets.length) return { connectors: 0, disconnected: anchors.length };
+ let connectors = 0;
+ let disconnected = 0;
+ const layer = mode === "rail" ? "branchRailways" : "minorRoads";
+ sourceMap[layer] ||= [];
+ const seen = new Set();
+ for (const raw of anchors) {
+ const anchorLand = nearestLand(world, raw.x, raw.y, rect, 12);
+ if (!anchorLand) { disconnected++; continue; }
+ const target = targets
+ .filter((p) => Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) <= (mode === "rail" ? 80 : 64))
+ .sort((a, b) => Math.hypot(a.x - anchorLand.x, a.y - anchorLand.y) - Math.hypot(b.x - anchorLand.x, b.y - anchorLand.y))[0];
+ if (!target) { disconnected++; continue; }
+ const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`;
+ if (seen.has(sig)) continue;
+ seen.add(sig);
+ const path = localPathfind(world, anchorLand, target, rect, mode);
+ if (!path || path.length < 2) { disconnected++; continue; }
+ sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2)));
+ connectors++;
+ }
+ return { connectors, disconnected };
+}
+
+function mergePointLayers(world, sourceMap, candidate, rects, window, seed) {
+ let preservedExternalEntities = 0;
+ let regeneratedInternalEntities = 0;
+ let invalidPortsRemoved = 0;
+ for (const key of POINT_LAYER_KEYS) {
+ const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
+ const kept = [];
+ for (const p of oldArr) {
+ if (!p) continue;
+ const wx = Math.round(pointWorldX(world, p));
+ const wy = Math.round(pointWorldY(world, p));
+ const inWrite = insideRect(wx, wy, rects.writeRect);
+ const alpha = inWrite ? patchAlpha(wx, wy, rects, seed) : 0;
+ if (!inWrite || alpha < 0.34) {
+ if (key === "ports" && inWrite && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) { invalidPortsRemoved++; continue; }
+ kept.push(p);
+ if (!inWrite) preservedExternalEntities++;
+ }
+ }
+ const generated = [];
+ for (const p of candidate[key] || []) {
+ const q = transformCandidatePoint(world, window, p, key, seed);
+ if (!q) continue;
+ const wx = Math.round(pointWorldX(world, q));
+ const wy = Math.round(pointWorldY(world, q));
+ if (!insideRect(wx, wy, rects.writeRect)) continue;
+ if (patchAlpha(wx, wy, rects, seed) < 0.42) continue;
+ generated.push(q);
+ }
+ sourceMap[key] = [...kept, ...generated];
+ regeneratedInternalEntities += generated.length;
+ }
+ return { preservedExternalEntities, regeneratedInternalEntities, invalidPortsRemoved };
+}
+
+function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
+ let roadAnchors = [];
+ let railAnchors = [];
+ let roadsClipped = 0;
+ let railsClipped = 0;
+ let regeneratedPaths = 0;
+ for (const key of PATH_LAYER_KEYS) {
+ const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
+ const mode = RAIL_LAYER_KEYS.has(key) ? "rail" : ROAD_LAYER_KEYS.has(key) ? "road" : RIVER_LAYER_KEYS.has(key) ? "river" : "path";
+ const pruned = pruneOldPathLayer(world, oldArr, rects.writeRect, mode);
+ if (mode === "rail") { railAnchors = railAnchors.concat(pruned.anchors); railsClipped += pruned.clipped; }
+ else if (mode === "road") { roadAnchors = roadAnchors.concat(pruned.anchors); roadsClipped += pruned.clipped; }
+ const next = [...pruned.kept];
+ for (const path of candidate[key] || []) {
+ const worldPath = transformCandidatePath(window, path);
+ const chunks = splitWorldPathByRect(worldPath, rects.writeRect, true)
+ .map((chunk) => chunk.filter(([x, y]) => mode === "river" || isLand(world, x, y) || patchAlpha(x, y, rects, seed) > 0.90))
+ .filter((chunk) => chunk.length >= 2);
+ for (const chunk of chunks) {
+ if (chunk.some(([x, y]) => patchAlpha(x, y, rects, seed) >= 0.40)) {
+ next.push(sourcePathFromWorld(world, simplifyPath(chunk, mode === "rail" ? 3 : 2)));
+ regeneratedPaths++;
+ }
+ }
+ }
+ sourceMap[key] = next;
+ }
+ const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", rects.writeRect);
+ const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", rects.writeRect);
+ return {
+ roadsClipped,
+ railsClipped,
+ regeneratedPaths,
+ roadConnectorsCreated: roadConn.connectors,
+ railwayConnectorsCreated: railConn.connectors,
+ disconnectedRoadComponents: roadConn.disconnected,
+ disconnectedRailComponents: railConn.disconnected,
+ };
+}
+
+function segmentTouchesRect(world, seg, rect) {
+ if (!Array.isArray(seg) || seg.length < 2) return false;
+ return insideRect(tupleWorldX(world, seg[0]), tupleWorldY(world, seg[0]), rect)
+ || insideRect(tupleWorldX(world, seg[1]), tupleWorldY(world, seg[1]), rect);
+}
+
+function buildBoundarySegmentsFromField(world, fieldName, rect) {
+ const field = world.fields[fieldName];
+ const sea = world.fields.sea;
+ if (!field) return [];
+ const out = [];
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0 || sea?.[i]) continue;
+ const id = field[i];
+ if (id < 0) continue;
+ const right = worldIndex(world, x + 1, y);
+ if (x + 1 < rect.x1 && right >= 0 && !sea?.[right] && field[right] >= 0 && field[right] !== id) {
+ out.push([[x + 0.5 - world.originX, y - world.originY], [x + 0.5 - world.originX, y + 1 - world.originY]]);
+ }
+ const down = worldIndex(world, x, y + 1);
+ if (y + 1 < rect.y1 && down >= 0 && !sea?.[down] && field[down] >= 0 && field[down] !== id) {
+ out.push([[x - world.originX, y + 0.5 - world.originY], [x + 1 - world.originX, y + 0.5 - world.originY]]);
+ }
+ }
+ }
+ return out;
+}
+
+function mergeSegmentLayers(world, sourceMap, rects) {
+ for (const key of SEGMENT_LAYER_KEYS) {
+ const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
+ sourceMap[key] = oldArr.filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect));
+ }
+ sourceMap.adminBorders ||= [];
+ sourceMap.adminBorders.push(...buildBoundarySegmentsFromField(world, "adminId", rects.writeRect));
+ sourceMap.regionalPrefectureBorders ||= [];
+ sourceMap.regionalPrefectureBorders.push(...buildBoundarySegmentsFromField(world, "prefectureRegionId", rects.writeRect));
+}
+
+function repairLanduseAndPopulation(world, rects) {
+ const landuse = world.fields.landuse;
+ if (!landuse) return { landUseCellsUpdated: 0 };
+ let updated = 0;
+ for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
+ for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ if (world.fields.sea?.[i]) {
+ if (landuse[i] !== (LANDUSE.WATER || 0)) updated++;
+ landuse[i] = LANDUSE.WATER || 0;
+ continue;
+ }
+ if (landuse[i] === (LANDUSE.WATER || 0)) {
+ const slope = world.fields.slope?.[i] || 0;
+ const ag = world.fields.agriculture?.[i] || 0;
+ landuse[i] = slope > 0.42 ? LANDUSE.FOREST : ag > 0.28 ? LANDUSE.FARMLAND : LANDUSE.RURAL;
+ updated++;
+ }
+ }
+ }
+ return { landUseCellsUpdated: updated };
+}
+
+function countSea(world, rect) {
+ let seaCount = 0;
+ let total = 0;
+ for (let y = rect.y0; y < rect.y1; y++) {
+ for (let x = rect.x0; x < rect.x1; x++) {
+ const i = worldIndex(world, x, y);
+ if (i < 0) continue;
+ total++;
+ if (world.fields.sea?.[i]) seaCount++;
+ }
+ }
+ return { seaCount, total, seaRatio: total ? seaCount / total : 0 };
+}
+
+function terrainLabel(candidate, fallback) {
+ return candidate?.terrainTemplate?.terrainTypeLabel || candidate?.terrainDebug?.terrainTypeLabel || fallback;
+}
+
+function terrainId(candidate, fallback) {
+ return candidate?.terrainTemplate?.terrainType || candidate?.terrainDebug?.terrainType || fallback;
+}
+
+export function generatePatch(world, userRectInput, options = {}) {
+ const validation = validatePatchRect(userRectInput, world);
+ if (!validation.ok) return { ok: false, ...validation };
+
+ const rects = buildPatchRects(validation.rect, world);
+ const terrainType = options.terrainType || "auto";
+ const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world?.seed || 0) + 1013904223) >>> 0;
+ const candidate = generateMap(seed, { terrainType, onProgress: () => {} });
+ const sourceMap = world.sourceMap || (world.sourceMap = {});
+ const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
+
+ const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed);
+ const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
+ recomputeSlopeAndWaterDependentFields(world, rects.repairRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
+ const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed);
+ const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed);
+ mergeSegmentLayers(world, sourceMap, rects);
+ const landDebug = repairLanduseAndPopulation(world, rects);
+ sanitizeExistingLogistics(sourceMap);
+
+ const seaStats = countSea(world, rects.coreRect);
+ const label = terrainLabel(candidate, terrainType);
+ const id = terrainId(candidate, terrainType);
+ const humanGeography = {
+ ok: true,
+ modernCities: (sourceMap.modernCities || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
+ ports: (sourceMap.ports || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
+ villages: (sourceMap.villages || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
+ ...pointDebug,
+ ...pathDebug,
+ adminCellsReassigned: fieldDebug.adminCellsReassigned,
+ landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated,
+ logisticsLabelsMigrated,
+ };
+
+ const record = {
+ ...rects.coreRect,
+ coreRect: { ...rects.coreRect },
+ writeRect: { ...rects.writeRect },
+ repairRect: { ...rects.repairRect },
+ contextRect: { ...rects.contextRect },
+ blendRect: { ...rects.blendRect },
+ terrainType: id,
+ label,
+ seed,
+ updatedCells: fieldDebug.updatedCells,
+ terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced,
+ coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged,
+ naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated,
+ naturalRegionFragmentsMerged: 0,
+ seaRatio: seaStats.seaRatio,
+ humanGeography,
+ createdAt: Date.now(),
+ };
+ world.generatedRects = [...(world.generatedRects || []), record];
+ world.invalidatedRects = [...(world.invalidatedRects || []), { ...rects.writeRect }];
+ world.lastPatchResult = record;
+
+ return {
+ ok: true,
+ validation,
+ rects,
+ terrainType: id,
+ label,
+ seed,
+ updatedCells: record.updatedCells,
+ terrainCellsFullyReplaced: record.terrainCellsFullyReplaced,
+ coastCellsChanged: record.coastCellsChanged,
+ naturalRegionsUpdated: record.naturalRegionsUpdated,
+ naturalRegionFragmentsMerged: 0,
+ seaRatio: seaStats.seaRatio,
+ humanGeography,
+ };
+}
diff --git a/mapTerrain.js b/mapTerrain.js
index 8452d33..3087ea6 100644
--- a/mapTerrain.js
+++ b/mapTerrain.js
@@ -206,6 +206,31 @@ const TERRAIN_TYPES = [
riverRichnessRange: [0.74, 1.14],
bigRiverChanceRange: [0.30, 0.60],
},
+ {
+ id: "oceanic_archipelago",
+ label: "海洋型・多島海",
+ weight: 0.16,
+ coastStyle: "oceanic_archipelago",
+ mountainMode: "mixed",
+ massifnessRange: [0.04, 0.28],
+ seaRatioRange: [0.72, 0.90],
+ twoSidedChance: 1.0,
+ mountainOffsetRange: [0.25, 0.55],
+ baseHeightRange: [0.30, 0.62],
+ primaryLengthRange: [0.20, 0.52],
+ primaryWidthRange: [0.08, 0.24],
+ systemCountRange: [7, 14],
+ beltCountRange: [2, 4],
+ angleSpread: 0.70,
+ crossSpread: 0.90,
+ lengthScale: 0.78,
+ widthScale: 0.82,
+ heightScale: 0.58,
+ coastStrength: 1.55,
+ plainBiasRange: [0.12, 0.34],
+ riverRichnessRange: [0.18, 0.52],
+ bigRiverChanceRange: [0.02, 0.12],
+ },
{
id: "setouchi_inland_sea",
label: "瀬戸内型・内海多島",
@@ -584,7 +609,15 @@ function computeCoastLower(px, py, template, seed) {
const islandNoise = (fbm(px * 420 + 17, py * 420 - 11, seed + 302) - 0.5) * 0.032;
let pressure = 0;
- if (template.coastStyle === "inland_sea") {
+ if (template.coastStyle === "oceanic_archipelago") {
+ // 海洋型: 外洋を強く取り、島列・湾・水道が点在する低標高圧を作る。
+ const radial = distNorm(px, py, 0.5, 0.5);
+ const outerOcean = smoothstep((radial - 0.30 + wave * 1.15 + bay * 0.85) / 0.18) * 0.96;
+ const diagonalChannel = smoothstep((0.13 - Math.abs(cross + wave * 0.82 + islandNoise * 0.70)) / 0.14) * 0.70;
+ const openSide = smoothstep((-axis + 0.12 + wave + bay) / 0.23) * 0.82;
+ const islandGaps = clamp((valueNoise(px * 780 + 31, py * 780 - 19, seed + 303, 20) - 0.42) * 1.25) * 0.22;
+ pressure = clamp(Math.max(outerOcean, diagonalChannel, openSide) + islandGaps);
+ } else if (template.coastStyle === "inland_sea") {
// 瀬戸内型は旧来の大きな内海+両岸海岸線に戻す。
// 海面比率はテンプレート側で高めに保ち、微細な島ノイズではなく
// 連続した水道形状で海を増やす。
@@ -1217,8 +1250,17 @@ export function generateTerrainAndRivers(seed, options = {}) {
const high = Math.max(0, e - 0.62);
e -= high * 0.42;
}
- const softCapStart = terrainTemplate.terrainType === "tohoku_spine" ? 0.78 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.72 : terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91;
- const softCapMax = terrainTemplate.terrainType === "tohoku_spine" ? 0.96 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.92 : 1.08;
+ if (terrainTemplate.terrainType === "oceanic_archipelago") {
+ // 海洋型は大きな山塊ではなく、島列を読むための低い起伏を点在させる。
+ const islandCore = clamp((fbm(x * 0.82 + 41, y * 0.82 - 29, seed + 572) - 0.46) * 2.7);
+ const islandChain = clamp(mountainMaskMax * 0.92 + islandCore * 0.54 - coastPressure * 0.24);
+ e += islandChain * 0.135;
+ e -= clamp((coastPressure - 0.38) * 1.55) * 0.040;
+ const high = Math.max(0, e - 0.56);
+ e -= high * 0.52;
+ }
+ const softCapStart = terrainTemplate.terrainType === "tohoku_spine" ? 0.78 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.72 : terrainTemplate.terrainType === "oceanic_archipelago" ? 0.62 : terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91;
+ const softCapMax = terrainTemplate.terrainType === "tohoku_spine" ? 0.96 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.92 : terrainTemplate.terrainType === "oceanic_archipelago" ? 0.84 : 1.08;
elevation[i] = clamp(softCapElevation(e, softCapStart, softCapMax), 0.025, softCapMax);
visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40);
surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
diff --git a/renderer.js b/renderer.js
index 501526f..055d3c9 100644
--- a/renderer.js
+++ b/renderer.js
@@ -892,14 +892,15 @@ export function drawMap(canvas, map, options) {
const mode = options.mode || "all";
const showFeatures = options.showFeatures !== false;
const showLabels = options.showLabels !== false;
+ const continuousTerrain = options.continuousTerrain !== false;
const width = MAP_W * CELL_SIZE;
const height = MAP_H * CELL_SIZE;
- canvas.width = width;
- canvas.height = height;
+ if (canvas.width !== width) canvas.width = width;
+ if (canvas.height !== height) canvas.height = height;
// 1. Base Terrain & Urban
- drawBase(ctx, map, mode, true);
+ drawBase(ctx, map, mode, continuousTerrain);
drawUrbanAreas(ctx, map, mode);
const coastSegments = getCoastlineSegments(map);
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
@@ -1100,10 +1101,9 @@ export function drawMap(canvas, map, options) {
...prefectureLabels,
...map.modernCities,
...map.ports,
- ...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })),
...(map.satelliteCities || []),
...settlementIconLabelPoints,
- ].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
+ ].filter((p) => !p.suppressSettlementLabel && (p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000));
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
}
drawScaleBar(ctx);
diff --git a/styles.css b/styles.css
index a09f2f7..89d04d2 100644
--- a/styles.css
+++ b/styles.css
@@ -61,7 +61,7 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
.port-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #4682c8;border-top:0;box-shadow:none;background:transparent;border-radius:0}
.newtown-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #b4d2f0;border-top:0;box-shadow:none;background:transparent;border-radius:0}
-.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.98);border:1px solid rgba(0,0,0,0.1);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.1);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500}
+.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.74);border:1px solid rgba(0,0,0,0.10);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.10);backdrop-filter:blur(4px);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500}
.map-tooltip.visible{opacity:1;transform:translateY(0)}
.generation-progress{position:absolute;inset:24px auto auto 24px;z-index:30;min-width:300px;max-width:440px;background:rgba(255,255,255,0.96);border:1px solid rgba(0,0,0,0.12);border-radius:12px;box-shadow:0 14px 36px rgba(0,0,0,0.14);padding:14px 16px;color:#202124;font-size:13px;line-height:1.5}
@@ -72,4 +72,9 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px}
.canvas-shell.panning{cursor:grabbing}
-.canvas-shell.panning .map-canvas{pointer-events:none}
+.canvas-shell.selecting{cursor:crosshair}
+.map-selection{position:absolute;z-index:18;display:none;pointer-events:none;border:2px solid rgba(26,115,232,0.86);background:rgba(26,115,232,0.16);box-shadow:0 0 0 1px rgba(255,255,255,0.70) inset,0 8px 22px rgba(26,115,232,0.20)}
+.primary-button:disabled{background:#a8b6c8;color:#eef3f8;cursor:not-allowed}
+.patch-status{margin:10px 0 0;color:#5f6368;font-size:12px;line-height:1.45}
+.patch-status.invalid{color:#b3261e;font-weight:600}
+.map-selection.invalid{border-color:rgba(179,38,30,0.88);background:rgba(179,38,30,0.14);box-shadow:0 0 0 1px rgba(255,255,255,0.70) inset,0 8px 22px rgba(179,38,30,0.18)}
diff --git a/worldMap.js b/worldMap.js
new file mode 100644
index 0000000..322a60b
--- /dev/null
+++ b/worldMap.js
@@ -0,0 +1,100 @@
+import { MAP_H, MAP_W, SIZE } from "./mapUtils.js";
+
+export const DEFAULT_WORLD_PADDING_X = MAP_W;
+export const DEFAULT_WORLD_PADDING_Y = MAP_H;
+
+const NEGATIVE_ONE_FIELDS = new Set([
+ "adminId",
+ "prefectureRegionId",
+ "regionId",
+ "municipalityId",
+]);
+
+function isCellField(value) {
+ return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
+}
+
+function defaultForField(name, Constructor) {
+ if (name === "sea") return 1;
+ if (name === "elevation") return 0.08;
+ if (name === "seaLevel") return undefined;
+ if (NEGATIVE_ONE_FIELDS.has(name)) return -1;
+ if (Constructor === Float32Array || Constructor === Float64Array) return 0;
+ return 0;
+}
+
+function makeWorldField(name, source, worldWidth, worldHeight, originX, originY) {
+ const Constructor = source.constructor;
+ const out = new Constructor(worldWidth * worldHeight);
+ const fallback = defaultForField(name, Constructor);
+ if (fallback !== 0) out.fill(fallback);
+
+ for (let y = 0; y < MAP_H; y++) {
+ const srcRow = y * MAP_W;
+ const dstRow = (originY + y) * worldWidth + originX;
+ for (let x = 0; x < MAP_W; x++) out[dstRow + x] = source[srcRow + x];
+ }
+ return out;
+}
+
+export function createWorldMap(initialMap, options = {}) {
+ const paddingX = Number.isFinite(options.paddingX) ? Math.max(0, Math.floor(options.paddingX)) : DEFAULT_WORLD_PADDING_X;
+ const paddingY = Number.isFinite(options.paddingY) ? Math.max(0, Math.floor(options.paddingY)) : DEFAULT_WORLD_PADDING_Y;
+ const worldWidth = MAP_W + paddingX * 2;
+ const worldHeight = MAP_H + paddingY * 2;
+ const originX = paddingX;
+ const originY = paddingY;
+ const fields = {};
+
+ for (const [name, value] of Object.entries(initialMap || {})) {
+ if (isCellField(value)) fields[name] = makeWorldField(name, value, worldWidth, worldHeight, originX, originY);
+ }
+
+ if (!fields.sea) {
+ fields.sea = new Uint8Array(worldWidth * worldHeight);
+ fields.sea.fill(1);
+ }
+ if (!fields.elevation) {
+ fields.elevation = new Float32Array(worldWidth * worldHeight);
+ fields.elevation.fill(0.08);
+ }
+
+ return {
+ seed: initialMap?.seed ?? 0,
+ width: worldWidth,
+ height: worldHeight,
+ originX,
+ originY,
+ sourceWidth: initialMap?.width || MAP_W,
+ sourceHeight: initialMap?.height || MAP_H,
+ sourceMap: initialMap,
+ fields,
+ invalidatedRects: [],
+ humanPatchHistory: [],
+ generatedRects: [{
+ x0: originX,
+ y0: originY,
+ x1: originX + MAP_W,
+ y1: originY + MAP_H,
+ terrainType: initialMap?.terrainTemplate?.terrainType || initialMap?.terrainDebug?.terrainType || "auto",
+ label: initialMap?.terrainTemplate?.terrainTypeLabel || initialMap?.terrainDebug?.terrainTypeLabel || "Initial generation",
+ }],
+ };
+}
+
+export function createInitialCamera(world) {
+ return {
+ x: world?.originX || 0,
+ y: world?.originY || 0,
+ };
+}
+
+export function clampCameraToWorld(camera, world, viewWidth = MAP_W, viewHeight = MAP_H) {
+ if (!camera || !world) return { x: 0, y: 0 };
+ const maxX = Math.max(0, world.width - viewWidth);
+ const maxY = Math.max(0, world.height - viewHeight);
+ return {
+ x: Math.min(Math.max(Math.round(camera.x || 0), 0), maxX),
+ y: Math.min(Math.max(Math.round(camera.y || 0), 0), maxY),
+ };
+}
diff --git a/worldViewport.js b/worldViewport.js
new file mode 100644
index 0000000..8b1c001
--- /dev/null
+++ b/worldViewport.js
@@ -0,0 +1,205 @@
+import { MAP_H, MAP_W, SIZE } from "./mapUtils.js";
+
+const EMPTY_ARRAY_KEYS = new Set([
+ "villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations",
+ "interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
+ "premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
+ "railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
+ "mainRivers", "tributaryRivers", "smallStreams", "prefectureBorder", "regionalPrefectureBorders",
+ "adminBorders", "externalGateways", "prefectureRegions",
+]);
+
+const PATH_ARRAY_KEYS = new Set([
+ "premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
+ "railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
+ "mainRivers", "tributaryRivers", "smallStreams",
+]);
+
+const SEGMENT_ARRAY_KEYS = new Set([
+ "prefectureBorder", "regionalPrefectureBorders", "adminBorders",
+]);
+
+const POINT_ARRAY_KEYS = new Set([
+ "villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations",
+ "interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
+ "externalGateways", "prefectureRegions",
+]);
+
+const NEGATIVE_ONE_FIELDS = new Set([
+ "adminId",
+ "prefectureRegionId",
+ "regionId",
+ "municipalityId",
+]);
+
+function isCellField(value) {
+ return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
+}
+
+function defaultForField(name, Constructor) {
+ if (name === "sea") return 1;
+ if (name === "elevation") return 0.08;
+ if (NEGATIVE_ONE_FIELDS.has(name)) return -1;
+ if (Constructor === Float32Array || Constructor === Float64Array) return 0;
+ return 0;
+}
+
+function worldIndex(world, x, y) {
+ if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1;
+ return y * world.width + x;
+}
+
+function copyViewportField(name, source, world, camera, viewWidth, viewHeight) {
+ const Constructor = source.constructor;
+ const out = new Constructor(viewWidth * viewHeight);
+ const fallback = defaultForField(name, Constructor);
+ if (fallback !== 0) out.fill(fallback);
+
+ const cx = Math.round(camera.x || 0);
+ const cy = Math.round(camera.y || 0);
+ for (let y = 0; y < viewHeight; y++) {
+ for (let x = 0; x < viewWidth; x++) {
+ const src = worldIndex(world, cx + x, cy + y);
+ if (src >= 0) out[y * viewWidth + x] = source[src];
+ }
+ }
+ return out;
+}
+
+function inViewportPoint(p, margin = 0) {
+ return p && p.x >= -margin && p.y >= -margin && p.x < MAP_W + margin && p.y < MAP_H + margin;
+}
+
+function transformPointObject(point, camera, originX, originY) {
+ if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
+ return {
+ ...point,
+ x: point.x + originX - camera.x,
+ y: point.y + originY - camera.y,
+ worldX: point.x + originX,
+ worldY: point.y + originY,
+ };
+}
+
+function transformPointArray(items, camera, originX, originY, margin = 36, preserveIndexes = false) {
+ const mapped = (items || []).map((item) => transformPointObject(item, camera, originX, originY));
+ return preserveIndexes ? mapped : mapped.filter((item) => inViewportPoint(item, margin));
+}
+
+function transformTuple(tuple, camera, originX, originY) {
+ if (!Array.isArray(tuple) || tuple.length < 2) return null;
+ return [tuple[0] + originX - camera.x, tuple[1] + originY - camera.y];
+}
+
+function tupleInside(tuple, margin = 0) {
+ return tuple && tuple[0] >= -margin && tuple[1] >= -margin && tuple[0] < MAP_W + margin && tuple[1] < MAP_H + margin;
+}
+
+function splitTransformedPath(path, camera, originX, originY, margin = 0) {
+ const chunks = [];
+ let current = [];
+ for (const tuple of path || []) {
+ const p = transformTuple(tuple, camera, originX, originY);
+ const inside = tupleInside(p, margin);
+ if (inside) {
+ current.push([Math.round(p[0]), Math.round(p[1])]);
+ } else if (current.length >= 2) {
+ chunks.push(current);
+ current = [];
+ } else {
+ current = [];
+ }
+ }
+ if (current.length >= 2) chunks.push(current);
+ return chunks;
+}
+
+function transformPaths(paths, camera, originX, originY) {
+ const out = [];
+ for (const path of paths || []) out.push(...splitTransformedPath(path, camera, originX, originY));
+ return out;
+}
+
+function segmentIntersectsViewport(seg, margin = 4) {
+ if (!seg || seg.length < 2) return false;
+ const xs = [seg[0][0], seg[1][0]];
+ const ys = [seg[0][1], seg[1][1]];
+ return Math.max(...xs) >= -margin && Math.min(...xs) <= MAP_W + margin && Math.max(...ys) >= -margin && Math.min(...ys) <= MAP_H + margin;
+}
+
+function transformSegments(segments, camera, originX, originY) {
+ return (segments || [])
+ .map((seg) => [transformTuple(seg?.[0], camera, originX, originY), transformTuple(seg?.[1], camera, originX, originY)])
+ .filter(segmentIntersectsViewport);
+}
+
+function transformTransportDebug(debug, camera, originX, originY) {
+ if (!debug?.layers) return debug;
+ const layers = { ...debug.layers };
+ if (Array.isArray(layers.components)) {
+ layers.components = layers.components.map((component) => ({
+ ...component,
+ cells: (component.cells || [])
+ .map((cell) => transformTuple(cell, camera, originX, originY))
+ .filter((cell) => tupleInside(cell, 0))
+ .map(([x, y]) => [Math.round(x), Math.round(y)]),
+ })).filter((component) => component.cells.length);
+ }
+ if (Array.isArray(layers.repairedSegments)) {
+ layers.repairedSegments = layers.repairedSegments.flatMap((repair) => transformPaths([repair.path || []], camera, originX, originY).map((path) => ({ ...repair, path })));
+ }
+ if (Array.isArray(layers.unservedSettlements)) layers.unservedSettlements = transformPointArray(layers.unservedSettlements, camera, originX, originY);
+ return { ...debug, layers };
+}
+
+function buildEmptyViewportFromSource(sourceMap, world, camera, viewWidth, viewHeight) {
+ const viewport = { ...sourceMap };
+ viewport.width = viewWidth;
+ viewport.height = viewHeight;
+ viewport.worldCamera = { x: camera.x, y: camera.y };
+ viewport.worldOrigin = { x: world.originX, y: world.originY };
+ viewport.generatedRects = world.generatedRects || [];
+ for (const key of EMPTY_ARRAY_KEYS) if (Array.isArray(viewport[key])) viewport[key] = [];
+ return viewport;
+}
+
+export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MAP_H) {
+ const sourceMap = world?.sourceMap || {};
+ const normalizedCamera = {
+ x: Math.round(camera?.x || 0),
+ y: Math.round(camera?.y || 0),
+ };
+ const viewport = buildEmptyViewportFromSource(sourceMap, world, normalizedCamera, viewWidth, viewHeight);
+
+ for (const [name, value] of Object.entries(world?.fields || {})) {
+ viewport[name] = copyViewportField(name, value, world, normalizedCamera, viewWidth, viewHeight);
+ }
+
+ const originX = world?.originX || 0;
+ const originY = world?.originY || 0;
+ for (const key of POINT_ARRAY_KEYS) {
+ if (!Array.isArray(sourceMap[key])) continue;
+ viewport[key] = transformPointArray(sourceMap[key], normalizedCamera, originX, originY, 36, key === "adminCenters");
+ }
+ for (const key of PATH_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformPaths(sourceMap[key], normalizedCamera, originX, originY);
+ for (const key of SEGMENT_ARRAY_KEYS) if (Array.isArray(sourceMap[key])) viewport[key] = transformSegments(sourceMap[key], normalizedCamera, originX, originY);
+
+ if (sourceMap.adminDebug) {
+ viewport.adminDebug = {
+ ...sourceMap.adminDebug,
+ compartmentBorders: transformSegments(sourceMap.adminDebug.compartmentBorders || [], normalizedCamera, originX, originY),
+ lowlandAdminSeeds: transformPointArray(sourceMap.adminDebug.lowlandAdminSeeds || [], normalizedCamera, originX, originY),
+ };
+ }
+ if (sourceMap.transportDebug) viewport.transportDebug = transformTransportDebug(sourceMap.transportDebug, normalizedCamera, originX, originY);
+ if (sourceMap.neighborPrefectureDetails) {
+ viewport.neighborPrefectureDetails = {
+ ...sourceMap.neighborPrefectureDetails,
+ cities: transformPointArray(sourceMap.neighborPrefectureDetails.cities || [], normalizedCamera, originX, originY),
+ adminCenters: transformPointArray(sourceMap.neighborPrefectureDetails.adminCenters || [], normalizedCamera, originX, originY),
+ roads: transformPaths(sourceMap.neighborPrefectureDetails.roads || [], normalizedCamera, originX, originY),
+ };
+ }
+
+ return viewport;
+}