This commit is contained in:
33333-33333 2026-05-29 22:00:42 +09:00
commit 6cef9a3abe
11 changed files with 1184 additions and 235 deletions

299
app.js
View file

@ -35,6 +35,7 @@ const state = {
patchVariant: 0,
zoom: 1,
lastPatchResult: null,
pendingPatch: null,
};
const canvas = document.getElementById("mapCanvas");
@ -62,6 +63,9 @@ let generationCurrentStage = "";
let generationTimer = null;
let zoomRedrawRaf = null;
let zoomSettledTimer = null;
let zoomVisualState = null;
let patchWorker = null;
let patchJobSeq = 0;
const dragState = {
mode: null,
@ -81,8 +85,16 @@ const dragState = {
panRaf: null,
};
function displayWorld() {
return state.pendingPatch?.world || state.world;
}
function displaySourceMap() {
return displayWorld()?.sourceMap || state.map;
}
function activeMap() {
return state.viewportMap || state.map;
return state.viewportMap || displaySourceMap();
}
function clampZoom(value) {
@ -99,8 +111,8 @@ function viewportSizeForZoom(zoom = state.zoom) {
};
}
function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom)) {
return clampCameraToWorld(camera, state.world, size?.width || MAP_W, size?.height || MAP_H);
function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom), world = displayWorld()) {
return clampCameraToWorld(camera, world, size?.width || MAP_W, size?.height || MAP_H);
}
function syncViewportSize() {
@ -110,8 +122,12 @@ function syncViewportSize() {
return size;
}
function canvasInteractionRect() {
return zoomVisualState?.baseRect || canvas.getBoundingClientRect();
}
function mapCellScreenSize(map = activeMap()) {
const rect = canvas.getBoundingClientRect();
const rect = canvasInteractionRect();
const mapWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
return rect.width ? rect.width / mapWidth : CELL_SIZE * clampZoom(state.zoom || 1);
}
@ -131,7 +147,7 @@ function displayedCellSize() {
}
function screenPointToMapPixel(clientX, clientY, sizeOverride = null) {
const rect = canvas.getBoundingClientRect();
const rect = canvasInteractionRect();
if (!rect.width || !rect.height) return null;
const map = activeMap();
const viewWidth = Math.max(1, sizeOverride?.width || map?.width || state.viewWidth || MAP_W);
@ -144,7 +160,7 @@ function screenPointToMapPixel(clientX, clientY, sizeOverride = null) {
}
function mapPixelToScreenPoint(px, py) {
const rect = canvas.getBoundingClientRect();
const rect = canvasInteractionRect();
const map = activeMap();
const viewWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
const viewHeight = Math.max(1, map?.height || state.viewHeight || MAP_H);
@ -176,7 +192,7 @@ function viewportCellToWorldCell(cell) {
}
function clampCanvasPoint(event) {
const rect = canvas.getBoundingClientRect();
const rect = canvasInteractionRect();
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),
@ -186,7 +202,7 @@ function clampCanvasPoint(event) {
function screenPointToWorldCell(point) {
const map = activeMap();
if (!map || !point) return null;
const rect = canvas.getBoundingClientRect();
const rect = canvasInteractionRect();
if (!rect.width || !rect.height) return null;
const viewWidth = Math.max(1, map.width || state.viewWidth || MAP_W);
const viewHeight = Math.max(1, map.height || state.viewHeight || MAP_H);
@ -387,8 +403,9 @@ function updatePatchControls() {
return;
}
const rects = buildPatchRects(validation.rect, state.world);
const patchText = state.lastPatchResult
? ` Last patch: ${state.lastPatchResult.label}, variant ${state.lastPatchResult.variant ?? "-"}, mode ${state.lastPatchResult.patchGenerationMode || "legacy-full-pipeline"}, 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}` : ""}.`
const shownPatch = state.pendingPatch?.result || state.lastPatchResult;
const patchText = shownPatch
? ` ${state.pendingPatch ? "Preview" : "Last applied"}: ${shownPatch.label}, variant ${shownPatch.variant ?? "-"}, mode ${shownPatch.patchGenerationMode || "legacy-full-pipeline"}, terrain ${shownPatch.updatedCells.toLocaleString()} cells, coast ${shownPatch.coastCellsChanged || 0}, natural ${shownPatch.naturalRegionsUpdated || 0}${shownPatch.humanGeography?.ok ? `, connectors ${(shownPatch.humanGeography.roadConnectorsCreated || 0) + (shownPatch.humanGeography.railwayConnectorsCreated || 0)}, admin ${shownPatch.humanGeography.adminCellsReassigned || 0}, invalid ports ${shownPatch.humanGeography.invalidPortsRemoved || 0}` : ""}.${state.pendingPatch ? " Click the map without dragging to apply; generate Alternative to replace the preview." : ""}`
: "";
patchStatusEl.textContent = `Variant: ${variant}. Core: ${formatRectSize(rects.coreRect)}. Write: ${formatRectSize(rects.writeRect)}. Context: ${formatRectSize(rects.contextRect)}. Repair: ${formatRectSize(rects.repairRect)}.${patchText}`;
patchStatusEl.classList.toggle("invalid", false);
@ -424,7 +441,36 @@ function schedulePanRedraw(camera) {
});
}
function hideSelectionOverlay() {
function commitPendingPatch({ redrawAfter = true } = {}) {
if (!state.pendingPatch?.world) return false;
state.world = state.pendingPatch.world;
state.map = state.world.sourceMap || state.map;
state.lastPatchResult = state.pendingPatch.result || state.world.lastPatchResult || state.lastPatchResult;
state.pendingPatch = null;
state.viewportMap = null;
if (redrawAfter) {
renderStats(displaySourceMap());
redraw({ fastTerrain: true, allowWorldExpand: false });
window.setTimeout(() => redraw({ fastTerrain: false, allowWorldExpand: false }), 80);
}
return true;
}
function discardPendingPatch({ redrawAfter = true } = {}) {
if (!state.pendingPatch) return false;
state.pendingPatch = null;
state.viewportMap = null;
if (redrawAfter) {
renderStats(displaySourceMap());
redraw({ fastTerrain: false, allowWorldExpand: false });
}
return true;
}
function hideSelectionOverlay(options = {}) {
const commitPreview = options.commitPreview === true;
if (commitPreview) commitPendingPatch({ redrawAfter: false });
else if (options.discardPreview === true) discardPendingPatch({ redrawAfter: false });
dragState.selectStart = null;
dragState.selectEnd = null;
dragState.selectPath = null;
@ -432,7 +478,10 @@ function hideSelectionOverlay() {
resetPatchVariant({ update: false });
hideSelectionSvg();
if (selectionEl) selectionEl.style.display = "none";
state.viewportMap = null;
renderStats(displaySourceMap());
updatePatchControls();
if (commitPreview || options.discardPreview === true) redraw({ fastTerrain: false, allowWorldExpand: false });
}
function selectionPixelsToCells(start, end) {
@ -470,6 +519,7 @@ function handleMapPointerDown(event) {
dragState.mode = "pan";
canvasShell.classList.add("panning");
} else {
if (state.selectionRect) commitPendingPatch({ redrawAfter: false });
dragState.mode = "select";
dragState.selectStart = clampCanvasPoint(event);
dragState.selectEnd = dragState.selectStart;
@ -551,8 +601,11 @@ function handleMapPointerUp(event) {
state.camera = dragState.pendingCamera;
dragState.pendingCamera = null;
}
const clickDistance = Math.hypot(event.clientX - dragState.startClientX, event.clientY - dragState.startClientY);
const shouldClearSelectionByClick = wasPanning && clickDistance <= 5 && !!state.selectionRect;
clearDragMode();
if (wasPanning) redraw({ fastTerrain: false });
if (shouldClearSelectionByClick) hideSelectionOverlay({ commitPreview: true });
else if (wasPanning) redraw({ fastTerrain: false });
event.preventDefault();
}
@ -738,6 +791,11 @@ function hasNumericId(item, id, keys = ADMIN_ID_KEYS) {
return keys.some((key) => Number.isFinite(item?.[key]) && Math.floor(item[key]) === Math.floor(id));
}
function numericPrefectureId(item) {
const value = numericIdOf(item, PREFECTURE_ID_KEYS);
return value == null ? -1 : value;
}
function firstUsableText(item, keys) {
for (const key of keys) {
const value = item?.[key];
@ -768,7 +826,7 @@ function adminCenterForId(map, adminId) {
function looksNumericName(name) {
if (!name) return true;
const text = String(name).trim();
return !text || /^-?\d+(?:\s*[,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text);
return !text || /^県域\d*$/u.test(text) || /^-?\d+(?:\s*[,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text);
}
function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = null) {
@ -810,13 +868,46 @@ function adminPopulation(map, adminId, cellIndex = -1) {
return found ? sum : null;
}
function prefectureNameForCell(map, i) {
const id = map.prefectureRegionId?.[i] ?? -1;
const region = (map.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS));
function worldSourceMap() {
return displayWorld()?.sourceMap || null;
}
function prefectureRegionById(id, maps = []) {
if (!Number.isFinite(id) || id < 0) return null;
for (const source of maps) {
const region = (source?.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS));
if (region) return region;
}
return null;
}
function prefectureNameForId(id, adminId = -1) {
if (!Number.isFinite(id) || id < 0) return "";
const sources = [activeMap(), worldSourceMap()].filter(Boolean);
const region = prefectureRegionById(id, sources);
const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS);
if (regionName) return regionName;
for (const source of sources) {
for (const center of source?.adminCenters || []) {
if (adminId >= 0 && !hasNumericId(center, adminId)) continue;
const centerPref = numericPrefectureId(center);
if (centerPref >= 0 && centerPref !== id) continue;
const name = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]);
if (name) return name;
}
}
return "";
}
function prefectureNameForCell(map, i) {
const id = map.prefectureRegionId?.[i] ?? -1;
const direct = prefectureNameForId(id);
if (direct) return direct;
const adminId = map.adminId?.[i] ?? -1;
const mappedPref = adminId >= 0 ? map.municipalityToPrefectureId?.[adminId] ?? -1 : -1;
const mappedPref = adminId >= 0 ? map.municipalityToPrefectureId?.[adminId] ?? state.world?.sourceMap?.municipalityToPrefectureId?.[adminId] ?? -1 : -1;
const mapped = prefectureNameForId(mappedPref, adminId);
if (mapped) return mapped;
const center = mappedPref === id ? nearestNamedAdminCenter(map, i, 90, adminId) : null;
const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]);
return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-");
@ -893,8 +984,9 @@ async function regenerate() {
state.world = createWorldMap(state.map);
state.camera = createInitialCamera(state.world);
state.lastPatchResult = null;
state.pendingPatch = null;
resetPatchVariant({ update: false });
hideSelectionOverlay();
hideSelectionOverlay({ discardPreview: true });
renderStats(state.map);
redraw();
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
@ -919,6 +1011,47 @@ function derivePatchSeed(rect, terrainType, variant = 0) {
}
function beginZoomVisual(oldZoom, event) {
if (zoomVisualState) return;
const rect = canvas.getBoundingClientRect();
zoomVisualState = {
baseRect: rect,
startZoom: clampZoom(oldZoom || state.zoom || 1),
originX: Math.min(Math.max(event.clientX - rect.left, 0), rect.width),
originY: Math.min(Math.max(event.clientY - rect.top, 0), rect.height),
};
canvas.style.transformOrigin = `${zoomVisualState.originX}px ${zoomVisualState.originY}px`;
canvas.style.willChange = "transform";
canvas.classList.add("is-zooming");
if (selectionSvgEl) selectionSvgEl.style.visibility = "hidden";
}
function scheduleZoomVisualUpdate() {
if (!zoomVisualState || zoomRedrawRaf != null) return;
zoomRedrawRaf = requestAnimationFrame(() => {
zoomRedrawRaf = null;
if (!zoomVisualState) return;
const scale = clampZoom(state.zoom || 1) / Math.max(1e-6, zoomVisualState.startZoom || 1);
canvas.style.transform = `scale(${scale})`;
});
}
function finishZoomVisual() {
if (zoomRedrawRaf != null) {
cancelAnimationFrame(zoomRedrawRaf);
zoomRedrawRaf = null;
}
if (zoomVisualState) {
canvas.style.transform = "";
canvas.style.transformOrigin = "";
canvas.style.willChange = "";
canvas.classList.remove("is-zooming");
if (selectionSvgEl) selectionSvgEl.style.visibility = "";
zoomVisualState = null;
}
}
function handleCanvasWheel(event) {
if (!state.world || !activeMap()) return;
event.preventDefault();
@ -942,19 +1075,101 @@ function handleCanvasWheel(event) {
}
}
// Wheel events can fire dozens of times per second. Do one lightweight redraw
// per frame, then a full labeled/continuous redraw once zooming settles.
if (zoomRedrawRaf == null) {
zoomRedrawRaf = requestAnimationFrame(() => {
zoomRedrawRaf = null;
redraw({ fastTerrain: true, allowWorldExpand: false });
});
}
// Wheel events can fire dozens of times per second. During the gesture, keep
// the last rendered bitmap and only transform it on the GPU; rebuild the
// viewport and labels once the gesture settles.
beginZoomVisual(oldZoom, event);
scheduleZoomVisualUpdate();
if (zoomSettledTimer != null) clearTimeout(zoomSettledTimer);
zoomSettledTimer = window.setTimeout(() => {
zoomSettledTimer = null;
finishZoomVisual();
redraw({ fastTerrain: false, allowWorldExpand: false });
}, 140);
}, 170);
}
function cloneForPatchPreview(value, seen = new Map()) {
if (value == null || typeof value !== "object") return value;
if (ArrayBuffer.isView(value)) return new value.constructor(value);
if (value instanceof ArrayBuffer) return value.slice(0);
if (seen.has(value)) return seen.get(value);
if (value instanceof Map) {
const out = new Map();
seen.set(value, out);
for (const [k, v] of value.entries()) out.set(cloneForPatchPreview(k, seen), cloneForPatchPreview(v, seen));
return out;
}
if (Array.isArray(value)) {
const out = [];
seen.set(value, out);
for (const item of value) out.push(cloneForPatchPreview(item, seen));
return out;
}
const out = {};
seen.set(value, out);
for (const [key, item] of Object.entries(value)) out[key] = cloneForPatchPreview(item, seen);
return out;
}
function createPatchWorker() {
if (patchWorker || typeof Worker === "undefined") return patchWorker;
try {
patchWorker = new Worker(new URL("./mapPatchWorker.js", import.meta.url), { type: "module" });
patchWorker.addEventListener("error", () => {
patchWorker?.terminate?.();
patchWorker = null;
});
} catch (_) {
patchWorker = null;
}
return patchWorker;
}
function runPatchInWorker(world, rect, options) {
const worker = createPatchWorker();
if (!worker) return null;
const id = ++patchJobSeq;
return new Promise((resolve, reject) => {
const cleanup = () => {
worker.removeEventListener("message", onMessage);
worker.removeEventListener("error", onError);
worker.removeEventListener("messageerror", onMessageError);
};
const onMessage = (event) => {
if (event.data?.id !== id) return;
cleanup();
if (event.data.ok) resolve({ world: event.data.world, result: event.data.result, worker: true });
else reject(new Error(event.data.error || "Patch worker failed"));
};
const onError = (event) => {
cleanup();
reject(new Error(event.message || "Patch worker error"));
};
const onMessageError = () => {
cleanup();
reject(new Error("Patch worker message clone failed"));
};
worker.addEventListener("message", onMessage);
worker.addEventListener("error", onError);
worker.addEventListener("messageerror", onMessageError);
worker.postMessage({ id, world, rect, options });
});
}
async function generatePatchPreviewWorld(baseWorld, rect, options) {
const workerPromise = runPatchInWorker(baseWorld, rect, options);
if (workerPromise) {
try {
return await workerPromise;
} catch (error) {
console.warn("Patch worker unavailable; falling back to main-thread preview generation.", error);
patchWorker?.terminate?.();
patchWorker = null;
}
}
const previewWorld = cloneForPatchPreview(baseWorld);
const result = generatePatch(previewWorld, rect, options);
return { world: previewWorld, result, worker: false };
}
async function generateSelectedPatch() {
@ -966,23 +1181,29 @@ async function generateSelectedPatch() {
const terrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto";
const variant = readPatchVariant();
const seed = derivePatchSeed(validation.rect, terrainType, variant);
setProgressVisible(true, "Generating selected patch...");
setProgressVisible(true, "Generating preview patch...");
await nextFrame();
try {
const result = generatePatch(state.world, validation.rect, { terrainType, seed, variant });
const job = await generatePatchPreviewWorld(state.world, validation.rect, { terrainType, seed, variant });
const result = job.result;
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);
state.pendingPatch = { world: job.world, result, rect: validation.rect, terrainType, seed, variant, worker: job.worker };
// Keep the committed world untouched. The preview world is rendered until
// the user clicks once without dragging; Alternative replaces this preview
// from the same committed base, so old candidate artifacts cannot accumulate.
state.viewportMap = null;
redraw({ fastTerrain: true, allowWorldExpand: false });
window.setTimeout(() => redraw({ fastTerrain: false, allowWorldExpand: false }), 80);
renderStats(displaySourceMap());
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} / variant ${result.variant ?? variant} / mode ${result.patchGenerationMode || "legacy-full-pipeline"} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}`;
if (progressStageEl) progressStageEl.textContent = `Preview generated${job.worker ? " in worker" : ""}: ${result.label} / variant ${result.variant ?? variant} / mode ${result.patchGenerationMode || "legacy-full-pipeline"} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}. Click the map without dragging to apply.`;
renderTimingRows(result.patchTimings || []);
window.setTimeout(() => setProgressVisible(false), 900);
} catch (error) {
@ -1002,9 +1223,11 @@ async function generateAlternativePatch() {
}
function redraw(options = {}) {
if (!state.world) return;
const renderWorld = displayWorld();
if (!renderWorld) return;
const viewSize = syncViewportSize();
const expansion = options.allowWorldExpand === false ? null : ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height);
const mayExpand = !state.pendingPatch && options.allowWorldExpand !== false;
const expansion = mayExpand ? ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height) : null;
if (expansion?.expanded) {
const ex = expansion.dx || 0;
const ey = expansion.dy || 0;
@ -1025,8 +1248,9 @@ function redraw(options = {}) {
};
}
}
state.camera = clampCameraForView(state.camera, viewSize);
state.viewportMap = getViewportMap(state.world, state.camera, viewSize.width, viewSize.height, { light: !!options.fastTerrain });
const activeWorldForRender = displayWorld();
state.camera = clampCameraForView(state.camera, viewSize, activeWorldForRender);
state.viewportMap = getViewportMap(activeWorldForRender, state.camera, viewSize.width, viewSize.height, { light: !!options.fastTerrain });
state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap);
drawMap(canvas, state.viewportMap, {
mode: state.mode,
@ -1050,6 +1274,7 @@ function init() {
generationTypeInput?.addEventListener("change", regenerate);
patchTerrainTypeInput?.addEventListener("change", () => {
discardPendingPatch({ redrawAfter: true });
state.lastPatchResult = null;
resetPatchVariant({ update: false });
updatePatchControls();

View file

@ -17,8 +17,10 @@ import { buildCoarseCostGraph, refineCoarsePath, routeCoarsePath } from "./mapTr
// 3. make sparse approximate transport paths without full-resolution A*
// 4. synthesize population and land-use fields in one raster pass
export function generateMapFeatures(seed, terrain) {
export function generateMapFeatures(seed, terrain, options = {}) {
const SPEED_TOLERANCE = 0.90;
const patchMode = options?.patchMode === true || options?.generationContext?.hasBoundaryWorld === true;
const topCenterSuppression = clamp(Number.isFinite(options?.topCenterSuppression) ? options.topCenterSuppression : (patchMode ? 0.68 : 0), 0, 0.95);
const featureTimings = [];
const nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
let timingMark = nowMs();
@ -502,7 +504,11 @@ export function generateMapFeatures(seed, terrain) {
// regional pass.
modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score);
const regionalCapitalSlots = Math.max(1, Math.min(2, Math.floor(Math.sqrt(Math.max(1, modernCities.length)) / 2)));
const baseRegionalCapitalSlots = Math.max(1, Math.min(2, Math.floor(Math.sqrt(Math.max(1, modernCities.length)) / 2)));
const regionalCapitalSlots = patchMode
? Math.max(0, Math.min(1, Math.round(baseRegionalCapitalSlots * (1 - topCenterSuppression))))
: baseRegionalCapitalSlots;
const topCenterGeoThreshold = 0.80 + topCenterSuppression * 0.16;
for (const [rank, city] of modernCities.entries()) {
const i = indexOf(city.x, city.y);
const st = regionStats.get(city.regionId);
@ -514,8 +520,12 @@ export function generateMapFeatures(seed, terrain) {
fieldValue(geoAccessibility, i, 0) * 0.18 +
Math.log10((city.capacity || 26000) + 1) / 7 * 0.26
);
const isTopCenter = rank < regionalCapitalSlots || geoTierScore > 0.8;
const isRegionalCapital = isFirstInRegion && (isTopCenter || (city.capacity || 0) > 210000 || (st?.highCentralityCells || 0) > 220);
const slotTopCenter = regionalCapitalSlots > 0 && rank < regionalCapitalSlots;
const exceptionalPatchCenter = patchMode && geoTierScore > topCenterGeoThreshold && (city.capacity || 0) > 360000;
const isTopCenter = (!patchMode && slotTopCenter) || exceptionalPatchCenter || (!patchMode && geoTierScore > topCenterGeoThreshold);
const regionalCapacityThreshold = patchMode ? 300000 : 210000;
const regionalCentralityThreshold = patchMode ? 340 : 220;
const isRegionalCapital = isFirstInRegion && (isTopCenter || (city.capacity || 0) > regionalCapacityThreshold || (st?.highCentralityCells || 0) > regionalCentralityThreshold);
const u = rand(st.seed, city.x, city.y, 9101);
const v = rand(st.seed, city.x, city.y, 9102);
const w = rand(st.seed, city.x, city.y, 9103);
@ -524,17 +534,20 @@ let rawPop;
if (isRegionalCapital) {
if (isTopCenter) {
// largest 3M - 11M
// largest 3M - 11M in full generation; patch candidates are suppressed
// unless they are exceptionally strong geographic centers.
rawPop =
3000000 +
Math.pow(u, 0.42) * 5200000 +
Math.pow(v, 3.2) * 2800000;
if (patchMode) rawPop *= (0.42 + (1 - topCenterSuppression) * 0.28);
} else {
// larger 0.25M - 2.5M
rawPop =
250000 +
Math.pow(u, 0.55) * 1450000 +
Math.pow(v, 2.4) * 900000;
if (patchMode) rawPop *= 0.72;
}
} else {
// normal 5k - 0.75k
@ -543,9 +556,9 @@ if (isRegionalCapital) {
Math.pow(u, 0.72) * 520000 +
Math.pow(v, 3.0) * 320000;
}
const capMultiplier = isRegionalCapital ? (isTopCenter ? 1.66 : 1.42) : 1.20;
const capMultiplier = isRegionalCapital ? (isTopCenter ? (patchMode ? 1.22 : 1.66) : (patchMode ? 1.08 : 1.42)) : 1.20;
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
const floor = isRegionalCapital ? (isTopCenter ? 210000 : 120000) : 42000;
const floor = isRegionalCapital ? (isTopCenter ? (patchMode ? 150000 : 210000) : (patchMode ? 90000 : 120000)) : 42000;
city.population = Math.max(floor, population);
city.isPrefecturalCapital = isPrefecturalCapital;
city.isRegionalCapital = isRegionalCapital;

View file

@ -1,6 +1,24 @@
import { INF, MAP_H, MAP_W } from "./mapUtils.js";
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId"];
const PREFECTURE_NAME_KEYS = ["prefectureName", "prefectureRegionName", "regionName", "name", "labelName"];
function usableName(value) {
const text = value == null ? "" : String(value).trim();
if (!text) return "";
if (/^県域\d*$/u.test(text)) return "";
if (/^Unnamed prefecture$/i.test(text)) return "";
if (/^Prefecture\s*-?\d+$/i.test(text)) return "";
return text;
}
function firstUsableName(obj, keys = PREFECTURE_NAME_KEYS) {
for (const key of keys) {
const text = usableName(obj?.[key]);
if (text) return text;
}
return "";
}
function coordIndex(width, height, x, y) {
if (x < 0 || y < 0 || x >= width || y >= height) return -1;
@ -190,6 +208,7 @@ export function refreshPrefectureRegionsMetadata({
prefectureRegionId,
sea,
existing = [],
adminCenters = [],
fields = {},
width = MAP_W,
height = MAP_H,
@ -219,6 +238,13 @@ export function refreshPrefectureRegionsMetadata({
const id = Number.isFinite(region?.prefectureRegionId) ? Math.floor(region.prefectureRegionId) : Number.isFinite(region?.id) ? Math.floor(region.id) : -1;
if (id >= 0 && !existingById.has(id)) existingById.set(id, region);
}
const nameByPref = new Map();
for (const center of adminCenters || []) {
const id = Number.isFinite(center?.prefectureRegionId) ? Math.floor(center.prefectureRegionId) : -1;
if (id < 0 || nameByPref.has(id)) continue;
const name = firstUsableName(center, ["prefectureName", "prefectureRegionName", "regionName"]);
if (name) nameByPref.set(id, name);
}
let fallbackRegionsAdded = 0;
const prefectureRegions = [];
for (const [id, row] of [...byId.entries()].sort((a, b) => a[0] - b[0])) {
@ -226,6 +252,7 @@ export function refreshPrefectureRegionsMetadata({
if (!base) fallbackRegionsAdded++;
const x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
const y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
const resolvedName = firstUsableName(base) || nameByPref.get(id) || `県域${id + 1}`;
prefectureRegions.push({
...(base || {}),
id,
@ -235,8 +262,11 @@ export function refreshPrefectureRegionsMetadata({
y: y - pointOffsetY,
area: row.area,
kind: base?.kind || (id === 0 ? "Current Prefecture" : "Prefecture"),
name: base?.name || `県域${id + 1}`,
labelName: base?.labelName || base?.name || `県域${id + 1}`,
name: resolvedName,
labelName: firstUsableName(base, ["labelName"]) || resolvedName,
prefectureName: firstUsableName(base, ["prefectureName"]) || resolvedName,
prefectureRegionName: firstUsableName(base, ["prefectureRegionName"]) || resolvedName,
regionName: firstUsableName(base, ["regionName"]) || resolvedName,
forceLabel: true,
labelPriorityBase: base?.labelPriorityBase || 950 + Math.sqrt(row.area),
});

View file

@ -441,6 +441,7 @@ export function finishMapOutput({
let externalGateways = inputExternalGateways;
const outputProgress = (step) => options?.onProgress?.({ status: "output-step", key: "output", label: `Output: ${step}`, step });
const patchMode = options?.patchMode === true || options?.generationContext?.hasBoundaryWorld === true;
outputProgress("final packaging");
// Use all generated prefecture regions for human-geography masks, not only
// the focused prefecture. Population density itself is already generated in
@ -454,11 +455,19 @@ export function finishMapOutput({
// name features.
for (const city of modernCities) {
const cap = cityPopulationCap(city);
if (cap < INF && (city.population || 0) > cap) {
city.population = Math.round(cap / 1000) * 1000;
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2);
city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0);
let targetCap = cap;
if (patchMode) {
// Patch candidates should not regularly introduce a new top-center-scale
// metropolis. Existing cities in the world are preserved by mapPatch; this
// only affects newly generated candidate cities before they are merged.
const patchCap = city.isPrefecturalCapital ? 820000 : city.isRegionalCapital ? 680000 : 540000;
targetCap = Math.min(targetCap, patchCap);
}
if (targetCap < INF && (city.population || 0) > targetCap) {
city.population = Math.round(targetCap / 1000) * 1000;
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, patchMode ? 14 : 16);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, patchMode ? 4.8 : 5.2);
city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, patchMode ? 1.86 : 2.0);
}
}

File diff suppressed because it is too large Load diff

11
mapPatchWorker.js Normal file
View file

@ -0,0 +1,11 @@
import { generatePatch } from "./mapPatch.js";
self.onmessage = (event) => {
const { id, world, rect, options } = event.data || {};
try {
const result = generatePatch(world, rect, options || {});
self.postMessage({ id, ok: true, world, result });
} catch (error) {
self.postMessage({ id, ok: false, error: error?.message || String(error), stack: error?.stack || "" });
}
};

View file

@ -160,7 +160,6 @@ const TERRAIN_TYPES = [
{
id: "tohoku_spine",
label: "東北型・長大脊梁",
weight: 0.24,
coastStyle: "parallel_spine",
mountainMode: "range",
massifnessRange: [0.06, 0.26],
@ -185,7 +184,6 @@ const TERRAIN_TYPES = [
{
id: "chubu_mountain",
label: "中部型・交差高山地",
weight: 0.24,
coastStyle: "outer_coast",
mountainMode: "massif",
massifnessRange: [0.42, 0.74],
@ -210,7 +208,6 @@ const TERRAIN_TYPES = [
{
id: "oceanic_archipelago",
label: "Ocean",
weight: 0.00,
autoSelectable: false,
coastStyle: "oceanic_archipelago",
mountainMode: "mixed",
@ -236,24 +233,23 @@ const TERRAIN_TYPES = [
{
id: "setouchi_inland_sea",
label: "瀬戸内型・内海多島",
weight: 0.16,
coastStyle: "inland_sea",
mountainMode: "mixed",
massifnessRange: [0.34, 0.62],
seaRatioRange: [0.28, 0.43],
twoSidedChance: 0.92,
seaRatioRange: [0.40, 0.56],
twoSidedChance: 0.96,
mountainOffsetRange: [0.22, 0.34],
baseHeightRange: [0.46, 0.78],
primaryLengthRange: [0.52, 0.78],
primaryWidthRange: [0.20, 0.38],
systemCountRange: [16, 22],
beltCountRange: [3, 4],
systemCountRange: [20, 28],
beltCountRange: [4, 5],
angleSpread: 0.34,
crossSpread: 0.86,
lengthScale: 1.00,
widthScale: 1.18,
heightScale: 0.82,
coastStrength: 1.34,
lengthScale: 0.92,
widthScale: 1.02,
heightScale: 0.78,
coastStrength: 1.52,
plainBiasRange: [0.26, 0.50],
riverRichnessRange: [0.58, 0.96],
bigRiverChanceRange: [0.18, 0.42],
@ -261,7 +257,6 @@ const TERRAIN_TYPES = [
{
id: "kanto_alluvial",
label: "関東・濃尾型・大河川平野",
weight: 0.16,
coastStyle: "open_bay",
mountainMode: "range",
massifnessRange: [0.10, 0.30],
@ -286,7 +281,6 @@ const TERRAIN_TYPES = [
{
id: "mixed_archipelago",
label: "混合型・列島変化",
weight: 0.20,
coastStyle: "mixed_archipelago",
mountainMode: "mixed",
massifnessRange: [0.16, 0.72],
@ -1896,16 +1890,19 @@ export function generateTerrainAndRivers(seed, options = {}) {
e -= high * clamp(0.18 + mountainMaskMax * 0.22, 0.18, 0.40);
}
if (terrainTemplate.terrainType === "setouchi_inland_sea") {
// Setouchi maps should have many low hills and island backbones rather
// than a few high alpine ridges. Add broad low relief, then cap peaks.
const lowHillNoise = clamp((fbm(wx * 0.95 + 17, wy * 0.95 - 23, seed + 571) - 0.38) * 2.9);
const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12);
e += lowHillMask * 0.145;
// Do not add the previous fine speckle uplift here: it created too many
// tiny islets. Sea amount is controlled by seaRatio/coast pressure.
e -= clamp((coastPressure - 0.42) * 1.35) * 0.026;
const high = Math.max(0, e - 0.62);
e -= high * 0.42;
// Setouchi should read as sea-dominant, with many compact wooded island
// backbones rather than broad continental ridges. The small-massif term
// is band-limited so it forms believable islands, not one-cell speckle.
const lowHillNoise = clamp((fbm(wx * 0.95 + 17, wy * 0.95 - 23, seed + 571) - 0.39) * 2.7);
const islandMassif = clamp((fbm(wx * 1.85 - 31, wy * 1.85 + 19, seed + 573) - 0.50) * 3.4);
const islandBackbone = clamp((valueNoise(wx * 2.8 + 7, wy * 2.8 - 11, seed + 574, 9) - 0.54) * 3.2);
const coastalIslandBias = clamp(coastPressure * 0.64 + mountainMaskMax * 0.46 + lowHillNoise * 0.24);
e += lowHillNoise * 0.090;
e += islandMassif * coastalIslandBias * 0.105;
e += islandBackbone * coastalIslandBias * 0.045;
e -= clamp((coastPressure - 0.34) * 1.55) * 0.044;
const high = Math.max(0, e - 0.60);
e -= high * 0.48;
}
if (terrainTemplate.terrainType === "oceanic_archipelago") {
// 海洋型は大きな山塊ではなく、島列を読むための低い起伏を点在させる。

View file

@ -433,14 +433,25 @@ function terrainColorContinuous(map, fx, fy, mode) {
]);
}
const coastBlend = clamp((waterCoverage - 0.36) / 0.28);
color = coastBlend > 0 ? mixRgb(landColor, waterColor, coastBlend) : landColor;
const centerWater = Boolean(map.sea?.[i]);
if (centerWater) {
// Keep the visible coastline and the filled water side derived from the same
// sea mask. Only a very narrow anti-aliased edge borrows land color; broad
// land/sea averaging made coast strokes disagree with the underlying fill.
const edgeLand = clamp((0.58 - waterCoverage) / 0.26);
color = edgeLand > 0 ? mixRgb(waterColor, landColor, edgeLand * 0.42) : waterColor;
} else {
const shore = clamp((waterCoverage - 0.10) / 0.42);
const shoreColor = [224, 229, 213];
color = shore > 0 ? mixRgb(landColor, shoreColor, shore * 0.34) : landColor;
}
return blendOutside(color, isInside);
}
function terrainShadeContinuous(map, fx, fy) {
const i = sampleCellIndex(map, fx, fy);
const waterCoverage = seaCoverageSample(map, fx, fy);
if (waterCoverage >= 0.50) return waterVisualShade(map, fx, fy);
if (map.sea?.[i] || waterCoverage >= 0.82) return waterVisualShade(map, fx, fy);
const step = 0.50;
const eC = fieldSample(map, map.elevation, fx, fy);

View file

@ -90,3 +90,5 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
.secondary-button{border:1px solid rgba(26,115,232,0.35);border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;background:#eef4ff;color:#1557b0;transition:background 0.2s,border-color 0.2s}
.secondary-button:hover{background:#e1edff;border-color:rgba(26,115,232,0.55)}
.secondary-button:disabled{background:#eef1f4;color:#8a98a8;border-color:rgba(0,0,0,0.08);cursor:not-allowed}
.map-canvas.is-zooming{image-rendering:auto;pointer-events:auto}

38
test.js
View file

@ -11,23 +11,30 @@ import {
validateGeneratedName,
} from "./names.js";
const result = document.getElementById("result");
const IS_BROWSER = typeof document !== "undefined";
const result = IS_BROWSER ? document.getElementById("result") : { className: "", textContent: "" };
const logLines = [];
let failed = 0;
async function readLocalText(path) {
if (IS_BROWSER) return fetch(path).then((response) => response.text());
const { readFile } = await import("node:fs/promises");
return readFile(new URL(path, import.meta.url), "utf8");
}
const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, mapPatchSource, worldMapSource, municipalSource, testSource] = await Promise.all([
fetch("./names.js").then((response) => response.text()),
fetch("./mapGenerator.js").then((response) => response.text()),
fetch("./mapOutput.js").then((response) => response.text()),
fetch("./mapTerrain.js").then((response) => response.text()),
fetch("./renderer.js").then((response) => response.text()),
fetch("./app.js").then((response) => response.text()),
fetch("./mapPipeline.js").then((response) => response.text()),
fetch("./mapAdminStage.js").then((response) => response.text()),
fetch("./mapPatch.js").then((response) => response.text()),
fetch("./worldMap.js").then((response) => response.text()),
fetch("./mapMunicipalCoherence.js").then((response) => response.text()),
fetch("./test.js").then((response) => response.text()),
readLocalText("./names.js"),
readLocalText("./mapGenerator.js"),
readLocalText("./mapOutput.js"),
readLocalText("./mapTerrain.js"),
readLocalText("./renderer.js"),
readLocalText("./app.js"),
readLocalText("./mapPipeline.js"),
readLocalText("./mapAdminStage.js"),
readLocalText("./mapPatch.js"),
readLocalText("./worldMap.js"),
readLocalText("./mapMunicipalCoherence.js"),
readLocalText("./test.js"),
]);
function assert(condition, message) {
@ -1023,7 +1030,12 @@ try {
result.className = failed === 0 ? "ok" : "ng";
result.textContent = `${failed === 0 ? "All tests passed." : `${failed} tests failed.`}\n\n${logLines.join("\n")}`;
if (!IS_BROWSER) console.log(result.textContent);
} catch (error) {
result.className = "ng";
result.textContent = String(error?.stack || error);
if (!IS_BROWSER) {
console.error(result.textContent);
process.exitCode = 1;
}
}

View file

@ -218,7 +218,14 @@ export function getViewportMap(world, camera, viewWidth = MAP_W, viewHeight = MA
viewport[name] = copyViewportField(name, value, world, normalizedCamera, viewWidth, viewHeight);
}
if (options.light) return viewport;
if (options.light) {
// Light/fast redraws are used while panning and zooming. Do not leave
// source-space debug vectors on the viewport; in borders-debug this made
// natural compartment lines appear fixed on screen while the map moved.
viewport.adminDebug = null;
viewport.transportDebug = null;
return viewport;
}
const originX = world?.originX || 0;
const originY = world?.originY || 0;