From 0990b9843693140f9eabadf84750db37cb030301 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sat, 30 May 2026 03:25:19 +0900 Subject: [PATCH] improoooved --- app.js | 28 +- mapPatch.js | 258 +++-- mapPatch.js.bak | 2660 -------------------------------------------- mapPatchContext.js | 24 + renderer.js | 181 ++- 5 files changed, 357 insertions(+), 2794 deletions(-) delete mode 100644 mapPatch.js.bak create mode 100644 mapPatchContext.js diff --git a/app.js b/app.js index 79292b3..5893a6b 100644 --- a/app.js +++ b/app.js @@ -957,6 +957,7 @@ function updateRenderDiagnostics(options = {}, timings = {}) { hoverMs: timings.hoverMs || 0, drawMs: timings.drawMs || 0, totalRenderMs: timings.totalRenderMs || 0, + renderBreakdown: timings.renderBreakdown || null, }; state.diagnostics.lastFeatureCounts = collectFeatureCounts(map); } @@ -981,6 +982,15 @@ function selectionWriteDiagnostics() { function renderViewportDiagnostics() { const viewport = state.diagnostics.lastViewport; + const renderRows = viewport?.renderBreakdown ? [ + { label: "Draw / base", value: formatMs(viewport.renderBreakdown.baseTerrain || 0), sub: "terrain raster" }, + { label: "Draw / urban", value: formatMs(viewport.renderBreakdown.urbanFill || 0), sub: "land-use overlay" }, + { label: "Draw / coast", value: formatMs(viewport.renderBreakdown.coastline || 0), sub: "coastline vectors" }, + { label: "Draw / rivers", value: formatMs(viewport.renderBreakdown.rivers || 0), sub: "river paths" }, + { label: "Draw / admin", value: formatMs(viewport.renderBreakdown.adminBorders || 0), sub: "fills and borders" }, + { label: "Draw / transport", value: formatMs(viewport.renderBreakdown.transport || 0), sub: "roads and rail" }, + { label: "Draw / labels", value: formatMs((viewport.renderBreakdown.icons || 0) + (viewport.renderBreakdown.labels || 0)), sub: "icons and text" }, + ] : []; renderDiagnosticGrid(advancedViewportDiagnosticsEl, viewport ? [ { label: "Viewport", value: `${viewport.viewWidth}×${viewport.viewHeight}`, sub: `${formatAreaCells(viewport.cells)} drawn` }, { label: "Canvas CSS", value: `${Math.round(viewport.cssWidth)}×${Math.round(viewport.cssHeight)}`, sub: "display pixels" }, @@ -990,6 +1000,7 @@ function renderViewportDiagnostics() { { label: "Canvas draw", value: formatMs(viewport.drawMs), sub: "drawMap" }, { label: "Hover index", value: formatMs(viewport.hoverMs), sub: "labels / hit targets" }, { label: "Render total", value: formatMs(viewport.totalRenderMs), sub: `${viewport.mode} mode` }, + ...renderRows, ] : [], "No viewport render recorded yet."); const counts = state.diagnostics.lastFeatureCounts; @@ -1056,13 +1067,15 @@ function performanceMetricsForRuns(runs) { const totals = summarizeValues(numericValues(runs, (run) => run.totalMs)); const perArea = summarizeValues(numericValues(runs, (run) => run.secondsPerThousand)); if (!totals) return []; + const hasSmallAreaRuns = (runs || []).some((run) => Number(run?.areaCells) > 0 && Number(run.areaCells) < 1000); + const perAreaSub = hasSmallAreaRuns ? "area-normalized; small runs include fixed overhead" : "seconds / 1k cells"; return [ { label: "Samples", value: String(totals.count), sub: "last 10" }, { label: "Avg total", value: formatMs(totals.avg), sub: "generation time" }, { label: "Max total", value: formatMs(totals.max), sub: "slowest run" }, { label: "P95 total", value: formatMs(totals.p95), sub: "tail latency" }, - perArea ? { label: "Avg / 1k", value: formatSeconds(perArea.avg), sub: "seconds / 1k cells" } : null, - perArea ? { label: "P95 / 1k", value: formatSeconds(perArea.p95), sub: "area-normalized" } : null, + perArea ? { label: "Avg / 1k", value: formatSeconds(perArea.avg), sub: perAreaSub } : null, + perArea ? { label: "P95 / 1k", value: formatSeconds(perArea.p95), sub: hasSmallAreaRuns ? "fixed overhead dominated below 1k cells" : "area-normalized" } : null, ]; } @@ -1072,6 +1085,10 @@ function recordGenerationRun(map, terrainType) { const totalMs = Number.isFinite(map.generationTotalMs) ? map.generationTotalMs : (map.generationTimings || []).reduce((sum, row) => sum + (Number(row?.ms) || 0), 0); + const featureTimings = (map.transportDebug?.featureTimings || []).map((row) => ({ + label: `Settlements / ${row.key || "substage"}`, + ms: Number(row.ms) || 0, + })); pushCapped(state.generationRuns, { id: Date.now(), createdAt: new Date(), @@ -1082,7 +1099,7 @@ function recordGenerationRun(map, terrainType) { timings: (map.generationTimings || []).map((row) => ({ label: row.label || row.key || "Stage", ms: Number(row.ms) || 0, - })), + })).concat(featureTimings), }); renderAdvancedData(); } @@ -1187,7 +1204,7 @@ function appendRunHistory(container, runs, options = {}) { ${name} ${formatMs(run.totalMs)} ${formatAreaCells(run.areaCells)}${options.patch && run.writeAreaCells ? ` / write ${formatAreaCells(run.writeAreaCells)}` : ""} - ${formatSeconds(run.secondsPerThousand)} / 1k cells + ${formatSeconds(run.secondsPerThousand)} / 1k cells${run.areaCells && run.areaCells < 1000 ? " (fixed overhead)" : ""} `; const body = document.createElement("div"); @@ -2235,7 +2252,7 @@ function redraw(options = {}) { state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap); const hoverMs = performance.now() - hoverStartedAt; const drawStartedAt = performance.now(); - drawMap(canvas, state.viewportMap, { + const renderBreakdown = drawMap(canvas, state.viewportMap, { mode: state.mode, showFeatures: state.showFeatures && !options.fastTerrain, showLabels: state.showLabels && !options.fastTerrain, @@ -2251,6 +2268,7 @@ function redraw(options = {}) { hoverMs, drawMs, totalRenderMs: performance.now() - redrawStartedAt, + renderBreakdown, }); renderAdvancedData(); } diff --git a/mapPatch.js b/mapPatch.js index 3406180..86c0bba 100644 --- a/mapPatch.js +++ b/mapPatch.js @@ -3,6 +3,7 @@ import { generateMap } from "./mapPipeline.js"; import { generateTerrainRect } from "./mapTerrain.js"; import { LANDUSE } from "./landuseCodes.js"; import { reconcileMunicipalMetadata, refreshPrefectureRegionsMetadata } from "./mapMunicipalCoherence.js"; +import { createPatchContext } from "./mapPatchContext.js"; export const PATCH_MIN_WIDTH = 48; export const PATCH_MIN_HEIGHT = 48; @@ -1358,8 +1359,8 @@ function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) { return { adminSeamCellsResolved, prefectureSeamCellsResolved }; } -function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, seaLevel = 0.30) { - const window = sourceWindowForRects(rects); +function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, seaLevel = 0.30, patchContext = null) { + const window = patchContext?.candidateWindow || sourceWindowForRects(rects); const elevationAdjustment = computeElevationCandidateAdjustment(world, candidate, rects, window, seed); const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null; const oldLanduse = world.fields.landuse ? new world.fields.landuse.constructor(world.fields.landuse) : null; @@ -1381,6 +1382,7 @@ function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, let adminCellsReassigned = 0; let landUseCellsUpdated = 0; + const fieldEntries = []; for (const [name, source] of Object.entries(candidate || {})) { if (SKIP_CELL_FIELDS.has(name) || !isCandidateCellField(candidate, source, window)) continue; const dest = ensureWorldField(world, name, source); @@ -1388,7 +1390,11 @@ function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, const isFloat = source.constructor === Float32Array || source.constructor === Float64Array; const isDiscrete = DISCRETE_FIELD_NAMES.has(name) || !isFloat; const idOffset = fieldIdOffset(name, seed); + fieldEntries.push({ name, source, dest, isDiscrete, idOffset }); + } + const cells = patchContext?.writeCells?.length ? patchContext.writeCells : (() => { + const out = []; 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); @@ -1396,33 +1402,39 @@ function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, const si = sourceIndexForWorld(rects, window, x, y); if (si < 0) continue; const alpha = patchAlpha(x, y, rects, seed); - if (alpha <= 0.005) continue; + if (alpha > 0.005) out.push({ x, y, wi, si, alpha }); + } + } + return out; + })(); - if (isDiscrete) { - const threshold = continuityReplaceThreshold(name, x, y, rects, seed); - if (alpha >= threshold) { - const raw = source[si]; - const mapped = remapAdminCandidateValue(name, raw, adminIdMapping); - const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId") - ? mapped - : 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; - let candidateValue = source[si] || 0; - if (name === "elevation") candidateValue = adjustedCandidateElevation(candidateValue, x, y, elevationAdjustment); - dest[wi] = lerp(before, candidateValue, alpha); - } + for (const cell of cells) { + const { x, y, wi, si, alpha } = cell; + for (const entry of fieldEntries) { + const { name, source, dest, isDiscrete, idOffset } = entry; + if (isDiscrete) { + const threshold = continuityReplaceThreshold(name, x, y, rects, seed); + if (alpha < threshold) continue; + const raw = source[si]; + const mapped = remapAdminCandidateValue(name, raw, adminIdMapping); + const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId") + ? mapped + : 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; + let candidateValue = source[si] || 0; + if (name === "elevation") candidateValue = adjustedCandidateElevation(candidateValue, x, y, elevationAdjustment); + dest[wi] = lerp(before, candidateValue, alpha); + } - if (name === "elevation") { - updatedCells++; - if (alpha > 0.94) terrainCellsFullyReplaced++; - } + if (name === "elevation") { + updatedCells++; + if (alpha > 0.94) terrainCellsFullyReplaced++; } } } @@ -3212,7 +3224,6 @@ function reconnectTransportGraph(world, sourceMap, rects, seed, mode, graphRect, const rejectedPairs = new Set(); let attemptsRemaining = options.maxAttempts ?? (mode === "rail" ? 4 : 8); for (let pass = 0; pass < maxAdds && attemptsRemaining > 0; pass++) { - snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed); const dirtyCount = snapshot.comps.filter(eligible).length; if (dirtyCount <= 1) break; const comps = snapshot.comps.filter(contextual); @@ -3285,13 +3296,13 @@ function reconnectTransportGraph(world, sourceMap, rects, seed, mode, graphRect, rejectedPairs.add(transportPairSignature(b, a, mode)); continue; } + snapshot = checkSnapshot; debug[`${mode}GraphConnectorsAdded`] += writeResult.wrote; accepted = true; break; } if (!accepted) break; } - snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed); debug[`${mode}GraphAfterComponents`] = snapshot.comps.filter(eligible).length; return debug; } @@ -3438,7 +3449,6 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { // repair is intentionally allowed to operate in a much broader neighborhood, // because clipping graph repairs to the selected polygon leaves implausible // dangling regional networks just outside the patch. - const connectorPatchOptions = null; const externalRoadAnchors = collectExternalNetworkAnchors(world, sourceMap, ["nationalRoads", "minorRoads", "premodernRoads", "externalRoads"], rects.writeRect, transportRect, "road"); const externalRailAnchors = collectExternalNetworkAnchors(world, sourceMap, ["railways", "branchRailways", "externalRailways"], rects.writeRect, transportRect, "rail"); roadAnchors = roadAnchors.concat(externalRoadAnchors); @@ -3447,9 +3457,6 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { // visible fragments that did not reduce disconnected components. Keep the // anchor collection for diagnostics, but route all actual repair through the // graph reconnection pass below, which rolls back failed candidates. - const roadConn = { connectors: 0, disconnected: roadAnchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 }; - const railConn = { connectors: 0, disconnected: railAnchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 }; - const settlementRoadConnectors = { connectors: 0, skippedServedSettlements: 0, checkedSettlementCoverage: 0 }; const graphRect = strictMask ? transportRect : transportRect; @@ -3459,14 +3466,14 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { roadsClipped, railsClipped, regeneratedPaths, - roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors + (roadGraph.roadGraphConnectorsAdded || 0), - railwayConnectorsCreated: railConn.connectors + (railGraph.railGraphConnectorsAdded || 0), - disconnectedRoadComponents: roadConn.disconnected, - disconnectedRailComponents: railConn.disconnected, - skippedConnectorAnchors: (roadConn.skippedConnectorAnchors || 0) + (railConn.skippedConnectorAnchors || 0), - connectorAttempts: (roadConn.connectorAttempts || 0) + (railConn.connectorAttempts || 0), - skippedServedSettlements: settlementRoadConnectors.skippedServedSettlements || 0, - checkedSettlementCoverage: settlementRoadConnectors.checkedSettlementCoverage || 0, + roadConnectorsCreated: roadGraph.roadGraphConnectorsAdded || 0, + railwayConnectorsCreated: railGraph.railGraphConnectorsAdded || 0, + disconnectedRoadComponents: roadAnchors.length, + disconnectedRailComponents: railAnchors.length, + skippedConnectorAnchors: 0, + connectorAttempts: (roadGraph.roadGraphCandidatesConsidered || 0) + (railGraph.railGraphCandidatesConsidered || 0), + skippedServedSettlements: 0, + checkedSettlementCoverage: 0, externalRoadAnchors: externalRoadAnchors.length, externalRailAnchors: externalRailAnchors.length, ...roadGraph, @@ -4208,6 +4215,82 @@ function addInvalidatedRect(world, rect) { if (!list.some((r) => rectKey(r) === key)) list.push({ ...normalized }); } +function repairPatchTerrain(world, rects, seed, seaLevel) { + const terrainSeamDebug = featherTerrainSeam(world, rects, seed); + const elevationCliffDebug = smoothExtremeElevationSeams(world, rects, seed, seaLevel); + const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed); + const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed); + const residualSeaDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true }); + const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed); + const maskDebug = repairDisplayMasks(world, rects, seed); + recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel); + return { + terrainSeamDebug, + elevationCliffDebug, + waterDebug, + waterComponentDebug, + residualSeaDebug, + waterElevationDebug, + maskDebug, + }; +} + +function repairPatchAdministration(world, sourceMap, rects, seed, adminIdMapping, strictFieldSnapshot) { + const landDebug = repairLanduseAndPopulation(world, rects); + const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, adminIdMapping, seed); + const adminTopologyDebug = repairPatchAdministrativeTopology(world, rects); + const strictMaskDebugPreCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed); + const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, adminIdMapping); + const municipalCoherence = reconcileMunicipalMetadata({ + adminId: world.fields.adminId, + municipalityId: world.fields.municipalityId, + prefectureRegionId: world.fields.prefectureRegionId, + sea: world.fields.sea, + adminCenters: sourceMap.adminCenters || [], + municipalityToPrefectureId: sourceMap.municipalityToPrefectureId, + fields: world.fields, + width: world.width, + height: world.height, + pointOffsetX: world.originX || 0, + pointOffsetY: world.originY || 0, + seed, + }); + sourceMap.adminCenters = municipalCoherence.adminCenters; + sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId; + const prefectureCoherence = refreshPrefectureRegionsMetadata({ + prefectureRegionId: world.fields.prefectureRegionId, + sea: world.fields.sea, + existing: sourceMap.prefectureRegions || [], + adminCenters: sourceMap.adminCenters || [], + fields: world.fields, + width: world.width, + height: world.height, + pointOffsetX: world.originX || 0, + pointOffsetY: world.originY || 0, + }); + sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions; + const strictMaskDebugPostCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed); + sourceMap.adminDebug = { + ...(sourceMap.adminDebug || {}), + municipalCoherence: municipalCoherence.debug, + prefectureMetadataCoherence: prefectureCoherence.debug, + }; + return { + landDebug, + finalAdminCoverageDebug, + adminTopologyDebug, + strictMaskDebugPreCoherence, + strictMaskDebugPostCoherence, + sourceAdminMetadataUpdated, + municipalCoherence, + prefectureCoherence, + }; +} + +function repairPatchTransport(world, sourceMap, candidate, rects, window, seed) { + return mergePathLayers(world, sourceMap, candidate, rects, window, seed); +} + export function generatePatch(world, userRectInput, options = {}) { const validation = validatePatchRect(userRectInput, world); if (!validation.ok) return { ok: false, ...validation }; @@ -4258,85 +4341,80 @@ export function generatePatch(world, userRectInput, options = {}) { : (candidateWindow.variable ? "Variable candidate generation" : "Full candidate generation")); getPatchAlphaCache(rects, seed); getPatchSourceIndexCache(rects, candidateWindow); + const patchContext = createPatchContext({ + world, + rects, + candidateWindow, + seed, + patchAlpha, + sourceIndexForWorld, + worldIndex, + }); const sourceMap = world.sourceMap || (world.sourceMap = {}); const strictMetadataSnapshot = captureStrictMetadataSnapshot(world, sourceMap, rects, seed); const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap); const seaLevel = candidate.seaLevel || world.sourceMap?.seaLevel || 0.30; - const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed, sourceMap, seaLevel); + const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed, sourceMap, seaLevel, patchContext); patchTimer.mark("fields", "Field copy and alpha blend"); - const terrainSeamDebug = featherTerrainSeam(world, rects, seed); - const elevationCliffDebug = smoothExtremeElevationSeams(world, rects, seed, seaLevel); - const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed); - const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed); - const residualSeaDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true }); - const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed); - const maskDebug = repairDisplayMasks(world, rects, seed); - recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel); + const terrainDebug = repairPatchTerrain(world, rects, seed, seaLevel); patchTimer.mark("terrainRepair", "Water, masks, and terrain repair"); const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping); patchTimer.mark("points", "Point merge"); - const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed); + const pathDebug = repairPatchTransport(world, sourceMap, candidate, rects, fieldDebug.window, seed); patchTimer.mark("paths", "Path merge and connector repair"); const influenceDebug = refreshPatchInfluenceFields(world, sourceMap, rects); patchTimer.mark("influence", "Influence refresh"); - const landDebug = repairLanduseAndPopulation(world, rects); - const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping, seed); - const adminTopologyDebug = repairPatchAdministrativeTopology(world, rects); - const strictMaskDebugPreCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed); - const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping); - const municipalCoherence = reconcileMunicipalMetadata({ - adminId: world.fields.adminId, - municipalityId: world.fields.municipalityId, - prefectureRegionId: world.fields.prefectureRegionId, - sea: world.fields.sea, - adminCenters: sourceMap.adminCenters || [], - municipalityToPrefectureId: sourceMap.municipalityToPrefectureId, - fields: world.fields, - width: world.width, - height: world.height, - pointOffsetX: world.originX || 0, - pointOffsetY: world.originY || 0, - seed, - }); - sourceMap.adminCenters = municipalCoherence.adminCenters; - sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId; - const prefectureCoherence = refreshPrefectureRegionsMetadata({ - prefectureRegionId: world.fields.prefectureRegionId, - sea: world.fields.sea, - existing: sourceMap.prefectureRegions || [], - adminCenters: sourceMap.adminCenters || [], - fields: world.fields, - width: world.width, - height: world.height, - pointOffsetX: world.originX || 0, - pointOffsetY: world.originY || 0, - }); - sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions; - const strictMaskDebugPostCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed); + const adminDebug = repairPatchAdministration(world, sourceMap, rects, seed, fieldDebug.adminIdMapping, strictFieldSnapshot); const residualSeaStrictDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true, preservePockets: true }); if ((residualSeaStrictDebug.residualSeaCellsFilled || 0) > 0) { repairDisplayMasks(world, rects, seed); recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel); } const strictMetadataDebug = restoreOutsideStrictMetadata(world, sourceMap, rects, strictMetadataSnapshot, seed); - sourceMap.adminDebug = { - ...(sourceMap.adminDebug || {}), - municipalCoherence: municipalCoherence.debug, - prefectureMetadataCoherence: prefectureCoherence.debug, - }; const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed, candidate, fieldDebug.window); const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed); patchTimer.mark("segments", "Boundary and debug segment merge"); sanitizeExistingLogistics(sourceMap); patchTimer.mark("cleanup", "Land-use, admin, and label cleanup"); const patchTimings = patchTimer.timings; + const { + terrainSeamDebug, + elevationCliffDebug, + waterDebug, + waterComponentDebug, + residualSeaDebug, + waterElevationDebug, + maskDebug, + } = terrainDebug; + const { + landDebug, + finalAdminCoverageDebug, + adminTopologyDebug, + strictMaskDebugPreCoherence, + strictMaskDebugPostCoherence, + sourceAdminMetadataUpdated, + municipalCoherence, + prefectureCoherence, + } = adminDebug; const seaStats = countSea(world, rects.coreRect, rects, seed); const label = terrainLabel(candidate, terrainType); const id = terrainId(candidate, terrainType); + const patchStageDebug = { + candidate: { cacheHit, cacheSize, patchGenerationMode, candidateAreaRatio: candidateWindow.areaRatio ?? 1 }, + fields: fieldDebug, + terrain: terrainDebug, + points: pointDebug, + transport: pathDebug, + influence: influenceDebug, + admin: adminDebug, + segments: { ...segmentDebug, candidateCompartmentSegmentsAdded }, + cleanup: { logisticsLabelsMigrated, strictMetadataDebug, residualSeaStrictDebug }, + }; const humanGeography = { ok: true, + patchStages: patchStageDebug, 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, diff --git a/mapPatch.js.bak b/mapPatch.js.bak deleted file mode 100644 index da1dcd2..0000000 --- a/mapPatch.js.bak +++ /dev/null @@ -1,2660 +0,0 @@ -import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, smoothstep, valueNoise } from "./mapUtils.js"; -import { generateMap } from "./mapPipeline.js"; -import { LANDUSE } from "./landuseCodes.js"; -import { reconcileMunicipalMetadata, refreshPrefectureRegionsMetadata } from "./mapMunicipalCoherence.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 PATCH_CANDIDATE_CACHE_LIMIT = 3; - -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 ADMIN_CONTINUITY_FIELD_NAMES = new Set(["adminId", "municipalityId", "prefectureRegionId"]); -const NATURAL_CONTINUITY_FIELD_NAMES = new Set(["regionId", "naturalCompartmentId", "watershedId"]); -const CONTINUITY_FIELD_NAMES = new Set([...ADMIN_CONTINUITY_FIELD_NAMES, ...NATURAL_CONTINUITY_FIELD_NAMES]); - -const SKIP_CELL_FIELDS = new Set(["flowTo", "prefectureMask", "humanRegionMask"]); - -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 nowMs() { - return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); -} - -function createPatchTimer() { - const timings = []; - let mark = nowMs(); - return { - timings, - mark(key, label = key) { - const t = nowMs(); - timings.push({ key, label, ms: Math.round((t - mark) * 10) / 10 }); - mark = t; - }, - }; -} - -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 isPolygonSelection(input) { - return !!input && Array.isArray(input.polygon) && input.polygon.length >= 3; -} - -function clampPointToWorld(point, world) { - return { - x: clamp(Math.round(point.x ?? 0), 0, Math.max(0, (world?.width || 1) - 1)), - y: clamp(Math.round(point.y ?? 0), 0, Math.max(0, (world?.height || 1) - 1)), - }; -} - -function polygonBounds(polygon) { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (const p of polygon || []) { - if (!Number.isFinite(p?.x) || !Number.isFinite(p?.y)) continue; - minX = Math.min(minX, p.x); - minY = Math.min(minY, p.y); - maxX = Math.max(maxX, p.x); - maxY = Math.max(maxY, p.y); - } - if (!Number.isFinite(minX)) return null; - return { x0: Math.floor(minX), y0: Math.floor(minY), x1: Math.ceil(maxX + 1), y1: Math.ceil(maxY + 1) }; -} - -function polygonAreaCells(polygon) { - if (!polygon || polygon.length < 3) return 0; - let area = 0; - for (let i = 0; i < polygon.length; i++) { - const a = polygon[i]; - const b = polygon[(i + 1) % polygon.length]; - area += a.x * b.y - b.x * a.y; - } - return Math.abs(area) * 0.5; -} - -function normalizeSelectionShape(input, world = null) { - if (!isPolygonSelection(input)) return normalizeRect(input); - const polygon = (input.polygon || []).map((p) => world ? clampPointToWorld(p, world) : { x: Math.round(p.x), y: Math.round(p.y) }); - const bounds = polygonBounds(polygon); - if (!bounds) return null; - return { - kind: input.kind || 'lasso', - polygon, - areaCells: Math.max(1, Math.round(input.areaCells || polygonAreaCells(polygon))), - x0: bounds.x0, - y0: bounds.y0, - x1: bounds.x1, - y1: bounds.y1, - }; -} - -function pointInPolygon(px, py, polygon) { - let inside = false; - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const xi = polygon[i].x + 0.5; - const yi = polygon[i].y + 0.5; - const xj = polygon[j].x + 0.5; - const yj = polygon[j].y + 0.5; - const intersect = ((yi > py) !== (yj > py)) && (px < ((xj - xi) * (py - yi)) / Math.max(1e-6, (yj - yi)) + xi); - if (intersect) inside = !inside; - } - return inside; -} - -function pointSegmentDistance(px, py, ax, ay, bx, by) { - const dx = bx - ax; - const dy = by - ay; - const len2 = dx * dx + dy * dy; - if (len2 <= 1e-6) return Math.hypot(px - ax, py - ay); - const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1); - return Math.hypot(px - (ax + dx * t), py - (ay + dy * t)); -} - -function distanceToPolygonEdge(px, py, polygon) { - let best = Infinity; - for (let i = 0; i < polygon.length; i++) { - const a = polygon[i]; - const b = polygon[(i + 1) % polygon.length]; - best = Math.min(best, pointSegmentDistance(px, py, a.x + 0.5, a.y + 0.5, b.x + 0.5, b.y + 0.5)); - } - return best; -} - -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 = normalizeSelectionShape(rect, world); - if (!normalized || !world) return null; - if (isPolygonSelection(normalized)) return normalized; - 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 = isPolygonSelection(clipped) ? Math.max(1, Math.round(clipped.areaCells || polygonAreaCells(clipped.polygon))) : 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 = normalizeSelectionShape(userRect, world); - 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); - const transportReachMargin = Math.max( - repairMargin + 48, - Math.min(260, Math.max(96, repairMargin + Math.floor(shortSide * 1.15))) - ); - const transportReachRect = expandRect(coreRect, transportReachMargin, world); - return { - coreRect, - writeRect, - repairRect, - contextRect: repairRect, - transportReachRect, - blendRect: coreRect, - userRect: writeRect, - selectedRect: coreRect, - selectionShape: isPolygonSelection(coreRect) ? coreRect : null, - writeMargin, - repairMargin, - transportReachMargin, - outerMargin: writeMargin, - innerMargin: 0, - }; -} - -function computePatchAlpha(x, y, rects, seed = 0) { - const writeRect = rects.writeRect || rects.userRect; - if (!insideRect(x, y, writeRect)) return 0; - const margin = Math.max(1, rects.writeMargin || 1); - const low = valueNoise(x, y, seed ^ 0x7153a9d1, 18) - 0.5; - const mid = valueNoise(x, y, seed ^ 0x9e3779b9, 7) - 0.5; - const shape = rects.selectionShape; - if (shape?.polygon?.length >= 3) { - const px = x + 0.5; - const py = y + 0.5; - const inside = pointInPolygon(px, py, shape.polygon); - const dist = distanceToPolygonEdge(px, py, shape.polygon); - const signedDist = inside ? dist : -dist; - const noisySigned = signedDist + low * margin * 0.28 + mid * margin * 0.10; - return clamp(smoothstep((noisySigned + margin) / Math.max(1e-6, margin * 2))); - } - const edge = distanceToRectEdge(x, y, writeRect); - 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 getPatchAlphaCache(rects, seed = 0) { - const writeRect = rects?.writeRect || rects?.userRect; - if (!writeRect) return null; - const width = rectWidth(writeRect); - const height = rectHeight(writeRect); - const existing = rects.patchAlphaCache; - if ( - existing - && existing.seed === seed - && existing.width === width - && existing.height === height - && existing.x0 === writeRect.x0 - && existing.y0 === writeRect.y0 - ) return existing; - - const data = new Float32Array(width * height); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - data[y * width + x] = computePatchAlpha(writeRect.x0 + x, writeRect.y0 + y, rects, seed); - } - } - rects.patchAlphaCache = { seed, width, height, x0: writeRect.x0, y0: writeRect.y0, data }; - return rects.patchAlphaCache; -} - -function patchAlpha(x, y, rects, seed = 0) { - const writeRect = rects?.writeRect || rects?.userRect; - if (!writeRect || !insideRect(x, y, writeRect)) return 0; - const cache = rects.patchAlphaCache; - if ( - cache - && cache.seed === seed - && x >= cache.x0 - && y >= cache.y0 - && x < cache.x0 + cache.width - && y < cache.y0 + cache.height - ) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)] || 0; - return computePatchAlpha(x, y, rects, seed); -} - -function patchBand(x, y, rects, seed = 0) { - const a = patchAlpha(x, y, rects, seed); - if (a <= 0.18) return "preserve"; - if (a >= 0.82) return "core"; - return "feather"; -} - -function continuityReplaceThreshold(name, x, y, rects, seed = 0) { - const n = valueNoise(x, y, seed ^ 0x4f1bbcdc, 11) - 0.5; - if (ADMIN_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.82 + n * 0.12, 0.70, 0.92); - if (NATURAL_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.68 + n * 0.16, 0.54, 0.82); - return clamp(0.46 + n * 0.20, 0.28, 0.68); -} - -function continuitySegmentAllowed(x, y, nx, ny, rects, seed, minAlpha = 0.42) { - if (!rects) return true; - return Math.min(patchAlpha(x, y, rects, seed), patchAlpha(nx, ny, rects, seed)) >= minAlpha; -} - -function patchAffected(x, y, rects, seed = 0, minAlpha = 0.34) { - return insideRect(Math.round(x), Math.round(y), rects?.writeRect) && patchAlpha(Math.round(x), Math.round(y), rects, seed) >= minAlpha; -} - -function segmentTouchesPatch(world, seg, rects, seed = 0, minAlpha = 0.34) { - if (!Array.isArray(seg) || seg.length < 2) return false; - const ax = tupleWorldX(world, seg[0]); - const ay = tupleWorldY(world, seg[0]); - const bx = tupleWorldX(world, seg[1]); - const by = tupleWorldY(world, seg[1]); - const mx = (ax + bx) * 0.5; - const my = (ay + by) * 0.5; - return patchAffected(ax, ay, rects, seed, minAlpha) || patchAffected(bx, by, rects, seed, minAlpha) || patchAffected(mx, my, rects, seed, minAlpha); -} - -function segmentTouchesRect(world, seg, rect) { - if (!Array.isArray(seg) || seg.length < 2 || !rect) return false; - const ax = tupleWorldX(world, seg[0]); - const ay = tupleWorldY(world, seg[0]); - const bx = tupleWorldX(world, seg[1]); - const by = tupleWorldY(world, seg[1]); - const mx = (ax + bx) * 0.5; - const my = (ay + by) * 0.5; - if (insideRect(ax, ay, rect) || insideRect(bx, by, rect) || insideRect(mx, my, rect)) return true; - const minX = Math.min(ax, bx); - const maxX = Math.max(ax, bx); - const minY = Math.min(ay, by); - const maxY = Math.max(ay, by); - return maxX >= rect.x0 && minX < rect.x1 && maxY >= rect.y0 && minY < rect.y1; -} - -function quantizedSegmentKey(seg) { - if (!Array.isArray(seg) || seg.length < 2) return ""; - const p = seg.map(([x, y]) => [Math.round(x * 4) / 4, Math.round(y * 4) / 4]); - const a = `${p[0][0]},${p[0][1]}`; - const b = `${p[1][0]},${p[1][1]}`; - return a < b ? `${a}|${b}` : `${b}|${a}`; -} - -function dedupeSegments(segments) { - const seen = new Set(); - const out = []; - for (const seg of segments || []) { - const key = quantizedSegmentKey(seg); - if (!key || seen.has(key)) continue; - seen.add(key); - out.push(seg); - } - return out; -} - - -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 getPatchSourceIndexCache(rects, window) { - const writeRect = rects?.writeRect; - if (!writeRect || !window) return null; - const width = rectWidth(writeRect); - const height = rectHeight(writeRect); - const existing = rects.patchSourceIndexCache; - if ( - existing - && existing.width === width - && existing.height === height - && existing.x0 === writeRect.x0 - && existing.y0 === writeRect.y0 - && existing.worldCenterX === window.worldCenterX - && existing.worldCenterY === window.worldCenterY - && existing.sourceCenterX === window.sourceCenterX - && existing.sourceCenterY === window.sourceCenterY - ) return existing; - - const data = new Int32Array(width * height); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const sx = Math.round(writeRect.x0 + x - window.worldCenterX + window.sourceCenterX); - const sy = Math.round(writeRect.y0 + y - window.worldCenterY + window.sourceCenterY); - data[y * width + x] = sourceIndex(sx, sy); - } - } - rects.patchSourceIndexCache = { - width, - height, - x0: writeRect.x0, - y0: writeRect.y0, - worldCenterX: window.worldCenterX, - worldCenterY: window.worldCenterY, - sourceCenterX: window.sourceCenterX, - sourceCenterY: window.sourceCenterY, - data, - }; - return rects.patchSourceIndexCache; -} - -function sourceIndexForWorld(rects, window, x, y) { - const cache = rects?.patchSourceIndexCache; - if ( - cache - && x >= cache.x0 - && y >= cache.y0 - && x < cache.x0 + cache.width - && y < cache.y0 + cache.height - ) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)]; - const s = sourceCoordForWorld(window, x, y); - return sourceIndex(s.x, s.y); -} - -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 maxFieldId(field) { - if (!field) return -1; - let max = -1; - for (let i = 0; i < field.length; i++) { - const id = field[i]; - if (Number.isFinite(id) && id > max) max = id; - } - return max; -} - -function addMappingVote(votes, from, to, weight = 1) { - if (!Number.isFinite(from) || from < 0 || !Number.isFinite(to) || to < 0) return; - const key = Math.floor(from); - const target = Math.floor(to); - const bucket = votes.get(key) || new Map(); - bucket.set(target, (bucket.get(target) || 0) + Math.max(1, weight)); - votes.set(key, bucket); -} - -function chooseVotedTarget(bucket, minVotes = 1) { - let best = -1; - let bestVotes = 0; - for (const [target, count] of bucket || []) { - if (count > bestVotes || (count === bestVotes && target < best)) { - best = target; - bestVotes = count; - } - } - return best >= 0 && bestVotes >= minVotes ? best : -1; -} - -function collectCandidateIdsInRect(candidateField, rects, window, minAlpha = 0.20, seed = 0) { - const ids = new Set(); - if (!candidateField) return ids; - const rect = rects.writeRect; - for (let y = rect.y0; y < rect.y1; y++) { - for (let x = rect.x0; x < rect.x1; x++) { - if (patchAlpha(x, y, rects, seed) < minAlpha) continue; - const si = sourceIndexForWorld(rects, window, x, y); - if (si < 0) continue; - const id = candidateField[si]; - if (Number.isFinite(id) && id >= 0) ids.add(Math.floor(id)); - } - } - return ids; -} - -function buildCandidatePrefByAdmin(candidateMap, candidateAdminIds) { - const out = new Map(); - const direct = candidateMap?.municipalityToPrefectureId; - for (const id of candidateAdminIds || []) { - const pref = direct?.[id]; - if (Number.isFinite(pref) && pref >= 0) out.set(id, Math.floor(pref)); - } - const adminField = candidateMap?.adminId || candidateMap?.municipalityId; - const prefField = candidateMap?.prefectureRegionId; - if (!adminField || !prefField) return out; - const votes = new Map(); - for (let i = 0; i < adminField.length; i++) { - const admin = adminField[i]; - const pref = prefField[i]; - if (!Number.isFinite(admin) || admin < 0 || !Number.isFinite(pref) || pref < 0) continue; - if (candidateAdminIds?.size && !candidateAdminIds.has(Math.floor(admin))) continue; - addMappingVote(votes, admin, pref, 1); - } - for (const [admin, bucket] of votes) { - if (!out.has(admin)) { - const pref = chooseVotedTarget(bucket, 1); - if (pref >= 0) out.set(admin, pref); - } - } - return out; -} - -export function buildAdminIdMapping({ candidateMap, world, writeRect, seamBand = 24, window = null, rects = null, seed = 0 } = {}) { - const actualWindow = window || (rects ? sourceWindowForRects(rects) : null); - const actualRects = rects || { writeRect, writeMargin: seamBand || 1 }; - if (!candidateMap || !world || !writeRect || !actualWindow) { - return { - prefecture: new Map(), - municipality: new Map(), - admin: new Map(), - candidateAdminToPrefecture: new Map(), - debug: { prefecturesMappedToExisting: 0, prefecturesAllocated: 0, municipalitiesMappedToExisting: 0, municipalitiesAllocated: 0 }, - }; - } - - const candidateAdmin = candidateMap.adminId || candidateMap.municipalityId; - const candidateMunicipality = candidateMap.municipalityId || candidateAdmin; - const candidatePrefecture = candidateMap.prefectureRegionId; - const worldAdmin = world.fields?.adminId || world.fields?.municipalityId; - const worldMunicipality = world.fields?.municipalityId || worldAdmin; - const worldPrefecture = world.fields?.prefectureRegionId; - - const candidateAdminIds = collectCandidateIdsInRect(candidateAdmin, actualRects, actualWindow, 0.18, seed); - const candidateMunicipalityIds = collectCandidateIdsInRect(candidateMunicipality, actualRects, actualWindow, 0.18, seed); - const candidatePrefectureIds = collectCandidateIdsInRect(candidatePrefecture, actualRects, actualWindow, 0.18, seed); - const candidateAdminToPrefecture = buildCandidatePrefByAdmin(candidateMap, candidateAdminIds); - - const adminVotes = new Map(); - const municipalityVotes = new Map(); - const prefectureVotes = new Map(); - const band = Math.max(2, Math.floor(seamBand)); - const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; - - for (let y = writeRect.y0; y < writeRect.y1; y++) { - for (let x = writeRect.x0; x < writeRect.x1; x++) { - const edge = distanceToRectEdge(x, y, writeRect); - if (edge > band) continue; - const s = sourceCoordForWorld(actualWindow, x, y); - const si = sourceIndex(s.x, s.y); - const wi = worldIndex(world, x, y); - if (si < 0 || wi < 0) continue; - const cAdmin = candidateAdmin?.[si] ?? -1; - const cMunicipality = candidateMunicipality?.[si] ?? cAdmin; - const cPrefecture = candidatePrefecture?.[si] ?? -1; - const sameCellWeight = Math.max(1, band + 1 - edge); - addMappingVote(adminVotes, cAdmin, worldAdmin?.[wi] ?? -1, sameCellWeight); - addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[wi] ?? worldAdmin?.[wi] ?? -1, sameCellWeight); - addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[wi] ?? -1, sameCellWeight); - - for (const [dx, dy] of dirs) { - for (let step = 1; step <= 6; step++) { - const nx = x + dx * step; - const ny = y + dy * step; - if (insideRect(nx, ny, writeRect)) continue; - const ni = worldIndex(world, nx, ny); - if (ni < 0) break; - const w = Math.max(1, 7 - step) + Math.max(0, band - edge) * 0.25; - addMappingVote(adminVotes, cAdmin, worldAdmin?.[ni] ?? -1, w); - addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[ni] ?? worldAdmin?.[ni] ?? -1, w); - addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[ni] ?? -1, w); - break; - } - } - } - } - - const prefecture = new Map(); - let nextPrefectureId = maxFieldId(worldPrefecture) + 1; - let prefecturesMappedToExisting = 0; - let prefecturesAllocated = 0; - for (const id of [...candidatePrefectureIds].sort((a, b) => a - b)) { - const voted = chooseVotedTarget(prefectureVotes.get(id), 3); - if (voted >= 0) { - prefecture.set(id, voted); - prefecturesMappedToExisting++; - } else { - prefecture.set(id, nextPrefectureId++); - prefecturesAllocated++; - } - } - - const usedAdminIds = new Set(); - if (worldAdmin) { - for (let i = 0; i < worldAdmin.length; i++) if (worldAdmin[i] >= 0) usedAdminIds.add(worldAdmin[i]); - } - const municipality = new Map(); - const admin = new Map(); - let nextMunicipalityId = maxFieldId(worldAdmin || worldMunicipality) + 1; - let municipalitiesMappedToExisting = 0; - let municipalitiesAllocated = 0; - const allMunicipalityIds = new Set([...candidateAdminIds, ...candidateMunicipalityIds]); - for (const id of [...allMunicipalityIds].sort((a, b) => a - b)) { - const voted = chooseVotedTarget(municipalityVotes.get(id) || adminVotes.get(id), 4); - if (voted >= 0) { - municipality.set(id, voted); - admin.set(id, voted); - municipalitiesMappedToExisting++; - } else { - while (usedAdminIds.has(nextMunicipalityId)) nextMunicipalityId++; - municipality.set(id, nextMunicipalityId); - admin.set(id, nextMunicipalityId); - usedAdminIds.add(nextMunicipalityId); - nextMunicipalityId++; - municipalitiesAllocated++; - } - } - - const municipalityToPrefecture = new Map(); - for (const [candidateAdminId, worldAdminId] of admin) { - const candidatePrefId = candidateAdminToPrefecture.get(candidateAdminId); - const worldPrefId = prefecture.get(candidatePrefId); - if (Number.isFinite(worldAdminId) && Number.isFinite(worldPrefId)) municipalityToPrefecture.set(worldAdminId, worldPrefId); - } - - return { - prefecture, - municipality, - admin, - candidateAdminToPrefecture, - municipalityToPrefecture, - debug: { - candidatePrefectureIds: candidatePrefectureIds.size, - candidateMunicipalityIds: allMunicipalityIds.size, - prefecturesMappedToExisting, - prefecturesAllocated, - municipalitiesMappedToExisting, - municipalitiesAllocated, - }, - }; -} - -function remapAdminCandidateValue(name, raw, adminIdMapping) { - if (!Number.isFinite(raw) || raw < 0 || !adminIdMapping) return raw; - const id = Math.floor(raw); - if (name === "prefectureRegionId") return adminIdMapping.prefecture?.get(id) ?? raw; - if (name === "adminId") return adminIdMapping.admin?.get(id) ?? raw; - if (name === "municipalityId") return adminIdMapping.municipality?.get(id) ?? adminIdMapping.admin?.get(id) ?? raw; - return raw; -} - -function numericFeatureId(point, keys) { - for (const key of keys) { - const value = point?.[key]; - if (Number.isFinite(value) && value >= 0) return Math.floor(value); - } - return -1; -} - -function summarizeIdMapping(mapping) { - return { ...(mapping?.debug || {}) }; -} - -function updateSourceAdminMetadata(sourceMap, adminIdMapping) { - if (!sourceMap || !adminIdMapping?.municipalityToPrefecture?.size) return 0; - let maxId = -1; - const current = sourceMap.municipalityToPrefectureId; - if (current && typeof current.length === "number") maxId = Math.max(maxId, current.length - 1); - for (const [adminId] of adminIdMapping.municipalityToPrefecture) maxId = Math.max(maxId, adminId); - const next = new Int32Array(Math.max(0, maxId + 1)); - next.fill(-1); - if (current && typeof current.length === "number") { - for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1; - } - let updated = 0; - for (const [adminId, prefId] of adminIdMapping.municipalityToPrefecture) { - if (!Number.isFinite(adminId) || adminId < 0 || !Number.isFinite(prefId) || prefId < 0) continue; - if (next[adminId] !== prefId) updated++; - next[adminId] = prefId; - } - sourceMap.municipalityToPrefectureId = next; - sourceMap.patchAdminIdMappingDebug = summarizeIdMapping(adminIdMapping); - return updated; -} - -function cloneContinuityFields(world) { - const out = new Map(); - for (const name of CONTINUITY_FIELD_NAMES) { - const field = world?.fields?.[name]; - if (ArrayBuffer.isView(field)) out.set(name, new field.constructor(field)); - } - return out; -} - -function stabilizeContinuitySeam(world, rects, oldFields, seed = 0) { - let restored = 0; - let remapped = 0; - const margin = Math.max(2, rects.writeMargin || 1); - for (const name of CONTINUITY_FIELD_NAMES) { - const field = world.fields?.[name]; - const old = oldFields?.get(name); - if (!field || !old) continue; - const isPrefecture = name === "prefectureRegionId"; - const isAdmin = name === "adminId" || name === "municipalityId"; - const preserveAlpha = isPrefecture ? 0.94 : isAdmin ? 0.90 : 0.74; - const preserveEdge = isPrefecture ? margin * 1.25 : isAdmin ? margin : margin * 0.72; - - // First preserve the old IDs in the transition band. This prevents the - // writeRect edge from becoming a prefecture/municipal border. - 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 || old[i] < 0) continue; - const edge = distanceToRectEdge(x, y, rects.writeRect); - const a = patchAlpha(x, y, rects, seed); - if (edge <= preserveEdge || a < preserveAlpha) { - if (field[i] !== old[i]) { field[i] = old[i]; restored++; } - } - } - } - - // Then map candidate IDs that contact an outside ID back to that outside ID. - // This lets prefectures/municipalities cross the generated-area seam instead - // of creating a new border exactly on the seam. - const contacts = new Map(); - const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; - for (let y = rects.writeRect.y0 + 1; y < rects.writeRect.y1 - 1; y++) { - for (let x = rects.writeRect.x0 + 1; x < rects.writeRect.x1 - 1; x++) { - const i = worldIndex(world, x, y); - if (i < 0 || field[i] < 0 || old[i] === field[i]) continue; - const a = patchAlpha(x, y, rects, seed); - if (a < 0.98 && !isPrefecture) continue; - for (const [dx, dy] of dirs) { - const ni = worldIndex(world, x + dx, y + dy); - if (ni < 0 || old[ni] < 0 || old[ni] === field[i]) continue; - if (field[ni] === old[ni] || patchAlpha(x + dx, y + dy, rects, seed) < preserveAlpha) { - const key = field[i]; - const bucket = contacts.get(key) || new Map(); - bucket.set(old[ni], (bucket.get(old[ni]) || 0) + 1); - contacts.set(key, bucket); - } - } - } - } - const mapping = new Map(); - for (const [from, bucket] of contacts) { - let best = -1, bestCount = 0; - for (const [to, count] of bucket) if (count > bestCount) { best = to; bestCount = count; } - if (best >= 0 && bestCount >= (isPrefecture ? 2 : 3)) mapping.set(from, best); - } - if (mapping.size) { - 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 && mapping.has(field[i])) { field[i] = mapping.get(field[i]); remapped++; } - } - } - } - } - return { continuityCellsRestored: restored, continuityCellsRemapped: remapped }; -} - -function chooseSeamOwnerValue(world, fieldName, oldField, candidateValue, x, y, rects, seed) { - const a = patchAlpha(x, y, rects, seed); - const i = worldIndex(world, x, y); - const oldValue = oldField?.[i] ?? -1; - if (oldValue < 0 || candidateValue < 0) return candidateValue >= 0 ? candidateValue : oldValue; - if (a <= 0.24) return oldValue; - if (a >= 0.82) return candidateValue; - - const field = world.fields?.[fieldName]; - const pref = world.fields?.prefectureRegionId; - const naturalBarrier = world.fields?.naturalBarrierScore || world.fields?.ridgeField; - let oldScore = (1 - a) * 3.0; - let candidateScore = a * 3.0; - for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { - const ni = worldIndex(world, x + dx, y + dy); - if (ni < 0) continue; - const neighbor = field?.[ni] ?? -1; - if (neighbor === oldValue) oldScore += 1.2; - if (neighbor === candidateValue) candidateScore += 1.2; - if (fieldName !== "prefectureRegionId" && pref && pref[ni] >= 0) { - if (pref[ni] === pref[i] && candidateValue !== oldValue) oldScore += 0.18; - } - } - const barrierBonus = naturalBarrier?.[i] || 0; - if (barrierBonus > 0.48 && Math.abs(a - 0.5) < 0.24) { - if (a < 0.5) oldScore += barrierBonus * 0.9; - else candidateScore += barrierBonus * 0.9; - } - return candidateScore > oldScore ? candidateValue : oldValue; -} - -function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) { - let adminSeamCellsResolved = 0; - let prefectureSeamCellsResolved = 0; - for (const name of ["prefectureRegionId", "adminId", "municipalityId"]) { - const field = world.fields?.[name]; - const old = oldFields?.get(name); - if (!field || !old) continue; - 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 || world.fields.sea?.[i]) continue; - if (patchBand(x, y, rects, seed) !== "feather") continue; - const before = field[i]; - const next = chooseSeamOwnerValue(world, name, old, before, x, y, rects, seed); - if (next !== before) { - field[i] = next; - if (name === "prefectureRegionId") prefectureSeamCellsResolved++; - else adminSeamCellsResolved++; - } - } - } - } - if (world.fields.adminId && world.fields.municipalityId) { - 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 && !world.fields.sea?.[i]) world.fields.municipalityId[i] = world.fields.adminId[i]; - } - } - } - return { adminSeamCellsResolved, prefectureSeamCellsResolved }; -} - -function copyFullPipelineFields(world, candidate, rects, seed) { - const window = sourceWindowForRects(rects); - const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null; - const oldContinuityFields = cloneContinuityFields(world); - const adminIdMapping = buildAdminIdMapping({ - candidateMap: candidate, - world, - writeRect: rects.writeRect, - seamBand: Math.max(8, Math.floor(rects.writeMargin || 24)), - window, - rects, - seed, - }); - 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 si = sourceIndexForWorld(rects, window, x, y); - if (si < 0) continue; - const alpha = patchAlpha(x, y, rects, seed); - if (alpha <= 0.005) continue; - - if (isDiscrete) { - const threshold = continuityReplaceThreshold(name, x, y, rects, seed); - if (alpha >= threshold) { - const raw = source[si]; - const mapped = remapAdminCandidateValue(name, raw, adminIdMapping); - const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId") - ? mapped - : 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++; - } - } - } - } - - // The legacy full pipeline uses `adminId` as the municipality raster and - // assigns municipality metadata on `adminCenters`; it does not expose a - // separate municipalityId cell field. If an old experimental field exists, - // keep it synchronized with the canonical legacy adminId instead of leaving - // stale numeric/one-municipality data in regenerated patches. - if (world.fields.adminId && !candidate?.municipalityId) { - const expected = world.width * world.height; - if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) { - world.fields.municipalityId = new Int32Array(expected); - world.fields.municipalityId.fill(-1); - } - const municipalityId = world.fields.municipalityId; - const adminId = world.fields.adminId; - 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) municipalityId[wi] = adminId[wi]; - } - } - } - - // 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; - } - } - } - } - - const continuityDebug = stabilizeContinuitySeam(world, rects, oldContinuityFields, seed); - const seamOwnershipDebug = repairDiscreteSeamOwnership(world, rects, oldContinuityFields, seed); - if (world.fields.adminId && world.fields.municipalityId && !candidate?.municipalityId) { - 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) world.fields.municipalityId[wi] = world.fields.adminId[wi]; - } - } - } - - return { - window, - updatedCells, - terrainCellsFullyReplaced, - coastCellsChanged, - naturalRegionsUpdated, - adminCellsReassigned, - landUseCellsUpdated, - adminIdMapping, - adminIdMappingDebug: summarizeIdMapping(adminIdMapping), - ...continuityDebug, - ...seamOwnershipDebug, - }; -} - - -function repairDisplayMasks(world, rects, seed = 0) { - const expected = world.width * world.height; - if (!world.fields.prefectureMask || world.fields.prefectureMask.length !== expected) world.fields.prefectureMask = new Uint8Array(expected); - if (!world.fields.landMask || world.fields.landMask.length !== expected) world.fields.landMask = new Uint8Array(expected); - if (!world.fields.humanRegionMask || world.fields.humanRegionMask.length !== expected) world.fields.humanRegionMask = new Uint8Array(expected); - const coverage = world.fields.prefectureMask; - const landMask = world.fields.landMask; - const humanMask = world.fields.humanRegionMask; - const sea = world.fields.sea; - let displayMaskUpdated = 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; - const a = patchAlpha(x, y, rects, seed); - if (a <= 0.08) continue; - if (!coverage[i]) displayMaskUpdated++; - coverage[i] = 1; - const isSea = Boolean(sea?.[i]); - humanMask[i] = isSea ? 0 : 1; - landMask[i] = isSea ? 0 : 1; - } - } - return { displayMaskUpdated }; -} - -function featherTerrainSeam(world, rects, seed = 0) { - const fields = world.fields || {}; - const smoothKeys = [ - "elevation", "moisture", "ridgeField", "valleyField", "visibleRavineField", - "basinField", "coastalLowland", "plain", "agriculture", "erosionField", - "depositionField", "depositionalLowland", "alluvialFanField", "deltaField", - "naturalBarrierScore", "settlementScore", "populationDensity", - ]; - let terrainFeatherCells = 0; - let terrainFeatherValues = 0; - for (const key of smoothKeys) { - const field = fields[key]; - if (!field || !ArrayBuffer.isView(field)) continue; - const old = new field.constructor(field); - for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { - for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { - if (patchBand(x, y, rects, seed) !== "feather") continue; - const i = worldIndex(world, x, y); - if (i < 0 || fields.sea?.[i]) continue; - let sum = 0; - let count = 0; - for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { - const ni = worldIndex(world, x + dx, y + dy); - if (ni >= 0 && !fields.sea?.[ni]) { - sum += old[ni] || 0; - count++; - } - } - if (!count) continue; - const a = patchAlpha(x, y, rects, seed); - const neighborMean = sum / count; - const seamWeight = 0.34 * (1 - Math.abs(a - 0.5) * 1.2); - field[i] = lerp(field[i] || 0, neighborMean, clamp(seamWeight, 0.08, 0.34)); - terrainFeatherValues++; - if (key === "elevation") terrainFeatherCells++; - } - } - } - return { terrainFeatherCells, terrainFeatherValues }; -} - - -function nearestLandFieldValue(world, x, y, fieldName, rect, options = {}) { - const field = world.fields?.[fieldName]; - const sea = world.fields?.sea; - if (!field) return -1; - const maxRadius = Math.max(1, Math.floor(options.maxRadius || 18)); - const requiredPref = Number.isFinite(options.requiredPref) ? Math.floor(options.requiredPref) : null; - const prefField = world.fields?.prefectureRegionId; - for (let r = 1; r <= maxRadius; r++) { - let best = -1; - let bestD = Infinity; - const y0 = Math.max(0, y - r); - const y1 = Math.min(world.height - 1, y + r); - const x0 = Math.max(0, x - r); - const x1 = Math.min(world.width - 1, x + r); - for (let yy = y0; yy <= y1; yy++) { - for (let xx = x0; xx <= x1; xx++) { - if (Math.max(Math.abs(xx - x), Math.abs(yy - y)) !== r) continue; - if (rect && !insideRect(xx, yy, rect)) continue; - const i = worldIndex(world, xx, yy); - if (i < 0 || sea?.[i]) continue; - if (requiredPref !== null && prefField?.[i] !== requiredPref) continue; - const id = field[i]; - if (!Number.isFinite(id) || id < 0) continue; - const d = Math.hypot(xx - x, yy - y); - if (d < bestD) { best = Math.floor(id); bestD = d; } - } - } - if (best >= 0) return best; - } - return -1; -} - -function lookupPrefectureForAdmin(sourceMap, adminIdMapping, adminId) { - if (!Number.isFinite(adminId) || adminId < 0) return -1; - const id = Math.floor(adminId); - const mapped = adminIdMapping?.municipalityToPrefecture?.get(id); - if (Number.isFinite(mapped) && mapped >= 0) return Math.floor(mapped); - const table = sourceMap?.municipalityToPrefectureId; - if (table && id >= 0 && id < table.length && Number.isFinite(table[id]) && table[id] >= 0) return Math.floor(table[id]); - return -1; -} - -function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null, seed = 0) { - const admin = world.fields?.adminId; - if (!admin) return { seaAdminCellsCleared: 0, landAdminCellsFilled: 0, prefectureCellsFilled: 0, adminPrefectureCellsAligned: 0 }; - const expected = world.width * world.height; - if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) { - world.fields.municipalityId = new Int32Array(expected); - world.fields.municipalityId.fill(-1); - } - if (!world.fields.prefectureRegionId || world.fields.prefectureRegionId.length !== expected) { - world.fields.prefectureRegionId = new Int32Array(expected); - world.fields.prefectureRegionId.fill(-1); - } - const municipality = world.fields.municipalityId; - const prefecture = world.fields.prefectureRegionId; - const sea = world.fields.sea; - const coverage = world.fields.prefectureMask; - let seaAdminCellsCleared = 0; - let landAdminCellsFilled = 0; - let prefectureCellsFilled = 0; - let adminPrefectureCellsAligned = 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 (sea?.[i]) { - if (admin[i] >= 0 || municipality[i] >= 0 || prefecture[i] >= 0) seaAdminCellsCleared++; - admin[i] = -1; - municipality[i] = -1; - prefecture[i] = -1; - continue; - } - const generated = patchAlpha(x, y, rects, seed) > 0.08 || (!coverage && insideRect(x, y, rects.writeRect)); - if (!generated) continue; - - if (admin[i] < 0) { - const preferredPref = prefecture[i] >= 0 ? prefecture[i] : null; - let nearest = nearestLandFieldValue(world, x, y, 'adminId', rects.repairRect || rects.writeRect, { requiredPref: preferredPref, maxRadius: 24 }); - if (nearest < 0) nearest = nearestLandFieldValue(world, x, y, 'adminId', null, { requiredPref: preferredPref, maxRadius: 18 }); - if (nearest >= 0) { - admin[i] = nearest; - municipality[i] = nearest; - landAdminCellsFilled++; - } - } - if (municipality[i] < 0 && admin[i] >= 0) municipality[i] = admin[i]; - if (admin[i] >= 0 && municipality[i] !== admin[i]) municipality[i] = admin[i]; - - let targetPref = lookupPrefectureForAdmin(sourceMap, adminIdMapping, admin[i]); - if (targetPref < 0 && prefecture[i] < 0) targetPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', rects.repairRect || rects.writeRect, { maxRadius: 28 }); - if (targetPref >= 0 && prefecture[i] !== targetPref) { - if (prefecture[i] < 0) prefectureCellsFilled++; - else adminPrefectureCellsAligned++; - prefecture[i] = targetPref; - } else if (prefecture[i] < 0) { - const nearestPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', null, { maxRadius: 22 }); - if (nearestPref >= 0) { prefecture[i] = nearestPref; prefectureCellsFilled++; } - } - } - } - - const current = sourceMap?.municipalityToPrefectureId; - let maxId = current?.length ? current.length - 1 : -1; - 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 && admin[i] >= 0 && prefecture[i] >= 0) maxId = Math.max(maxId, admin[i]); - } - } - if (sourceMap && maxId >= 0) { - const next = new Int32Array(maxId + 1); - next.fill(-1); - if (current) for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1; - 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 && admin[i] >= 0 && prefecture[i] >= 0) next[admin[i]] = prefecture[i]; - } - } - sourceMap.municipalityToPrefectureId = next; - } - - return { seaAdminCellsCleared, landAdminCellsFilled, prefectureCellsFilled, adminPrefectureCellsAligned }; -} - -function protectedAdministrativeCells(world, fieldName, rect) { - const protectedCells = new Set(); - const sourceMap = world?.sourceMap || {}; - const pointKeys = fieldName === "prefectureRegionId" ? ["prefectureRegions"] : ["adminCenters"]; - const field = world?.fields?.[fieldName]; - if (!field) return protectedCells; - for (const key of pointKeys) { - for (const p of sourceMap[key] || []) { - const x = Math.round(pointWorldX(world, p)); - const y = Math.round(pointWorldY(world, p)); - if (!insideRect(x, y, rect)) continue; - const i = worldIndex(world, x, y); - if (i >= 0 && field[i] >= 0) protectedCells.add(i); - } - } - return protectedCells; -} - -function modeFromCounts(counts) { - let best = -1; - let bestCount = 0; - for (const [id, count] of counts || []) { - if (count > bestCount || (count === bestCount && id < best)) { - best = id; - bestCount = count; - } - } - return best; -} - -function addNestedVote(map, key, value, weight = 1) { - if (!Number.isFinite(key) || key < 0 || !Number.isFinite(value) || value < 0) return; - const id = Math.floor(key); - const bucket = map.get(id) || new Map(); - bucket.set(Math.floor(value), (bucket.get(Math.floor(value)) || 0) + weight); - map.set(id, bucket); -} - -function cleanupDiscreteFieldComponents(world, fieldName, rect, options = {}) { - const field = world?.fields?.[fieldName]; - const sea = world?.fields?.sea; - if (!field || !rect) return { componentsMerged: 0, cellsMerged: 0 }; - const minCells = Math.max(1, Math.floor(options.minCells || 80)); - const passes = Math.max(1, Math.floor(options.passes || 2)); - const respectPrefecture = !!options.respectPrefecture; - const pref = world.fields?.prefectureRegionId; - const protectedCells = protectedAdministrativeCells(world, fieldName, rect); - const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; - let componentsMerged = 0; - let cellsMerged = 0; - - for (let pass = 0; pass < passes; pass++) { - const seen = new Uint8Array(world.width * world.height); - const reassignments = []; - - for (let y = rect.y0; y < rect.y1; y++) { - for (let x = rect.x0; x < rect.x1; x++) { - const start = worldIndex(world, x, y); - if (start < 0 || seen[start] || sea?.[start] || field[start] < 0) continue; - - const id = field[start]; - const stack = [start]; - seen[start] = 1; - const cells = []; - const neighborVotes = new Map(); - const neighborPrefVotes = new Map(); - const ownPrefVotes = new Map(); - let touchesRectEdge = false; - let hasProtectedPoint = false; - - while (stack.length) { - const ci = stack.pop(); - const cx = ci % world.width; - const cy = Math.floor(ci / world.width); - cells.push(ci); - if (protectedCells.has(ci)) hasProtectedPoint = true; - if (cx <= rect.x0 || cy <= rect.y0 || cx >= rect.x1 - 1 || cy >= rect.y1 - 1) touchesRectEdge = true; - if (pref?.[ci] >= 0) ownPrefVotes.set(pref[ci], (ownPrefVotes.get(pref[ci]) || 0) + 1); - - for (const [dx, dy] of dirs) { - const nx = cx + dx; - const ny = cy + dy; - const ni = worldIndex(world, nx, ny); - if (ni < 0 || sea?.[ni]) continue; - const nid = field[ni]; - if (insideRect(nx, ny, rect) && nid === id && !seen[ni]) { - seen[ni] = 1; - stack.push(ni); - } else if (nid >= 0 && nid !== id) { - neighborVotes.set(nid, (neighborVotes.get(nid) || 0) + 1); - if (pref?.[ni] >= 0) addNestedVote(neighborPrefVotes, nid, pref[ni], 1); - } - } - } - - // Components touching the cleanup rectangle boundary may be only the - // visible slice of a large outside municipality/prefecture. Preserve - // those unless they are extremely small; otherwise patched seams can - // erase legitimate existing regions. - const clippedLargeOutsideRegion = touchesRectEdge && cells.length >= Math.floor(minCells * 0.55); - if (hasProtectedPoint || clippedLargeOutsideRegion || cells.length >= minCells || !neighborVotes.size) continue; - - const componentPref = respectPrefecture ? modeFromCounts(ownPrefVotes) : -1; - let bestTarget = -1; - let bestScore = -Infinity; - for (const [target, count] of neighborVotes) { - let score = count; - if (respectPrefecture && componentPref >= 0) { - const targetPref = modeFromCounts(neighborPrefVotes.get(target)); - if (targetPref === componentPref) score += count * 0.85; - else score -= count * 0.45; - } - if (score > bestScore || (score === bestScore && target < bestTarget)) { - bestTarget = target; - bestScore = score; - } - } - if (bestTarget < 0) continue; - for (const ci of cells) reassignments.push([ci, bestTarget]); - componentsMerged++; - cellsMerged += cells.length; - } - } - - if (!reassignments.length) break; - for (const [i, target] of reassignments) field[i] = target; - } - - return { componentsMerged, cellsMerged }; -} - -function repairPatchAdministrativeTopology(world, rects) { - const rect = rects.repairRect || rects.writeRect; - const prefecture = cleanupDiscreteFieldComponents(world, "prefectureRegionId", rect, { minCells: 420, passes: 3 }); - const admin = cleanupDiscreteFieldComponents(world, "adminId", rect, { minCells: 96, passes: 4, respectPrefecture: true }); - let municipalityCellsSynced = 0; - if (world.fields?.adminId && world.fields?.municipalityId) { - const adminId = world.fields.adminId; - const municipalityId = world.fields.municipalityId; - const sea = world.fields.sea; - 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; - const next = sea?.[i] ? -1 : adminId[i]; - if (municipalityId[i] !== next) { - municipalityId[i] = next; - municipalityCellsSynced++; - } - } - } - } - return { - prefectureTinyComponentsMerged: prefecture.componentsMerged, - prefectureTinyCellsMerged: prefecture.cellsMerged, - adminTinyComponentsMerged: admin.componentsMerged, - adminTinyCellsMerged: admin.cellsMerged, - municipalityCellsSynced, - }; -} - -function smoothWaterTopology(world, rect, seaLevel = 0.30, rects = null, seed = 0) { - 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++; - } - } - const a = rects ? patchAlpha(x, y, rects, seed) : 1; - if (a < 0.24) continue; - const strongOnly = a < 0.42; - if (sea[i] && seaN <= (strongOnly ? 0 : 1) && elevation[i] > seaLevel - 0.035) flips.push([i, 0]); - else if (!sea[i] && seaN >= (strongOnly ? 8 : 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 repairWaterComponentTopology(world, rects, seaLevel = 0.30, seed = 0) { - const sea = world.fields.sea; - const ocean = world.fields.ocean; - const lake = world.fields.lake; - const elevation = world.fields.elevation; - const landuse = world.fields.landuse; - const rect = rects?.writeRect; - if (!sea || !rect) return { waterComponentsScanned: 0, tinyWaterComponentsRemoved: 0, tinyLandIslandsRemoved: 0, waterTopologyCellsFlipped: 0 }; - - const expected = world.width * world.height; - const visited = new Uint8Array(expected); - const activeMinAlpha = 0.22; - const preserveAlpha = 0.40; - const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; - const active = (x, y) => insideRect(x, y, rect) && patchAlpha(x, y, rects, seed) >= activeMinAlpha; - const tinyWaterLimit = Math.max(14, Math.min(36, Math.floor(Math.sqrt(Math.max(1, rectArea(rect))) * 0.20))); - const tinyLandLimit = Math.max(10, Math.min(28, Math.floor(Math.sqrt(Math.max(1, rectArea(rect))) * 0.16))); - - let waterComponentsScanned = 0; - let tinyWaterComponentsRemoved = 0; - let tinyLandIslandsRemoved = 0; - let waterTopologyCellsFlipped = 0; - - const flipCell = (i, nextSea) => { - if (sea[i] === nextSea) return; - sea[i] = nextSea; - if (ocean) ocean[i] = nextSea; - if (lake) lake[i] = 0; - if (elevation) { - if (nextSea) elevation[i] = Math.min(elevation[i], seaLevel - 0.004); - else elevation[i] = Math.max(elevation[i], seaLevel + 0.006); - } - if (landuse) landuse[i] = nextSea ? (LANDUSE.WATER || LANDUSE.RURAL || 0) : (LANDUSE.RURAL || 0); - waterTopologyCellsFlipped++; - }; - - for (let y0 = rect.y0; y0 < rect.y1; y0++) { - for (let x0 = rect.x0; x0 < rect.x1; x0++) { - if (!active(x0, y0)) continue; - const start = worldIndex(world, x0, y0); - if (start < 0 || visited[start]) continue; - const value = sea[start] ? 1 : 0; - const stack = [[x0, y0]]; - const cells = []; - let sumElevation = 0; - let elevationCount = 0; - let touchesWeakPatchEdge = false; - let touchesSameOutsideActive = false; - let oppositeBorder = 0; - let sameBorder = 0; - - visited[start] = 1; - while (stack.length) { - const [x, y] = stack.pop(); - const i = worldIndex(world, x, y); - if (i < 0) continue; - cells.push(i); - if (elevation) { sumElevation += elevation[i] || 0; elevationCount++; } - if (patchAlpha(x, y, rects, seed) < preserveAlpha || x <= rect.x0 || y <= rect.y0 || x >= rect.x1 - 1 || y >= rect.y1 - 1) { - touchesWeakPatchEdge = true; - } - for (const [dx, dy] of dirs) { - const nx = x + dx; - const ny = y + dy; - const ni = worldIndex(world, nx, ny); - if (ni < 0) continue; - const nv = sea[ni] ? 1 : 0; - if (nv !== value) { - oppositeBorder++; - continue; - } - sameBorder++; - if (!active(nx, ny)) { - touchesSameOutsideActive = true; - continue; - } - if (!visited[ni]) { - visited[ni] = 1; - stack.push([nx, ny]); - } - } - } - - waterComponentsScanned++; - const area = cells.length; - const avgElevation = elevationCount ? sumElevation / elevationCount : seaLevel; - const isolatedInsidePatch = !touchesWeakPatchEdge && !touchesSameOutsideActive; - if (value === 1) { - if (isolatedInsidePatch && area <= tinyWaterLimit && avgElevation > seaLevel - 0.055) { - for (const i of cells) flipCell(i, 0); - tinyWaterComponentsRemoved++; - } - } else { - const mostlySurroundedBySea = oppositeBorder > sameBorder * 0.72; - if (isolatedInsidePatch && mostlySurroundedBySea && area <= tinyLandLimit && avgElevation < seaLevel + 0.045) { - for (const i of cells) flipCell(i, 1); - tinyLandIslandsRemoved++; - } - } - } - } - - return { waterComponentsScanned, tinyWaterComponentsRemoved, tinyLandIslandsRemoved, waterTopologyCellsFlipped }; -} - -function smoothPatchedWaterElevation(world, rects, seaLevel = 0.30, seed = 0) { - const elevation = world.fields?.elevation; - const sea = world.fields?.sea; - if (!elevation || !sea || !rects?.writeRect) return { waterElevationCellsSmoothed: 0 }; - const rect = rects.writeRect; - let waterElevationCellsSmoothed = 0; - - // Do not diffuse water elevation row-by-row. Diffusion made broad patched - // ocean/sea areas inherit candidate raster bands, which appeared as horizontal - // stripes. Instead assign a stable world-coordinate bathymetry target and - // blend toward it by patch alpha. The renderer also avoids DEM hillshade for - // water, but keeping the underlying water DEM coherent prevents dependent - // fields from reintroducing stripe artefacts later. - 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 a = patchAlpha(x, y, rects, seed); - if (a < 0.08) continue; - - let seaNear = 0; - let totalNear = 0; - for (let dy = -3; dy <= 3; dy++) { - for (let dx = -3; dx <= 3; dx++) { - if (!dx && !dy) continue; - const ni = worldIndex(world, x + dx, y + dy); - if (ni < 0) continue; - totalNear++; - if (sea[ni]) seaNear++; - } - } - const offshore = totalNear ? seaNear / totalNear : 1; - const broad = valueNoise(x, y, seed ^ 0x6d2b79f5, 86); - const mid = valueNoise(x, y, seed ^ 0x2f31c9a7, 31); - const texture = broad * 0.75 + mid * 0.25; - const depth = clamp(0.032 + offshore * 0.060 + (texture - 0.5) * 0.018, 0.018, 0.125); - const target = seaLevel - depth; - const before = elevation[i]; - const strength = clamp(0.46 + a * 0.42, 0.42, 0.86); - elevation[i] = clamp(lerp(Math.min(before, seaLevel - 0.004), target, strength), seaLevel - 0.16, seaLevel - 0.004); - if (Math.abs(elevation[i] - before) > 1e-6) waterElevationCellsSmoothed++; - } - } - - return { waterElevationCellsSmoothed }; -} - -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 offsetPointNumericFields(point, fields, offset) { - for (const field of fields) if (Number.isFinite(point[field])) point[field] += offset; -} - -function normalizeGeneratedPointIds(point, key, seed = 0, adminIdMapping = null) { - const rawAdminId = numericFeatureId(point, ["adminId", "municipalityId", "adminNumericId"]); - if (rawAdminId >= 0) { - const mappedAdminId = adminIdMapping?.admin?.get(rawAdminId) ?? adminIdMapping?.municipality?.get(rawAdminId); - if (Number.isFinite(mappedAdminId)) { - point.sourceAdminId = rawAdminId; - point.adminId = mappedAdminId; - point.adminNumericId = mappedAdminId; - point.municipalityId = mappedAdminId; - } else { - offsetPointNumericFields(point, ["adminId", "adminNumericId", "municipalityId"], fieldIdOffset("adminId", seed)); - if (!Number.isFinite(point.adminId) && Number.isFinite(point.municipalityId)) point.adminId = point.municipalityId; - if (!Number.isFinite(point.municipalityId) && Number.isFinite(point.adminId)) point.municipalityId = point.adminId; - } - } - - const rawPrefectureId = numericFeatureId(point, key === "prefectureRegions" ? ["prefectureRegionId", "id"] : ["prefectureRegionId"]); - if (rawPrefectureId >= 0) { - const mappedPrefectureId = adminIdMapping?.prefecture?.get(rawPrefectureId); - if (Number.isFinite(mappedPrefectureId)) { - point.sourcePrefectureRegionId = rawPrefectureId; - point.prefectureRegionId = mappedPrefectureId; - if (key === "prefectureRegions") point.id = mappedPrefectureId; - } else { - const offset = fieldIdOffset("prefectureRegionId", seed); - if (Number.isFinite(point.prefectureRegionId)) point.prefectureRegionId += offset; - if (key === "prefectureRegions" && Number.isFinite(point.id)) point.id += offset; - } - } - - if (Number.isFinite(point.adminId) && !Number.isFinite(point.municipalityId)) point.municipalityId = point.adminId; - if (Number.isFinite(point.municipalityId) && !Number.isFinite(point.adminId)) point.adminId = point.municipalityId; - return point; -} - -function transformCandidatePoint(world, window, p, key, seed = 0, adminIdMapping = null) { - 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 }); - normalizeGeneratedPointIds(out, key, seed, adminIdMapping); - if (key === "adminCenters") { - if (Number.isFinite(out.sourceAdminId)) { - const candidatePrefId = adminIdMapping?.candidateAdminToPrefecture?.get(out.sourceAdminId); - const mappedPrefId = adminIdMapping?.prefecture?.get(candidatePrefId); - if (Number.isFinite(mappedPrefId)) out.prefectureRegionId = mappedPrefId; - } - } - 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 splitWorldPathByPredicate(path, predicate, keepWhenTrue) { - const chunks = []; - let current = []; - for (const p of path || []) { - const matches = predicate(Math.round(p[0]), Math.round(p[1])); - if (matches === keepWhenTrue) 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 splitWorldPathByRect(path, rect, keepInside) { - return splitWorldPathByPredicate(path, (x, y) => insideRect(x, y, rect), keepInside); -} - -function splitWorldPathByPatch(path, rects, seed, keepAffected, minAlpha = 0.34) { - return splitWorldPathByPredicate(path, (x, y) => patchAffected(x, y, rects, seed, minAlpha), keepAffected); -} - -function pruneOldPathLayer(world, paths, rects, seed, 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]) => patchAffected(x, y, rects, seed, 0.34)); - if (!touches) { - kept.push(path); - continue; - } - clipped++; - let lastOutside = null; - let wasInside = false; - for (const [x, y] of worldPath) { - const inside = patchAffected(x, y, rects, seed, 0.34); - 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 splitWorldPathByPatch(worldPath, rects, seed, false, 0.34)) 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, mode = "road") { - const points = []; - const seen = new Set(); - const add = (x, y, key, weight = 1) => { - x = Math.round(x); y = Math.round(y); - if (!insideRect(x, y, rect) || !isLand(world, x, y)) return; - const sig = `${x},${y},${key}`; - if (seen.has(sig)) return; - seen.add(sig); - points.push({ x, y, key, weight }); - }; - for (const key of keys) { - for (const path of sourceMap[key] || []) { - for (let i = 0; i < path.length; i += 4) { - add(tupleWorldX(world, path[i]), tupleWorldY(world, path[i]), key, 1.1); - } - } - } - const featureKeys = mode === "rail" - ? ["modernCities", "stations", "ports", "adminCenters", "industrialZones", "newTowns"] - : ["modernCities", "ports", "markets", "villages", "adminCenters", "industrialZones", "logisticsParks", "newTowns"]; - for (const key of featureKeys) { - for (const p of sourceMap[key] || []) { - add(pointWorldX(world, p), pointWorldY(world, p), key, key === "adminCenters" || key === "modernCities" ? 1.8 : 1.25); - } - } - return points; -} - -function rectDistance(x, y, rect) { - if (insideRect(x, y, rect)) return 0; - const dx = x < rect.x0 ? rect.x0 - x : x >= rect.x1 ? x - rect.x1 + 1 : 0; - const dy = y < rect.y0 ? rect.y0 - y : y >= rect.y1 ? y - rect.y1 + 1 : 0; - return Math.hypot(dx, dy); -} - -function collectExternalNetworkAnchors(world, sourceMap, keys, writeRect, reachRect, mode = "road") { - const candidates = []; - const seen = new Set(); - const step = mode === "rail" ? 6 : 4; - for (const key of keys) { - for (const path of sourceMap[key] || []) { - for (let i = 0; i < path.length; i += step) { - const x = Math.round(tupleWorldX(world, path[i])); - const y = Math.round(tupleWorldY(world, path[i])); - if (!insideRect(x, y, reachRect) || insideRect(x, y, writeRect) || !isLand(world, x, y)) continue; - const d = rectDistance(x, y, writeRect); - if (d < 4 || d > (mode === "rail" ? 380 : 420)) continue; - const sig = `${x},${y},${mode}`; - if (seen.has(sig)) continue; - seen.add(sig); - candidates.push({ x, y, mode, external: true, d }); - } - } - } - candidates.sort((a, b) => a.d - b.d); - return candidates.slice(0, mode === "rail" ? 18 : 28); -} - -function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRect = null) { - const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"]; - const allTargets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode); - const preferredTargets = preferredTargetRect ? allTargets.filter((p) => insideRect(p.x, p.y, preferredTargetRect)) : []; - const targets = preferredTargets.length ? preferredTargets : allTargets; - if (!targets.length) return { connectors: 0, disconnected: anchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 }; - let connectors = 0; - let disconnected = 0; - let skippedConnectorAnchors = 0; - let connectorAttempts = 0; - const layer = mode === "rail" ? "branchRailways" : "minorRoads"; - sourceMap[layer] ||= []; - const seen = new Set(); - const maxRange = mode === "rail" ? 220 : 260; - const maxAnchors = mode === "rail" ? 10 : 18; - const maxTargets = mode === "rail" ? 3 : 3; - const searchRect = expandRect(rect, 16, world); - const orderedAnchors = (anchors || []) - .map((p) => ({ ...p, patchDistance: rectDistance(p.x, p.y, preferredTargetRect || rect) })) - .sort((a, b) => a.patchDistance - b.patchDistance) - .slice(0, maxAnchors); - skippedConnectorAnchors = Math.max(0, (anchors?.length || 0) - orderedAnchors.length); - for (const raw of orderedAnchors) { - const anchorLand = nearestLand(world, raw.x, raw.y, rect, 18); - if (!anchorLand) { disconnected++; continue; } - const targetList = targets - .map((p) => ({ ...p, d: Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) })) - .filter((p) => p.d <= maxRange && p.d >= 6) - .sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1))) - .slice(0, maxTargets); - if (!targetList.length) { disconnected++; continue; } - let made = false; - for (const target of targetList) { - const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`; - if (seen.has(sig)) continue; - connectorAttempts++; - const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 36000 : 44000); - if (!path || path.length < 2) continue; - seen.add(sig); - sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2))); - connectors++; - made = true; - break; - } - if (!made) disconnected++; - } - return { connectors, disconnected, skippedConnectorAnchors, connectorAttempts }; -} - -function nearestNetworkPoint(world, sourceMap, keys, point, rect, maxDistance = 80) { - let best = null; - let bestD = maxDistance; - for (const key of keys) { - for (const path of sourceMap[key] || []) { - for (let i = 0; i < path.length; i += 5) { - 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)) continue; - const d = Math.hypot(point.x - x, point.y - y); - if (d < bestD) { bestD = d; best = { x, y, key }; } - } - } - } - return best; -} - -function ensureSettlementRoadCoverage(world, sourceMap, rect) { - const keys = ["nationalRoads", "minorRoads", "premodernRoads"]; - const featureKeys = ["modernCities", "ports", "markets", "adminCenters", "villages"]; - sourceMap.minorRoads ||= []; - let connectors = 0; - let skippedServedSettlements = 0; - let checked = 0; - const seen = new Set(); - for (const key of featureKeys) { - const limit = key === "villages" ? 30 : 18; - const items = (sourceMap[key] || []) - .map((p) => ({ p, d: rectDistance(pointWorldX(world, p), pointWorldY(world, p), rect) })) - .filter((row) => row.d <= (key === "villages" ? 80 : 150)) - .sort((a, b) => a.d - b.d) - .slice(0, limit); - for (const { p } of items) { - checked++; - const start = nearestLand(world, pointWorldX(world, p), pointWorldY(world, p), rect, 10); - if (!start || !insideRect(start.x, start.y, rect)) continue; - const si = worldIndex(world, start.x, start.y); - if ((world.fields.roadInfluence?.[si] || 0) > (key === "villages" ? 0.18 : 0.12)) { - skippedServedSettlements++; - continue; - } - const target = nearestNetworkPoint(world, sourceMap, keys, start, rect, key === "villages" ? 72 : 132); - if (!target || Math.hypot(target.x - start.x, target.y - start.y) < 5) continue; - const sig = `${start.x},${start.y}:${target.x},${target.y}`; - if (seen.has(sig)) continue; - seen.add(sig); - const path = localPathfind(world, start, target, rect, "road", 36000); - if (!path || path.length < 2) continue; - sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2))); - connectors++; - } - } - return { connectors, skippedServedSettlements, checkedSettlementCoverage: checked }; -} - -function dedupeAdminCentersByWorldId(kept, generated) { - const out = [...kept]; - const seen = new Set(); - for (const p of kept) { - const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]); - if (id >= 0) seen.add(id); - } - for (const p of generated) { - const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]); - if (id >= 0 && seen.has(id)) continue; - if (id >= 0) seen.add(id); - out.push(p); - } - return out; -} - -function dedupePrefectureRegionsByWorldId(kept, generated) { - const out = [...kept]; - const seen = new Set(); - for (const p of kept) { - const id = numericFeatureId(p, ["prefectureRegionId", "id"]); - if (id >= 0) seen.add(id); - } - for (const p of generated) { - const id = numericFeatureId(p, ["prefectureRegionId", "id"]); - if (id >= 0 && seen.has(id)) continue; - if (id >= 0) seen.add(id); - out.push(p); - } - return out; -} - -function mergePointLayers(world, sourceMap, candidate, rects, window, seed, adminIdMapping = null) { - 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) { - kept.push(p); - if (!inWrite) preservedExternalEntities++; - } else if (key === "ports" && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) { - invalidPortsRemoved++; - continue; - } - } - const generated = []; - for (const p of candidate[key] || []) { - const q = transformCandidatePoint(world, window, p, key, seed, adminIdMapping); - 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); - } - if (key === "adminCenters") sourceMap[key] = dedupeAdminCentersByWorldId(kept, generated); - else if (key === "prefectureRegions") sourceMap[key] = dedupePrefectureRegionsByWorldId(kept, generated); - else sourceMap[key] = [...kept, ...generated]; - regeneratedInternalEntities += Math.max(0, sourceMap[key].length - kept.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, seed, 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 = splitWorldPathByPatch(worldPath, rects, seed, true, 0.40) - .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 transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect; - const externalRoadAnchors = collectExternalNetworkAnchors(world, sourceMap, ["nationalRoads", "minorRoads", "premodernRoads", "externalRoads"], rects.writeRect, transportRect, "road"); - const externalRailAnchors = collectExternalNetworkAnchors(world, sourceMap, ["railways", "branchRailways", "externalRailways"], rects.writeRect, transportRect, "rail"); - roadAnchors = roadAnchors.concat(externalRoadAnchors); - railAnchors = railAnchors.concat(externalRailAnchors); - const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect, rects.writeRect); - const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect, rects.writeRect); - const settlementRoadConnectors = ensureSettlementRoadCoverage(world, sourceMap, transportRect); - return { - roadsClipped, - railsClipped, - regeneratedPaths, - roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors, - railwayConnectorsCreated: railConn.connectors, - disconnectedRoadComponents: roadConn.disconnected, - disconnectedRailComponents: railConn.disconnected, - skippedConnectorAnchors: (roadConn.skippedConnectorAnchors || 0) + (railConn.skippedConnectorAnchors || 0), - connectorAttempts: (roadConn.connectorAttempts || 0) + (railConn.connectorAttempts || 0), - skippedServedSettlements: settlementRoadConnectors.skippedServedSettlements || 0, - checkedSettlementCoverage: settlementRoadConnectors.checkedSettlementCoverage || 0, - externalRoadAnchors: externalRoadAnchors.length, - externalRailAnchors: externalRailAnchors.length, - }; -} - -function buildBoundarySegmentsFromField(world, fieldName, rect, options = {}) { - const field = world.fields[fieldName]; - const sea = world.fields.sea; - const rects = options.rects || null; - const seed = options.seed || 0; - const minAlpha = Number.isFinite(options.minAlpha) ? options.minAlpha : 0; - 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 && continuitySegmentAllowed(x, y, x + 1, y, rects, seed, minAlpha)) { - 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 && continuitySegmentAllowed(x, y, x, y + 1, rects, seed, minAlpha)) { - out.push([[x - world.originX, y + 0.5 - world.originY], [x + 1 - world.originX, y + 0.5 - world.originY]]); - } - } - } - return out; -} - -function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, window, seed = 0) { - const debug = sourceMap.adminDebug || {}; - debug.compartmentBorders ||= []; - let added = 0; - for (const seg of candidate?.adminDebug?.compartmentBorders || []) { - if (!Array.isArray(seg) || seg.length < 2) continue; - const a = worldCoordForSource(window, seg[0]?.[0], seg[0]?.[1]); - const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]); - const mx = (a.x + b.x) * 0.5; - const my = (a.y + b.y) * 0.5; - if (!insideRect(mx, my, rects.writeRect) || patchAlpha(mx, my, rects, seed) < 0.08) continue; - debug.compartmentBorders.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]])); - added++; - } - sourceMap.adminDebug = debug; - return added; -} - -function transformCandidateBoundarySegments(world, candidate, key, rects, window, seed = 0, minAlpha = 0.74) { - const out = []; - if (!candidate || !window || !Array.isArray(candidate[key])) return out; - const sea = world.fields?.sea; - for (const seg of candidate[key]) { - if (!Array.isArray(seg) || seg.length < 2) continue; - const a = worldCoordForSource(window, seg[0]?.[0], seg[0]?.[1]); - const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]); - const mx = Math.round((a.x + b.x) * 0.5); - const my = Math.round((a.y + b.y) * 0.5); - if (!insideRect(mx, my, rects.writeRect)) continue; - if (patchAlpha(mx, my, rects, seed) < minAlpha) continue; - const mi = worldIndex(world, mx, my); - if (mi >= 0 && sea?.[mi]) continue; - out.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]])); - } - return out; -} - -function mergeSegmentLayers(world, sourceMap, rects, seed = 0, candidate = null, window = null) { - const segmentRect = rects.writeRect || rects.repairRect; - const strongAlpha = 0.72; - - // Do not wipe every prepared boundary inside the repair rectangle. That made - // the old/new seam itself visible as a prefecture border and left broad holes - // when candidate boundaries were weak. Replace only strong patch-interior - // segments; keep the weak seam band owned by the existing world. - for (const key of SEGMENT_LAYER_KEYS) { - const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; - sourceMap[key] = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, strongAlpha)); - } - - const candidateAdmin = transformCandidateBoundarySegments(world, candidate, "adminBorders", rects, window, seed, 0.76); - const candidatePref = transformCandidateBoundarySegments(world, candidate, "regionalPrefectureBorders", rects, window, seed, 0.78); - const rebuiltAdmin = buildBoundarySegmentsFromField(world, "adminId", segmentRect, { rects, seed, minAlpha: 0.76 }); - const rebuiltPrefecture = buildBoundarySegmentsFromField(world, "prefectureRegionId", segmentRect, { rects, seed, minAlpha: 0.82 }); - - sourceMap.adminBorders = dedupeSegments([...(sourceMap.adminBorders || []), ...candidateAdmin, ...rebuiltAdmin]); - sourceMap.regionalPrefectureBorders = dedupeSegments([...(sourceMap.regionalPrefectureBorders || []), ...candidatePref, ...rebuiltPrefecture]); - sourceMap.prefectureBorder ||= []; - - const debug = sourceMap.adminDebug || {}; - // Use the legacy full-pipeline compartment debug segments for regenerated - // areas. Rebuilding directly from the raster field made patch compartments - // look denser/smaller than the initial map. Candidate segments are merged - // just after this function. - debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34)); - sourceMap.adminDebug = debug; - return { - adminBordersRebuilt: sourceMap.adminBorders.length, - prefectureBordersRebuilt: sourceMap.regionalPrefectureBorders.length, - candidateAdminBordersMerged: candidateAdmin.length, - candidatePrefectureBordersMerged: candidatePref.length, - compartmentBordersRebuilt: debug.compartmentBorders.length, - }; -} - -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 ensureWorldFloatField(world, name) { - const expected = world.width * world.height; - if (!world.fields[name] || world.fields[name].length !== expected) world.fields[name] = new Float32Array(expected); - return world.fields[name]; -} - -function clearFieldRect(world, field, rect) { - 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; - } - } -} - -function paintInfluenceDisk(world, field, cx, cy, radius, strength, rect) { - const sea = world.fields.sea; - const r = Math.max(1, Math.ceil(radius)); - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - if (dx * dx + dy * dy > radius * radius) continue; - const x = Math.round(cx + dx); - const y = Math.round(cy + dy); - if (!insideRect(x, y, rect)) continue; - const i = worldIndex(world, x, y); - if (i < 0 || sea?.[i]) continue; - const d = Math.hypot(dx, dy); - const value = strength * Math.pow(1 - d / Math.max(1, radius), 1.35); - if (value > field[i]) field[i] = clamp(value); - } - } -} - -function refreshPatchInfluenceFields(world, sourceMap, rects) { - const rect = rects.repairRect || rects.writeRect; - const roadInfluence = ensureWorldFloatField(world, "roadInfluence"); - const railInfluence2 = ensureWorldFloatField(world, "railInfluence2"); - const stationInfluence = ensureWorldFloatField(world, "stationInfluence"); - const villageInfluence = ensureWorldFloatField(world, "villageInfluence"); - for (const field of [roadInfluence, railInfluence2, stationInfluence, villageInfluence]) clearFieldRect(world, field, rect); - - let roadCellsPainted = 0; - let railCellsPainted = 0; - let stationCellsPainted = 0; - let villageCellsPainted = 0; - const paintPathLayer = (keys, field, radius, strength, counterName) => { - let painted = 0; - for (const key of keys) { - for (const path of sourceMap[key] || []) { - for (const tuple of path || []) { - const x = tupleWorldX(world, tuple); - const y = tupleWorldY(world, tuple); - if (rectDistance(x, y, rect) > radius + 1) continue; - paintInfluenceDisk(world, field, x, y, radius, strength, rect); - painted++; - } - } - } - if (counterName === "road") roadCellsPainted += painted; - if (counterName === "rail") railCellsPainted += painted; - }; - - paintPathLayer(["nationalRoads", "ringRoads", "externalRoads", "minorRoads", "premodernRoads", "icAccessRoads"], roadInfluence, 5, 1, "road"); - paintPathLayer(["railways", "branchRailways", "ringRailways", "externalRailways"], railInfluence2, 4, 1, "rail"); - - for (const p of sourceMap.stations || []) { - const x = pointWorldX(world, p); - const y = pointWorldY(world, p); - if (rectDistance(x, y, rect) > 8) continue; - paintInfluenceDisk(world, stationInfluence, x, y, 5, clamp(p.score || 1), rect); - stationCellsPainted++; - } - for (const p of sourceMap.villages || []) { - const x = pointWorldX(world, p); - const y = pointWorldY(world, p); - if (rectDistance(x, y, rect) > 10) continue; - paintInfluenceDisk(world, villageInfluence, x, y, 6, clamp((p.population || 1800) / 4200, 0.35, 1.2), rect); - villageCellsPainted++; - } - - return { roadCellsPainted, railCellsPainted, stationCellsPainted, villageCellsPainted }; -} - -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; -} - -function rectKey(rect) { - return rect ? `${rect.x0},${rect.y0},${rect.x1},${rect.y1}` : "-"; -} - -function patchCandidateCacheKey({ seed, terrainType, variant, candidateOriginX, candidateOriginY, contextRect, serial = 0 }) { - return [serial, seed >>> 0, terrainType || "auto", variant >>> 0, candidateOriginX | 0, candidateOriginY | 0, rectKey(contextRect)].join("|"); -} - -function getPatchCandidateCache(world) { - if (!world.patchCandidateCache) world.patchCandidateCache = new Map(); - return world.patchCandidateCache; -} - -function rememberPatchCandidate(world, key, candidate) { - const cache = getPatchCandidateCache(world); - if (cache.has(key)) cache.delete(key); - cache.set(key, candidate); - while (cache.size > PATCH_CANDIDATE_CACHE_LIMIT) cache.delete(cache.keys().next().value); -} - -function getOrGeneratePatchCandidate(world, key, create) { - const cache = getPatchCandidateCache(world); - if (cache.has(key)) { - const candidate = cache.get(key); - cache.delete(key); - cache.set(key, candidate); - return { candidate, cacheHit: true, cacheSize: cache.size }; - } - const candidate = create(); - rememberPatchCandidate(world, key, candidate); - return { candidate, cacheHit: false, cacheSize: getPatchCandidateCache(world).size }; -} - -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 variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0; - const candidateWindow = sourceWindowForRects(rects); - const candidateOriginX = Math.round(candidateWindow.worldCenterX - candidateWindow.sourceCenterX); - const candidateOriginY = Math.round(candidateWindow.worldCenterY - candidateWindow.sourceCenterY); - const patchTimer = createPatchTimer(); - const patchGenerationMode = "legacy-full-pipeline"; - const cacheKey = patchCandidateCacheKey({ - seed, - terrainType, - variant, - candidateOriginX, - candidateOriginY, - contextRect: rects.contextRect, - serial: world.patchGenerationSerial || 0, - }); - const { candidate, cacheHit, cacheSize } = getOrGeneratePatchCandidate(world, cacheKey, () => generateMap(seed, { - terrainType, - legacyTerrain: true, - worldNative: true, - variant, - originX: candidateOriginX, - originY: candidateOriginY, - width: MAP_W, - height: MAP_H, - contextRect: rects.contextRect, - boundaryWorld: world, - onProgress: () => {}, - })); - patchTimer.mark("candidate", cacheHit ? "Full candidate generation (cached)" : "Full candidate generation"); - getPatchAlphaCache(rects, seed); - getPatchSourceIndexCache(rects, candidateWindow); - const sourceMap = world.sourceMap || (world.sourceMap = {}); - const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap); - - const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed); - patchTimer.mark("fields", "Field copy and alpha blend"); - const terrainSeamDebug = featherTerrainSeam(world, rects, seed); - const seaLevel = candidate.seaLevel || world.sourceMap?.seaLevel || 0.30; - const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed); - const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed); - const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed); - const maskDebug = repairDisplayMasks(world, rects, seed); - recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel); - patchTimer.mark("terrainRepair", "Water, masks, and terrain repair"); - const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping); - patchTimer.mark("points", "Point merge"); - const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed); - patchTimer.mark("paths", "Path merge and connector repair"); - const influenceDebug = refreshPatchInfluenceFields(world, sourceMap, rects); - patchTimer.mark("influence", "Influence refresh"); - const landDebug = repairLanduseAndPopulation(world, rects); - const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping, seed); - const adminTopologyDebug = repairPatchAdministrativeTopology(world, rects); - const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping); - const municipalCoherence = reconcileMunicipalMetadata({ - adminId: world.fields.adminId, - municipalityId: world.fields.municipalityId, - prefectureRegionId: world.fields.prefectureRegionId, - sea: world.fields.sea, - adminCenters: sourceMap.adminCenters || [], - municipalityToPrefectureId: sourceMap.municipalityToPrefectureId, - fields: world.fields, - width: world.width, - height: world.height, - pointOffsetX: world.originX || 0, - pointOffsetY: world.originY || 0, - seed, - }); - sourceMap.adminCenters = municipalCoherence.adminCenters; - sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId; - const prefectureCoherence = refreshPrefectureRegionsMetadata({ - prefectureRegionId: world.fields.prefectureRegionId, - sea: world.fields.sea, - existing: sourceMap.prefectureRegions || [], - fields: world.fields, - width: world.width, - height: world.height, - pointOffsetX: world.originX || 0, - pointOffsetY: world.originY || 0, - }); - sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions; - sourceMap.adminDebug = { - ...(sourceMap.adminDebug || {}), - municipalCoherence: municipalCoherence.debug, - prefectureMetadataCoherence: prefectureCoherence.debug, - }; - const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed, candidate, fieldDebug.window); - const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed); - patchTimer.mark("segments", "Boundary and debug segment merge"); - sanitizeExistingLogistics(sourceMap); - patchTimer.mark("cleanup", "Land-use, admin, and label cleanup"); - const patchTimings = patchTimer.timings; - - 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, - ...influenceDebug, - adminCellsReassigned: fieldDebug.adminCellsReassigned, - adminIdMapping: fieldDebug.adminIdMappingDebug, - sourceAdminMetadataUpdated, - ...finalAdminCoverageDebug, - ...adminTopologyDebug, - finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0, - finalLandAdminCellsFilled: finalAdminCoverageDebug.landAdminCellsFilled || 0, - finalPrefectureCellsFilled: finalAdminCoverageDebug.prefectureCellsFilled || 0, - finalAdminPrefectureCellsAligned: finalAdminCoverageDebug.adminPrefectureCellsAligned || 0, - municipalCoherence: municipalCoherence.debug, - prefectureMetadataCoherence: prefectureCoherence.debug, - continuityCellsRestored: fieldDebug.continuityCellsRestored || 0, - continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0, - adminSeamCellsResolved: fieldDebug.adminSeamCellsResolved || 0, - prefectureSeamCellsResolved: fieldDebug.prefectureSeamCellsResolved || 0, - ...terrainSeamDebug, - ...waterComponentDebug, - ...waterElevationDebug, - landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated, - displayMaskUpdated: maskDebug.displayMaskUpdated || 0, - logisticsLabelsMigrated, - candidateCacheHit: cacheHit, - candidateCacheSize: cacheSize, - ...segmentDebug, - candidateCompartmentSegmentsAdded, - }; - - const record = { - ...rects.coreRect, - coreRect: { ...rects.coreRect }, - selectionShape: rects.selectionShape ? { - kind: rects.selectionShape.kind || 'lasso', - areaCells: rects.selectionShape.areaCells || 0, - polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })), - } : null, - writeRect: { ...rects.writeRect }, - repairRect: { ...rects.repairRect }, - contextRect: { ...rects.contextRect }, - blendRect: { ...rects.blendRect }, - terrainType: id, - label, - seed, - variant, - candidateOriginX, - candidateOriginY, - patchGenerationMode, - patchTimings, - updatedCells: fieldDebug.updatedCells, - terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced, - coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged + waterComponentDebug.waterTopologyCellsFlipped, - naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated, - naturalRegionFragmentsMerged: 0, - adminIdMapping: fieldDebug.adminIdMappingDebug, - seaRatio: seaStats.seaRatio, - humanGeography, - createdAt: Date.now(), - }; - world.generatedRects = [...(world.generatedRects || []), record]; - world.invalidatedRects = [...(world.invalidatedRects || []), { ...rects.writeRect }]; - world.lastPatchResult = record; - world.patchGenerationSerial = (world.patchGenerationSerial || 0) + 1; - - return { - ok: true, - validation, - rects: { - ...rects, - selectionShape: rects.selectionShape ? { - kind: rects.selectionShape.kind || 'lasso', - areaCells: rects.selectionShape.areaCells || 0, - polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })), - } : null, - }, - terrainType: id, - label, - seed, - variant, - candidateOriginX, - candidateOriginY, - patchGenerationMode, - patchTimings, - updatedCells: record.updatedCells, - terrainCellsFullyReplaced: record.terrainCellsFullyReplaced, - coastCellsChanged: record.coastCellsChanged, - naturalRegionsUpdated: record.naturalRegionsUpdated, - naturalRegionFragmentsMerged: 0, - adminIdMapping: fieldDebug.adminIdMappingDebug, - seaRatio: seaStats.seaRatio, - humanGeography, - }; -} diff --git a/mapPatchContext.js b/mapPatchContext.js new file mode 100644 index 0000000..cddb1c8 --- /dev/null +++ b/mapPatchContext.js @@ -0,0 +1,24 @@ +export function createPatchContext({ world, rects, candidateWindow, seed, patchAlpha, sourceIndexForWorld, worldIndex }) { + const writeRect = rects?.writeRect; + const writeCells = []; + if (world && writeRect && typeof patchAlpha === "function" && typeof sourceIndexForWorld === "function" && typeof worldIndex === "function") { + for (let y = writeRect.y0; y < writeRect.y1; y++) { + for (let x = writeRect.x0; x < writeRect.x1; x++) { + const wi = worldIndex(world, x, y); + if (wi < 0) continue; + const si = sourceIndexForWorld(rects, candidateWindow, x, y); + if (si < 0) continue; + const alpha = patchAlpha(x, y, rects, seed); + if (alpha <= 0.005) continue; + writeCells.push({ x, y, wi, si, alpha }); + } + } + } + return { + world, + rects, + candidateWindow, + seed, + writeCells, + }; +} diff --git a/renderer.js b/renderer.js index 8433891..e9b9d65 100644 --- a/renderer.js +++ b/renderer.js @@ -2,11 +2,74 @@ import { CELL_SIZE, MAP_H, MAP_W, clamp, valueNoise } from "./mapUtils.js"; const segmentVectorCache = new WeakMap(); +const segmentVectorStableCache = new Map(); const pathVectorCache = new WeakMap(); const coastlineCache = new WeakMap(); const rasterBorderCache = new WeakMap(); -const baseImageCache = new WeakMap(); -const MAX_BASE_CACHE_IMAGES = 4; +const baseImageCache = new Map(); +const urbanOverlayCache = new Map(); +const prefectureFillCache = new Map(); +const MAX_BASE_CACHE_IMAGES = 18; +const MAX_OVERLAY_CACHE_IMAGES = 18; +const MAX_SEGMENT_VECTOR_CACHE = 48; +const CONTINUOUS_BASE_MODES = ["terrain", "development", "all"]; + +function nowMs() { + return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); +} + +function fieldRefSignature(map) { + const refs = [ + map?.elevation, map?.slope, map?.sea, map?.prefectureMask, map?.humanRegionMask, + map?.landuse, map?.populationDensity, map?.prefectureRegionId, map?.adminId, + ]; + return refs.map((field) => ArrayBuffer.isView(field) ? `${field.constructor.name}:${field.length}` : "-").join("|"); +} + +function generatedRectSignature(map) { + const rects = Array.isArray(map?.generatedRects) ? map.generatedRects : []; + const last = rects[rects.length - 1] || null; + if (!last) return "0"; + return `${rects.length}:${last.x0 || 0},${last.y0 || 0},${last.x1 || 0},${last.y1 || 0}:${last.terrainType || ""}:${last.variant || ""}`; +} + +function stableViewportCachePrefix(map) { + const camera = map?.worldCamera || {}; + const origin = map?.worldOrigin || {}; + return [ + map?.baseSeed ?? map?.seed ?? 0, + map?.effectiveSeed ?? "", + map?.generationContext?.variant ?? "", + mapWidth(map), + mapHeight(map), + Math.round(camera.x || 0), + Math.round(camera.y || 0), + Math.round(origin.x || 0), + Math.round(origin.y || 0), + generatedRectSignature(map), + fieldRefSignature(map), + ].join(":"); +} + +function cappedSet(cache, key, value, maxSize) { + cache.set(key, value); + while (cache.size > maxSize) cache.delete(cache.keys().next().value); + return value; +} + +function segmentContentSignature(segments) { + const count = segments?.length || 0; + if (!count) return "0"; + const step = Math.max(1, Math.floor(count / 24)); + const parts = [String(count)]; + for (let i = 0; i < count; i += step) { + const seg = segments[i]; + parts.push(`${seg?.[0]?.[0] || 0},${seg?.[0]?.[1] || 0},${seg?.[1]?.[0] || 0},${seg?.[1]?.[1] || 0}`); + } + const last = segments[count - 1]; + parts.push(`${last?.[0]?.[0] || 0},${last?.[0]?.[1] || 0},${last?.[1]?.[0] || 0},${last?.[1]?.[1] || 0}`); + return parts.join("|"); +} function mapWidth(map) { return Math.max(1, Math.floor(Number.isFinite(map?.width) ? map.width : MAP_W)); @@ -189,6 +252,12 @@ function vectorizeSegments(segments, { iterations = 2, tolerance = 0.08 } = {}) segmentVectorCache.set(segments, cachedByOption); } if (cachedByOption.has(cacheKey)) return cachedByOption.get(cacheKey); + const stableKey = `${cacheKey}:${segmentContentSignature(segments)}`; + const stableCached = segmentVectorStableCache.get(stableKey); + if (stableCached) { + cachedByOption.set(cacheKey, stableCached); + return stableCached; + } const polylines = segmentChains(segments).map((line) => { const cleaned = removeCollinear(line); @@ -199,6 +268,7 @@ function vectorizeSegments(segments, { iterations = 2, tolerance = 0.08 } = {}) }).filter((line) => line.length >= 2); cachedByOption.set(cacheKey, polylines); + cappedSet(segmentVectorStableCache, stableKey, polylines, MAX_SEGMENT_VECTOR_CACHE); return polylines; } @@ -527,35 +597,29 @@ function discreteColor(map, x, y, mode) { return blendOutside(color, Boolean(map.prefectureMask[i])); } -function baseCacheKey(mode, continuousTerrain, renderScale = 1) { - const continuousModes = ["terrain", "development", "all"]; - const scaleKey = Math.round((renderScale || 1) * 20) / 20; - if (continuousTerrain && continuousModes.includes(mode)) { - return `continuous:${mode === "all" ? "terrain" : mode}:${scaleKey}`; +function baseCacheKey(map, mode, continuousTerrain, renderScale = 1) { + const prefix = stableViewportCachePrefix(map); + if (continuousTerrain && CONTINUOUS_BASE_MODES.includes(mode)) { + const scaleKey = Math.round((renderScale || 1) * 20) / 20; + return `${prefix}:continuous:${mode === "all" ? "terrain" : mode}:${scaleKey}`; } - return `discrete:${mode}:${scaleKey}`; + return `${prefix}:discrete:${mode}:1`; } function getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale = 1) { - let cache = baseImageCache.get(map); - if (!cache) { - cache = new Map(); - baseImageCache.set(map, cache); - } - - const key = baseCacheKey(mode, continuousTerrain, renderScale); - let canvas = cache.get(key); + const key = baseCacheKey(map, mode, continuousTerrain, renderScale); + let canvas = baseImageCache.get(key); if (canvas) return canvas; const sourceWidth = mapPixelWidth(map); const sourceHeight = mapPixelHeight(map); - const targetScale = Math.max(0.35, Math.min(1, renderScale || 1)); + const continuous = continuousTerrain && CONTINUOUS_BASE_MODES.includes(mode); + const targetScale = continuous ? Math.max(0.35, Math.min(1, renderScale || 1)) : 1; const width = Math.max(1, Math.round(sourceWidth * targetScale)); const height = Math.max(1, Math.round(sourceHeight * targetScale)); const img = ctx.createImageData(width, height); - const continuousModes = ["terrain", "development", "all"]; - if (continuousTerrain && continuousModes.includes(mode)) { + if (continuous) { for (let py = 0; py < height; py++) { const fy = (py / Math.max(1, height)) * mapHeight(map); for (let px = 0; px < width; px++) { @@ -594,9 +658,7 @@ function getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale = 1) canvas.height = height; const bctx = canvas.getContext("2d"); bctx.putImageData(img, 0, 0); - cache.set(key, canvas); - if (cache.size > MAX_BASE_CACHE_IMAGES) cache.delete(cache.keys().next().value); - return canvas; + return cappedSet(baseImageCache, key, canvas, MAX_BASE_CACHE_IMAGES); } function drawBase(ctx, map, mode, continuousTerrain, renderScale = 1) { @@ -842,6 +904,13 @@ function drawUrbanAreas(ctx, map, mode) { const visibleModes = ["all", "modern", "development", "landuse", "admin"]; if (!visibleModes.includes(mode)) return; + const key = `${stableViewportCachePrefix(map)}:urban:${mode}`; + let canvas = urbanOverlayCache.get(key); + if (canvas) { + ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map)); + return; + } + const detailedColors = { 2: "rgba(223, 214, 206, 0.72)", 3: "rgba(215, 175, 172, 0.88)", @@ -854,7 +923,11 @@ function drawUrbanAreas(ctx, map, mode) { const cityColor = "rgba(232, 222, 228, 0.60)"; const cbdColor = "rgba(215, 175, 172, 0.84)"; - ctx.save(); + canvas = document.createElement("canvas"); + canvas.width = mapPixelWidth(map); + canvas.height = mapPixelHeight(map); + const bctx = canvas.getContext("2d"); + if (!bctx) return; const w = mapWidth(map); const h = mapHeight(map); for (let y = 0; y < h; y++) { @@ -871,11 +944,12 @@ function drawUrbanAreas(ctx, map, mode) { const px = x * CELL_SIZE; const py = y * CELL_SIZE; - ctx.fillStyle = fill; - ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE); + bctx.fillStyle = fill; + bctx.fillRect(px, py, CELL_SIZE, CELL_SIZE); } } - ctx.restore(); + cappedSet(urbanOverlayCache, key, canvas, MAX_OVERLAY_CACHE_IMAGES); + ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map)); } function drawDebugCells(ctx, map, field, color) { @@ -940,9 +1014,19 @@ function drawPrefectureRegionFill(ctx, map, mode) { if (!["all", "admin", "borders-debug"].includes(mode)) return; const ids = map.prefectureRegionId; if (!ids) return; + const key = `${stableViewportCachePrefix(map)}:prefecture-fill:${mode}`; + let canvas = prefectureFillCache.get(key); + if (canvas) { + ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map)); + return; + } const alpha = mode === "borders-debug" ? 0.34 : 0.18; - ctx.save(); - ctx.globalAlpha = alpha; + canvas = document.createElement("canvas"); + canvas.width = mapPixelWidth(map); + canvas.height = mapPixelHeight(map); + const bctx = canvas.getContext("2d"); + if (!bctx) return; + bctx.globalAlpha = alpha; const w = mapWidth(map); const h = mapHeight(map); for (let y = 0; y < h; y++) { @@ -951,11 +1035,12 @@ function drawPrefectureRegionFill(ctx, map, mode) { const id = ids[i]; if (map.sea[i] || id < 0) continue; const [r, g, b] = prefectureRegionColor(id); - ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; - ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE); + bctx.fillStyle = `rgb(${r}, ${g}, ${b})`; + bctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE); } } - ctx.restore(); + cappedSet(prefectureFillCache, key, canvas, MAX_OVERLAY_CACHE_IMAGES); + ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map)); } function dot(ctx, p, radius, fill, stroke = "white") { @@ -1090,6 +1175,14 @@ export function drawMap(canvas, map, options) { const ctx = canvas.getContext("2d"); if (!ctx) return; + const timings = {}; + let timingMark = nowMs(); + const markTiming = (key) => { + const t = nowMs(); + timings[key] = Math.round((t - timingMark) * 10) / 10; + timingMark = t; + }; + const mode = options.mode || "all"; const showFeatures = options.showFeatures !== false; const showLabels = options.showLabels !== false; @@ -1102,7 +1195,8 @@ export function drawMap(canvas, map, options) { const drawOffsetX = (outputWidth - sourceWidth * drawScale) * 0.5; const drawOffsetY = (outputHeight - sourceHeight * drawScale) * 0.5; const cellScreenSize = CELL_SIZE * drawScale; - const terrainRenderScale = continuousTerrain ? clamp(drawScale * (options.fastTerrain ? 0.48 : 1.35), 0.30, 1) : 1; + const terrainQualityScale = options.fastTerrain ? 0.48 : 0.62; + const terrainRenderScale = continuousTerrain ? clamp(drawScale * terrainQualityScale, 0.30, options.fastTerrain ? 0.55 : 0.78) : 1; if (canvas.width !== outputWidth) canvas.width = outputWidth; if (canvas.height !== outputHeight) canvas.height = outputHeight; @@ -1117,14 +1211,19 @@ export function drawMap(canvas, map, options) { drawScaleBar(ctx, cellScreenSize); delete ctx.__mapPixelWidth; delete ctx.__mapPixelHeight; + timings.scale = Math.round((nowMs() - timingMark) * 10) / 10; + return timings; }; // 1. Base Terrain & Urban drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale); + markTiming("baseTerrain"); drawUrbanAreas(ctx, map, mode); + markTiming("urbanFill"); 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 }); drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + markTiming("coastline"); // 2. Rivers const waterBlue = "rgba(116, 165, 202, 0.92)"; @@ -1175,6 +1274,7 @@ export function drawMap(canvas, map, options) { return 1.55 * downstreamBoost; }, 1.0); } + markTiming("rivers"); const showHistory = mode === "history"; const showTransportDebug = mode === "transport-debug"; @@ -1214,10 +1314,10 @@ export function drawMap(canvas, map, options) { drawVectorSegments(ctx, finalPrefectureBorders, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); } + markTiming("adminBorders"); if (!showFeatures) { - finish(); - return; + return finish(); } // 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways. @@ -1267,6 +1367,7 @@ export function drawMap(canvas, map, options) { for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); } + markTiming("transport"); // 6. Icons & Labels if (["admin", "borders-debug"].includes(mode)) { @@ -1307,6 +1408,7 @@ export function drawMap(canvas, map, options) { } } } + markTiming("icons"); if (showLabels) { const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 })); @@ -1315,13 +1417,13 @@ export function drawMap(canvas, map, options) { .filter((p) => p && p.name && Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x < map.width && p.y < map.height && map.adminId?.[Math.round(p.y) * map.width + Math.round(p.x)] === (p.adminId ?? p.municipalityId ?? p.adminNumericId)) .map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 })); drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity); - finish(); - return; + markTiming("labels"); + return finish(); } if (mode === "borders-debug") { drawLabels(ctx, prefectureLabels, Infinity); - finish(); - return; + markTiming("labels"); + return finish(); } const important = [ ...prefectureLabels, @@ -1332,5 +1434,6 @@ export function drawMap(canvas, map, options) { ].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); } - finish(); + markTiming("labels"); + return finish(); }