2026-05-20 13:50:56 +09:00
import { drawMap } from "./renderer.js" ;
2026-05-24 17:38:51 +09:00
import { landuseLabel } from "./landuseCodes.js" ;
2026-08-08 17:41:30 +09:00
import { CELL _SIZE , MAP _H , MAP _W , worldIndexOf } from "./mapUtils.js" ;
2026-05-28 23:51:55 +09:00
import { clampCameraToWorld , createInitialCamera , createWorldMap , ensureWorldPaddingForCamera } from "./worldMap.js" ;
2026-05-28 19:37:28 +09:00
import { getViewportMap } from "./worldViewport.js" ;
2026-08-08 17:41:30 +09:00
import { PATCH _MIN _AREA , PATCH _MIN _HEIGHT , PATCH _MIN _WIDTH , buildPatchRects , validatePatchRect } from "./mapPatch.js" ;
import { collectTransferableBuffers } from "./transferUtils.js" ;
2026-05-20 13:50:56 +09:00
const modes = [
[ "all" , "All" ] ,
[ "terrain" , "Terrain" ] ,
[ "modern" , "Modern" ] ,
2026-05-29 23:49:02 +09:00
[ "history" , "Premodern" ] ,
2026-05-20 13:50:56 +09:00
[ "landuse" , "Land Use" ] ,
2026-05-29 23:49:02 +09:00
[ "admin" , "Admin" ] ,
2026-05-20 13:50:56 +09:00
] ;
const state = {
seedText : "114514" ,
2026-05-28 16:45:00 +09:00
generationType : "auto" ,
2026-05-20 13:50:56 +09:00
mode : "all" ,
2026-05-29 23:49:02 +09:00
toolMode : "pan" ,
2026-05-20 13:50:56 +09:00
showFeatures : true ,
showLabels : true ,
2026-08-08 17:41:30 +09:00
showSeamDiagnostics : true ,
2026-05-20 13:50:56 +09:00
map : null ,
2026-05-28 19:37:28 +09:00
world : null ,
camera : { x : 0 , y : 0 } ,
viewportMap : null ,
2026-05-29 14:31:42 +09:00
viewWidth : MAP _W ,
viewHeight : MAP _H ,
2026-05-26 16:56:18 +09:00
hoverEntities : [ ] ,
2026-05-28 19:37:28 +09:00
selectionRect : null ,
2026-05-28 23:51:55 +09:00
patchVariant : 0 ,
zoom : 1 ,
2026-05-28 19:37:28 +09:00
lastPatchResult : null ,
2026-05-29 22:00:42 +09:00
pendingPatch : null ,
2026-08-08 17:41:30 +09:00
patchBusy : false ,
patchBusyVariant : null ,
patchStatusMessage : "" ,
renderRevision : 0 ,
2026-05-29 23:49:02 +09:00
generationRuns : [ ] ,
patchRuns : [ ] ,
interactionRuns : [ ] ,
diagnosticLog : [ ] ,
diagnostics : {
worldExpansionCount : 0 ,
lastWorldExpansion : null ,
lastViewport : null ,
lastFeatureCounts : null ,
lastWorkerUsed : null ,
lastWorkerFallbackReason : null ,
lastPatchWorkerKind : null ,
2026-08-08 17:41:30 +09:00
lastGenerationWorkerUsed : null ,
lastGenerationWorkerFallbackReason : null ,
2026-05-29 23:49:02 +09:00
} ,
2026-05-20 13:50:56 +09:00
} ;
const canvas = document . getElementById ( "mapCanvas" ) ;
2026-05-28 00:30:09 +09:00
const canvasShell = document . querySelector ( ".canvas-shell" ) ;
2026-05-20 13:50:56 +09:00
const seedInput = document . getElementById ( "seed" ) ;
2026-05-28 16:45:00 +09:00
const generationTypeInput = document . getElementById ( "generationType" ) ;
2026-05-28 19:37:28 +09:00
const patchTerrainTypeInput = document . getElementById ( "patchTerrainType" ) ;
2026-08-08 17:41:30 +09:00
const patchModeInput = document . getElementById ( "patchMode" ) ;
2026-05-28 23:51:55 +09:00
const patchVariantInput = document . getElementById ( "patchVariant" ) ;
2026-05-28 19:37:28 +09:00
const generatePatchButton = document . getElementById ( "generatePatch" ) ;
2026-05-28 23:51:55 +09:00
const alternativePatchButton = document . getElementById ( "alternativePatch" ) ;
2026-05-28 19:37:28 +09:00
const patchStatusEl = document . getElementById ( "patchStatus" ) ;
2026-05-29 23:49:02 +09:00
const generateMapButton = document . getElementById ( "generateMap" ) ;
2026-05-20 13:50:56 +09:00
const randomSeedButton = document . getElementById ( "randomSeed" ) ;
2026-05-29 23:49:02 +09:00
const toolPanButton = document . getElementById ( "toolPan" ) ;
const toolPatchButton = document . getElementById ( "toolPatch" ) ;
const toolHintEl = document . getElementById ( "toolHint" ) ;
const zoomInButton = document . getElementById ( "zoomIn" ) ;
const zoomOutButton = document . getElementById ( "zoomOut" ) ;
const zoomResetButton = document . getElementById ( "zoomReset" ) ;
const centerMapButton = document . getElementById ( "centerMap" ) ;
const applyPatchButton = document . getElementById ( "applyPatch" ) ;
const discardPatchButton = document . getElementById ( "discardPatch" ) ;
2026-08-08 17:41:30 +09:00
const clearPatchSelectionButton = document . getElementById ( "clearPatchSelection" ) ;
const cancelPatchButton = document . getElementById ( "cancelPatchGeneration" ) ;
2026-05-20 13:50:56 +09:00
const showFeaturesInput = document . getElementById ( "showFeatures" ) ;
const showLabelsInput = document . getElementById ( "showLabels" ) ;
2026-08-08 17:41:30 +09:00
const showSeamDiagnosticsInput = document . getElementById ( "showSeamDiagnostics" ) ;
2026-05-20 13:50:56 +09:00
const modeGrid = document . getElementById ( "modeGrid" ) ;
2026-05-29 23:49:02 +09:00
const mainLegendGrid = document . getElementById ( "mainLegendGrid" ) ;
const floatingLegendGrid = document . getElementById ( "floatingLegendGrid" ) ;
2026-05-20 13:50:56 +09:00
const statsEl = document . getElementById ( "stats" ) ;
2026-05-29 23:49:02 +09:00
const advancedGenerationStatsEl = document . getElementById ( "advancedGenerationStats" ) ;
const advancedGenerationHistoryEl = document . getElementById ( "advancedGenerationHistory" ) ;
const advancedPatchStatsEl = document . getElementById ( "advancedPatchStats" ) ;
const advancedPatchHistoryEl = document . getElementById ( "advancedPatchHistory" ) ;
const advancedInteractionStatsEl = document . getElementById ( "advancedInteractionStats" ) ;
const advancedInteractionHistoryEl = document . getElementById ( "advancedInteractionHistory" ) ;
const advancedViewportDiagnosticsEl = document . getElementById ( "advancedViewportDiagnostics" ) ;
const advancedFeatureCountsEl = document . getElementById ( "advancedFeatureCounts" ) ;
const advancedPatchDiagnosticsEl = document . getElementById ( "advancedPatchDiagnostics" ) ;
2026-08-08 17:41:30 +09:00
const advancedSeamDiagnosticsEl = document . getElementById ( "advancedSeamDiagnostics" ) ;
2026-05-29 23:49:02 +09:00
const advancedWorkerDiagnosticsEl = document . getElementById ( "advancedWorkerDiagnostics" ) ;
const advancedWorldDiagnosticsEl = document . getElementById ( "advancedWorldDiagnostics" ) ;
const advancedWarningHistoryEl = document . getElementById ( "advancedWarningHistory" ) ;
const copyImportantDataButton = document . getElementById ( "copyImportantData" ) ;
const copyDebugStatusEl = document . getElementById ( "copyDebugStatus" ) ;
2026-05-20 13:50:56 +09:00
const tooltipEl = document . getElementById ( "mapTooltip" ) ;
2026-05-29 14:31:42 +09:00
const selectionSvgEl = document . getElementById ( "mapSelectionSvg" ) ;
2026-05-28 19:37:28 +09:00
const selectionEl = document . getElementById ( "mapSelection" ) ;
2026-05-24 19:33:09 +09:00
const progressEl = document . getElementById ( "generationProgress" ) ;
const progressStageEl = document . getElementById ( "generationProgressStage" ) ;
const progressTimingsEl = document . getElementById ( "generationProgressTimings" ) ;
2026-05-24 22:52:22 +09:00
let generationStartedAt = 0 ;
let generationCurrentStage = "" ;
let generationTimer = null ;
2026-05-29 14:31:42 +09:00
let zoomRedrawRaf = null ;
let zoomSettledTimer = null ;
2026-05-29 22:00:42 +09:00
let zoomVisualState = null ;
2026-05-29 23:49:02 +09:00
let zoomLatencyStartedAt = null ;
2026-05-29 22:00:42 +09:00
let patchWorker = null ;
let patchJobSeq = 0 ;
2026-08-08 17:41:30 +09:00
let patchRequestSeq = 0 ;
let activePatchCancel = null ;
const PATCH _WORKER _INACTIVITY _WATCHDOG _MS = 60_000 ;
let generationWorker = null ;
let generationJobSeq = 0 ;
let generationRequestSeq = 0 ;
const generationPendingJobs = new Map ( ) ;
2026-05-20 13:50:56 +09:00
2026-05-28 19:37:28 +09:00
const dragState = {
mode : null ,
pointerId : null ,
startClientX : 0 ,
startClientY : 0 ,
startCameraX : 0 ,
startCameraY : 0 ,
2026-05-29 18:50:54 +09:00
lastClientX : 0 ,
lastClientY : 0 ,
panRemainderX : 0 ,
panRemainderY : 0 ,
2026-05-28 19:37:28 +09:00
selectStart : null ,
selectEnd : null ,
2026-05-29 14:31:42 +09:00
selectPath : null ,
2026-05-28 19:37:28 +09:00
pendingCamera : null ,
panRaf : null ,
2026-05-29 23:49:02 +09:00
panLatencyStartedAt : null ,
2026-05-28 19:37:28 +09:00
} ;
2026-05-29 22:00:42 +09:00
function displayWorld ( ) {
return state . pendingPatch ? . world || state . world ;
}
function displaySourceMap ( ) {
return displayWorld ( ) ? . sourceMap || state . map ;
}
2026-05-28 19:37:28 +09:00
function activeMap ( ) {
2026-05-29 22:00:42 +09:00
return state . viewportMap || displaySourceMap ( ) ;
2026-05-28 19:37:28 +09:00
}
2026-05-28 00:30:09 +09:00
2026-05-28 23:51:55 +09:00
function clampZoom ( value ) {
const parsed = Number ( value ) ;
if ( ! Number . isFinite ( parsed ) ) return 1 ;
return Math . min ( Math . max ( parsed , 0.55 ) , 2.8 ) ;
}
2026-05-29 14:31:42 +09:00
function viewportSizeForZoom ( zoom = state . zoom ) {
const z = clampZoom ( zoom || 1 ) ;
2026-05-28 23:51:55 +09:00
return {
2026-05-29 14:31:42 +09:00
width : Math . max ( 1 , Math . ceil ( MAP _W / z ) ) ,
height : Math . max ( 1 , Math . ceil ( MAP _H / z ) ) ,
2026-05-28 23:51:55 +09:00
} ;
}
2026-05-29 22:00:42 +09:00
function clampCameraForView ( camera , size = viewportSizeForZoom ( state . zoom ) , world = displayWorld ( ) ) {
return clampCameraToWorld ( camera , world , size ? . width || MAP _W , size ? . height || MAP _H ) ;
2026-05-29 14:31:42 +09:00
}
function syncViewportSize ( ) {
const size = viewportSizeForZoom ( state . zoom ) ;
state . viewWidth = size . width ;
state . viewHeight = size . height ;
return size ;
}
2026-05-29 22:00:42 +09:00
function canvasInteractionRect ( ) {
return zoomVisualState ? . baseRect || canvas . getBoundingClientRect ( ) ;
}
2026-05-29 14:31:42 +09:00
function mapCellScreenSize ( map = activeMap ( ) ) {
2026-05-29 22:00:42 +09:00
const rect = canvasInteractionRect ( ) ;
2026-05-29 14:31:42 +09:00
const mapWidth = Math . max ( 1 , map ? . width || state . viewWidth || MAP _W ) ;
return rect . width ? rect . width / mapWidth : CELL _SIZE * clampZoom ( state . zoom || 1 ) ;
}
2026-05-28 23:51:55 +09:00
function applyCanvasZoom ( ) {
if ( ! canvas ) return ;
state . zoom = clampZoom ( state . zoom || 1 ) ;
2026-05-29 23:49:02 +09:00
const baseWidth = MAP _W * CELL _SIZE ;
const baseHeight = MAP _H * CELL _SIZE ;
const shellRect = canvasShell ? . getBoundingClientRect ( ) ;
const availableWidth = Math . max ( 160 , ( shellRect ? . width || baseWidth ) - 24 ) ;
const availableHeight = Math . max ( 160 , ( shellRect ? . height || baseHeight ) - 24 ) ;
const displayScale = Math . max ( 0.18 , Math . min ( availableWidth / baseWidth , availableHeight / baseHeight ) ) ;
canvas . style . width = ` ${ Math . round ( baseWidth * displayScale ) } px ` ;
canvas . style . height = ` ${ Math . round ( baseHeight * displayScale ) } px ` ;
syncSelectionSvgToCanvas ( ) ;
2026-05-28 23:51:55 +09:00
updateSelectionOverlayFromWorldRect ( ) ;
}
function displayedCellSize ( ) {
2026-05-29 14:31:42 +09:00
return mapCellScreenSize ( ) ;
2026-05-28 23:51:55 +09:00
}
2026-05-29 14:31:42 +09:00
function screenPointToMapPixel ( clientX , clientY , sizeOverride = null ) {
2026-05-29 22:00:42 +09:00
const rect = canvasInteractionRect ( ) ;
2026-05-28 23:51:55 +09:00
if ( ! rect . width || ! rect . height ) return null ;
2026-05-29 14:31:42 +09:00
const map = activeMap ( ) ;
const viewWidth = Math . max ( 1 , sizeOverride ? . width || map ? . width || state . viewWidth || MAP _W ) ;
const viewHeight = Math . max ( 1 , sizeOverride ? . height || map ? . height || state . viewHeight || MAP _H ) ;
const canvasX = ( clientX - rect . left ) * ( ( canvas . width || MAP _W * CELL _SIZE ) / rect . width ) ;
const canvasY = ( clientY - rect . top ) * ( ( canvas . height || MAP _H * CELL _SIZE ) / rect . height ) ;
const cellX = canvasX / Math . max ( 1e-6 , ( canvas . width || MAP _W * CELL _SIZE ) / viewWidth ) ;
const cellY = canvasY / Math . max ( 1e-6 , ( canvas . height || MAP _H * CELL _SIZE ) / viewHeight ) ;
return { x : cellX * CELL _SIZE , y : cellY * CELL _SIZE } ;
2026-05-28 23:51:55 +09:00
}
function mapPixelToScreenPoint ( px , py ) {
2026-05-29 22:00:42 +09:00
const rect = canvasInteractionRect ( ) ;
2026-05-29 14:31:42 +09:00
const map = activeMap ( ) ;
const viewWidth = Math . max ( 1 , map ? . width || state . viewWidth || MAP _W ) ;
const viewHeight = Math . max ( 1 , map ? . height || state . viewHeight || MAP _H ) ;
const canvasX = ( px / CELL _SIZE ) * ( ( canvas . width || MAP _W * CELL _SIZE ) / viewWidth ) ;
const canvasY = ( py / CELL _SIZE ) * ( ( canvas . height || MAP _H * CELL _SIZE ) / viewHeight ) ;
2026-05-28 23:51:55 +09:00
return {
2026-05-29 14:31:42 +09:00
x : canvas . offsetLeft + canvasX * ( rect . width / Math . max ( 1 , canvas . width || MAP _W * CELL _SIZE ) ) ,
y : canvas . offsetTop + canvasY * ( rect . height / Math . max ( 1 , canvas . height || MAP _H * CELL _SIZE ) ) ,
2026-05-28 23:51:55 +09:00
} ;
}
2026-05-29 14:31:42 +09:00
function mapClientToCell ( event , sizeOverride = null ) {
2026-05-28 19:37:28 +09:00
const map = activeMap ( ) ;
2026-05-29 14:31:42 +09:00
if ( ! map && ! sizeOverride ) return null ;
const p = screenPointToMapPixel ( event . clientX , event . clientY , sizeOverride ) ;
2026-05-28 23:51:55 +09:00
if ( ! p ) return null ;
2026-05-28 00:30:09 +09:00
return {
2026-05-28 23:51:55 +09:00
x : Math . floor ( p . x / CELL _SIZE ) ,
y : Math . floor ( p . y / CELL _SIZE ) ,
2026-05-28 00:30:09 +09:00
} ;
}
2026-05-28 19:37:28 +09:00
function viewportCellToWorldCell ( cell ) {
if ( ! cell || ! state . camera ) return null ;
return {
x : Math . round ( state . camera . x || 0 ) + cell . x ,
y : Math . round ( state . camera . y || 0 ) + cell . y ,
} ;
2026-05-28 00:30:09 +09:00
}
2026-05-28 19:37:28 +09:00
function clampCanvasPoint ( event ) {
2026-05-29 22:00:42 +09:00
const rect = canvasInteractionRect ( ) ;
2026-05-28 19:37:28 +09:00
return {
x : Math . min ( Math . max ( event . clientX - rect . left , 0 ) , rect . width ) ,
y : Math . min ( Math . max ( event . clientY - rect . top , 0 ) , rect . height ) ,
} ;
}
2026-05-29 14:31:42 +09:00
function screenPointToWorldCell ( point ) {
const map = activeMap ( ) ;
if ( ! map || ! point ) return null ;
2026-05-29 22:00:42 +09:00
const rect = canvasInteractionRect ( ) ;
2026-05-29 14:31:42 +09:00
if ( ! rect . width || ! rect . height ) return null ;
const viewWidth = Math . max ( 1 , map . width || state . viewWidth || MAP _W ) ;
const viewHeight = Math . max ( 1 , map . height || state . viewHeight || MAP _H ) ;
const canvasX = point . x * ( ( canvas . width || MAP _W * CELL _SIZE ) / rect . width ) ;
const canvasY = point . y * ( ( canvas . height || MAP _H * CELL _SIZE ) / rect . height ) ;
const localX = Math . floor ( ( canvasX / Math . max ( 1e-6 , ( canvas . width || MAP _W * CELL _SIZE ) / viewWidth ) ) ) ;
const localY = Math . floor ( ( canvasY / Math . max ( 1e-6 , ( canvas . height || MAP _H * CELL _SIZE ) / viewHeight ) ) ) ;
const cameraX = Math . round ( state . camera ? . x || 0 ) ;
const cameraY = Math . round ( state . camera ? . y || 0 ) ;
return {
x : cameraX + Math . min ( Math . max ( localX , 0 ) , Math . max ( 0 , map . width - 1 ) ) ,
y : cameraY + Math . min ( Math . max ( localY , 0 ) , Math . max ( 0 , map . height - 1 ) ) ,
} ;
}
function worldCellToOverlayPoint ( point ) {
const cameraX = Math . round ( state . camera ? . x || 0 ) ;
const cameraY = Math . round ( state . camera ? . y || 0 ) ;
const screen = mapPixelToScreenPoint ( ( point . x - cameraX + 0.5 ) * CELL _SIZE , ( point . y - cameraY + 0.5 ) * CELL _SIZE ) ;
return { x : screen . x - canvas . offsetLeft , y : screen . y - canvas . offsetTop } ;
}
function simplifySelectionPath ( points ) {
const out = [ ] ;
2026-08-08 17:41:30 +09:00
// Pointer sampling already suppresses sub-3 px jitter. A second 6 px filter
// turned curved lassos into long chords at low zoom, and those chords leaked
// into terrain/administrative seams. Keep a small adaptive tolerance instead.
const minDistance = Math . max ( 1.5 , Math . min ( 3 , displayedCellSize ( ) * 0.35 ) ) ;
2026-05-29 14:31:42 +09:00
for ( const p of points || [ ] ) {
2026-08-08 17:41:30 +09:00
if ( ! out . length || Math . hypot ( out [ out . length - 1 ] . x - p . x , out [ out . length - 1 ] . y - p . y ) >= minDistance ) out . push ( p ) ;
2026-05-29 14:31:42 +09:00
}
return out ;
}
function polygonArea ( points ) {
let area = 0 ;
for ( let i = 0 ; i < points . length ; i ++ ) {
const a = points [ i ] ;
const b = points [ ( i + 1 ) % points . length ] ;
area += a . x * b . y - b . x * a . y ;
}
return Math . abs ( area ) * 0.5 ;
}
function selectionPathToShape ( points ) {
const simplified = simplifySelectionPath ( points || [ ] ) ;
if ( simplified . length < 3 ) return null ;
2026-08-08 17:41:30 +09:00
const polygon = [ ] ;
for ( const point of simplified . map ( screenPointToWorldCell ) . filter ( Boolean ) ) {
const last = polygon [ polygon . length - 1 ] ;
if ( ! last || last . x !== point . x || last . y !== point . y ) polygon . push ( point ) ;
}
if ( polygon . length > 2 && polygon [ 0 ] . x === polygon [ polygon . length - 1 ] . x && polygon [ 0 ] . y === polygon [ polygon . length - 1 ] . y ) polygon . pop ( ) ;
2026-05-29 14:31:42 +09:00
if ( polygon . length < 3 ) return null ;
const xs = polygon . map ( ( p ) => p . x ) ;
const ys = polygon . map ( ( p ) => p . y ) ;
return {
kind : "lasso" ,
polygon ,
x0 : Math . min ( ... xs ) ,
y0 : Math . min ( ... ys ) ,
x1 : Math . max ( ... xs ) + 1 ,
y1 : Math . max ( ... ys ) + 1 ,
areaCells : Math . max ( 1 , Math . round ( polygonArea ( polygon ) ) ) ,
} ;
}
2026-05-29 18:50:54 +09:00
function syncSelectionSvgToCanvas ( ) {
if ( ! selectionSvgEl || ! canvas ) return null ;
const rect = canvas . getBoundingClientRect ( ) ;
const width = Math . max ( 1 , rect . width || canvas . clientWidth || canvas . width || 1 ) ;
const height = Math . max ( 1 , rect . height || canvas . clientHeight || canvas . height || 1 ) ;
selectionSvgEl . style . left = ` ${ canvas . offsetLeft } px ` ;
selectionSvgEl . style . top = ` ${ canvas . offsetTop } px ` ;
selectionSvgEl . style . width = ` ${ width } px ` ;
selectionSvgEl . style . height = ` ${ height } px ` ;
selectionSvgEl . setAttribute ( "width" , String ( width ) ) ;
selectionSvgEl . setAttribute ( "height" , String ( height ) ) ;
selectionSvgEl . setAttribute ( "viewBox" , ` 0 0 ${ width } ${ height } ` ) ;
return { width , height } ;
}
2026-05-29 14:31:42 +09:00
function drawSelectionSvg ( points , invalid = false ) {
if ( ! selectionSvgEl ) return ;
2026-05-29 18:50:54 +09:00
const bounds = syncSelectionSvgToCanvas ( ) ;
if ( ! bounds || ! points || points . length < 3 ) {
2026-05-29 14:31:42 +09:00
selectionSvgEl . style . display = "none" ;
selectionSvgEl . innerHTML = "" ;
return ;
}
2026-05-29 18:50:54 +09:00
const pts = points . map ( ( p ) => {
const x = Math . min ( Math . max ( p . x , 0 ) , bounds . width ) ;
const y = Math . min ( Math . max ( p . y , 0 ) , bounds . height ) ;
return ` ${ x } , ${ y } ` ;
} ) . join ( " " ) ;
2026-05-29 14:31:42 +09:00
selectionSvgEl . innerHTML = ` <polygon points=" ${ pts } " /> ` ;
selectionSvgEl . style . display = "block" ;
selectionSvgEl . classList . toggle ( "invalid" , ! ! invalid ) ;
}
function hideSelectionSvg ( ) {
if ( ! selectionSvgEl ) return ;
selectionSvgEl . style . display = "none" ;
selectionSvgEl . innerHTML = "" ;
selectionSvgEl . classList . remove ( "invalid" ) ;
}
2026-05-28 19:37:28 +09:00
function updateSelectionOverlay ( ) {
2026-05-29 14:31:42 +09:00
if ( ! dragState . selectPath ? . length ) return ;
const liveShape = selectionPathToShape ( dragState . selectPath ) ;
const validation = validatePatchRect ( liveShape , state . world ) ;
drawSelectionSvg ( dragState . selectPath , ! validation . ok ) ;
if ( selectionEl ) selectionEl . style . display = "none" ;
2026-05-28 19:37:28 +09:00
if ( generatePatchButton ) generatePatchButton . disabled = true ;
2026-05-28 23:51:55 +09:00
if ( alternativePatchButton ) alternativePatchButton . disabled = true ;
2026-05-28 19:37:28 +09:00
if ( patchStatusEl ) {
2026-05-29 14:31:42 +09:00
const current = validation . rect || liveShape ;
2026-05-28 19:37:28 +09:00
patchStatusEl . textContent = validation . ok
? ` Selected: ${ formatRectSize ( validation . rect ) } . Release to confirm patch selection. `
: ` ${ validation . reason } Current: ${ formatRectSize ( current ) } . ` ;
patchStatusEl . classList . toggle ( "invalid" , ! validation . ok ) ;
}
}
function updateSelectionOverlayFromWorldRect ( ) {
2026-05-29 14:31:42 +09:00
if ( ! state . selectionRect || ! state . camera || ! activeMap ( ) ) return ;
2026-05-28 19:37:28 +09:00
const rect = canvas . getBoundingClientRect ( ) ;
if ( ! rect . width || ! rect . height ) return ;
2026-05-29 14:31:42 +09:00
if ( Array . isArray ( state . selectionRect . polygon ) && state . selectionRect . polygon . length >= 3 ) {
const points = state . selectionRect . polygon . map ( worldCellToOverlayPoint )
. map ( ( p ) => ( { x : Math . min ( Math . max ( p . x , 0 ) , rect . width ) , y : Math . min ( Math . max ( p . y , 0 ) , rect . height ) } ) ) ;
drawSelectionSvg ( points , ! validatePatchRect ( state . selectionRect , state . world ) . ok ) ;
if ( selectionEl ) selectionEl . style . display = "none" ;
return ;
}
2026-05-28 19:37:28 +09:00
const cameraX = Math . round ( state . camera . x || 0 ) ;
const cameraY = Math . round ( state . camera . y || 0 ) ;
2026-05-28 23:51:55 +09:00
const p0 = mapPixelToScreenPoint ( ( state . selectionRect . x0 - cameraX ) * CELL _SIZE , ( state . selectionRect . y0 - cameraY ) * CELL _SIZE ) ;
const p1 = mapPixelToScreenPoint ( ( state . selectionRect . x1 - cameraX ) * CELL _SIZE , ( state . selectionRect . y1 - cameraY ) * CELL _SIZE ) ;
const vx0 = p0 . x - canvas . offsetLeft ;
const vy0 = p0 . y - canvas . offsetTop ;
const vx1 = p1 . x - canvas . offsetLeft ;
const vy1 = p1 . y - canvas . offsetTop ;
2026-05-28 19:37:28 +09:00
const x0 = Math . min ( Math . max ( Math . min ( vx0 , vx1 ) , 0 ) , rect . width ) ;
const y0 = Math . min ( Math . max ( Math . min ( vy0 , vy1 ) , 0 ) , rect . height ) ;
const x1 = Math . min ( Math . max ( Math . max ( vx0 , vx1 ) , 0 ) , rect . width ) ;
const y1 = Math . min ( Math . max ( Math . max ( vy0 , vy1 ) , 0 ) , rect . height ) ;
if ( x1 - x0 < 1 || y1 - y0 < 1 ) {
selectionEl . style . display = "none" ;
2026-05-28 00:30:09 +09:00
return ;
}
2026-05-29 14:31:42 +09:00
hideSelectionSvg ( ) ;
2026-05-28 19:37:28 +09:00
selectionEl . style . display = "block" ;
selectionEl . style . left = ` ${ canvas . offsetLeft + x0 } px ` ;
selectionEl . style . top = ` ${ canvas . offsetTop + y0 } px ` ;
selectionEl . style . width = ` ${ Math . max ( 1 , x1 - x0 ) } px ` ;
selectionEl . style . height = ` ${ Math . max ( 1 , y1 - y0 ) } px ` ;
const validation = validatePatchRect ( state . selectionRect , state . world ) ;
selectionEl . classList . toggle ( "invalid" , ! validation . ok ) ;
}
function formatRectSize ( rect ) {
if ( ! rect ) return "-" ;
const w = Math . max ( 0 , rect . x1 - rect . x0 ) ;
const h = Math . max ( 0 , rect . y1 - rect . y0 ) ;
2026-05-29 14:31:42 +09:00
const area = Math . max ( 0 , rect . areaCells || ( w * h ) ) ;
return ` ${ w } x ${ h } cells / ${ area . toLocaleString ( ) } cells ` ;
2026-05-28 19:37:28 +09:00
}
2026-05-28 23:51:55 +09:00
function normalizePatchVariant ( value ) {
const parsed = Number . parseInt ( value , 10 ) ;
return Number . isFinite ( parsed ) ? Math . max ( 0 , parsed ) >>> 0 : 0 ;
}
function setPatchVariant ( value , { update = true } = { } ) {
state . patchVariant = normalizePatchVariant ( value ) ;
if ( patchVariantInput && patchVariantInput . value !== String ( state . patchVariant ) ) {
patchVariantInput . value = String ( state . patchVariant ) ;
}
if ( update ) updatePatchControls ( ) ;
return state . patchVariant ;
}
function readPatchVariant ( ) {
return setPatchVariant ( patchVariantInput ? . value ? ? state . patchVariant , { update : false } ) ;
}
function resetPatchVariant ( { update = true } = { } ) {
return setPatchVariant ( 0 , { update } ) ;
}
2026-05-28 19:37:28 +09:00
function updatePatchControls ( ) {
2026-05-28 23:51:55 +09:00
const variant = readPatchVariant ( ) ;
2026-05-29 23:49:02 +09:00
const validation = validatePatchRect ( state . selectionRect , state . world ) ;
const hasValidSelection = ! ! validation . ok ;
const hasPreview = ! ! state . pendingPatch ;
2026-08-08 17:41:30 +09:00
const busy = ! ! state . patchBusy ;
if ( generatePatchButton ) generatePatchButton . disabled = ! hasValidSelection || busy ;
if ( alternativePatchButton ) alternativePatchButton . disabled = ! hasValidSelection || busy ;
if ( applyPatchButton ) applyPatchButton . disabled = ! hasPreview || busy ;
if ( discardPatchButton ) discardPatchButton . disabled = ! hasPreview || busy ;
if ( clearPatchSelectionButton ) clearPatchSelectionButton . disabled = ! state . selectionRect || busy ;
if ( cancelPatchButton ) cancelPatchButton . disabled = ! busy ;
if ( patchVariantInput ) patchVariantInput . disabled = busy ;
2026-05-28 19:37:28 +09:00
if ( ! patchStatusEl ) return ;
if ( ! state . selectionRect ) {
2026-05-29 23:49:02 +09:00
patchStatusEl . textContent = state . toolMode === "patch"
? ` Right-drag a freeform patch area. Minimum: ${ PATCH _MIN _WIDTH } x ${ PATCH _MIN _HEIGHT } cells and ${ PATCH _MIN _AREA . toLocaleString ( ) } cells. `
: "Right-drag on the map to draw a patch area." ;
2026-05-28 19:37:28 +09:00
patchStatusEl . classList . toggle ( "invalid" , false ) ;
return ;
}
if ( ! validation . ok ) {
patchStatusEl . textContent = ` ${ validation . reason } Current: ${ formatRectSize ( validation . rect || state . selectionRect ) } . ` ;
patchStatusEl . classList . toggle ( "invalid" , true ) ;
return ;
2026-05-28 00:30:09 +09:00
}
2026-05-28 19:37:28 +09:00
const rects = buildPatchRects ( validation . rect , state . world ) ;
2026-05-29 22:00:42 +09:00
const shownPatch = state . pendingPatch ? . result || state . lastPatchResult ;
2026-08-08 17:41:30 +09:00
const candidateModeText = shownPatch ? . patchGenerationMode || "" ;
const delta = state . pendingPatch ? . previewDelta || shownPatch ? . previewDelta || null ;
const deltaText = delta
? ` Changed ${ Number ( delta . changedCells || 0 ) . toLocaleString ( ) } cells ( ${ Number ( delta . terrainChangedCells || 0 ) . toLocaleString ( ) } terrain / ${ Number ( delta . adminChangedCells || 0 ) . toLocaleString ( ) } admin). `
: "" ;
2026-05-29 23:49:02 +09:00
const previewText = state . pendingPatch
2026-08-08 17:41:30 +09:00
? ` Preview ready: ${ shownPatch ? . label || "candidate" } , variant ${ shownPatch ? . variant ? ? variant } ${ candidateModeText ? ` , ${ candidateModeText } ` : "" } . ${ deltaText } Use Apply Preview or Discard. `
2026-05-29 23:49:02 +09:00
: shownPatch
2026-08-08 17:41:30 +09:00
? ` Last applied: ${ shownPatch . label || "patch" } , variant ${ shownPatch . variant ? ? "-" } ${ candidateModeText ? ` , ${ candidateModeText } ` : "" } . `
2026-05-29 23:49:02 +09:00
: "" ;
2026-08-08 17:41:30 +09:00
const busyText = state . patchBusy
? ` Generating variant ${ state . patchBusyVariant ? ? variant } ; the currently displayed map will be replaced only after a verified preview is rendered. `
: "" ;
const statusText = state . patchStatusMessage ? ` ${ state . patchStatusMessage } ` : "" ;
patchStatusEl . textContent = ` Selection: ${ formatRectSize ( validation . rect ) } . Core ${ formatRectSize ( rects . coreRect ) } / write ${ formatRectSize ( rects . writeRect ) } . ${ previewText } ${ busyText } ${ statusText } ` ;
2026-05-28 19:37:28 +09:00
patchStatusEl . classList . toggle ( "invalid" , false ) ;
}
2026-05-29 23:49:02 +09:00
function setToolMode ( mode ) {
state . toolMode = mode === "patch" ? "patch" : "pan" ;
toolPanButton ? . classList . toggle ( "active" , state . toolMode === "pan" ) ;
toolPatchButton ? . classList . toggle ( "active" , state . toolMode === "patch" ) ;
toolHintEl ? . classList . toggle ( "hidden" , state . toolMode !== "patch" ) ;
canvasShell ? . classList . toggle ( "patch-intent" , state . toolMode === "patch" ) ;
updatePatchControls ( ) ;
}
function setZoomKeepingCenter ( nextZoom ) {
const startedAt = performance . now ( ) ;
const renderWorld = displayWorld ( ) ;
if ( ! renderWorld ) return ;
const oldSize = viewportSizeForZoom ( state . zoom ) ;
const centerWorld = {
x : Math . round ( ( state . camera ? . x || 0 ) + oldSize . width / 2 ) ,
y : Math . round ( ( state . camera ? . y || 0 ) + oldSize . height / 2 ) ,
} ;
state . zoom = clampZoom ( nextZoom ) ;
const nextSize = syncViewportSize ( ) ;
state . camera = clampCameraForView ( {
x : Math . round ( centerWorld . x - nextSize . width / 2 ) ,
y : Math . round ( centerWorld . y - nextSize . height / 2 ) ,
} , nextSize , renderWorld ) ;
redraw ( { fastTerrain : false , allowWorldExpand : false } ) ;
recordInteractionLatency ( "zoom button" , startedAt , { zoom : state . zoom } ) ;
}
function recenterMap ( ) {
const renderWorld = displayWorld ( ) ;
if ( ! renderWorld ) return ;
state . camera = createInitialCamera ( renderWorld ) ;
redraw ( { fastTerrain : false , allowWorldExpand : false } ) ;
}
2026-05-28 19:37:28 +09:00
function clearDragMode ( ) {
dragState . mode = null ;
dragState . pointerId = null ;
dragState . pendingCamera = null ;
2026-05-29 18:50:54 +09:00
dragState . panRemainderX = 0 ;
dragState . panRemainderY = 0 ;
2026-05-28 19:37:28 +09:00
if ( dragState . panRaf != null ) {
cancelAnimationFrame ( dragState . panRaf ) ;
dragState . panRaf = null ;
}
2026-05-29 23:49:02 +09:00
dragState . panLatencyStartedAt = null ;
2026-05-28 19:37:28 +09:00
canvasShell ? . classList . remove ( "panning" , "selecting" ) ;
}
function schedulePanRedraw ( camera ) {
dragState . pendingCamera = camera ;
2026-05-29 23:49:02 +09:00
if ( ! dragState . panLatencyStartedAt ) dragState . panLatencyStartedAt = performance . now ( ) ;
2026-05-28 19:37:28 +09:00
if ( dragState . panRaf != null ) return ;
dragState . panRaf = requestAnimationFrame ( ( ) => {
dragState . panRaf = null ;
if ( ! dragState . pendingCamera ) return ;
const next = dragState . pendingCamera ;
dragState . pendingCamera = null ;
if ( next . x === state . camera . x && next . y === state . camera . y ) return ;
2026-05-29 23:49:02 +09:00
const startedAt = dragState . panLatencyStartedAt || performance . now ( ) ;
dragState . panLatencyStartedAt = null ;
2026-05-28 19:37:28 +09:00
state . camera = next ;
2026-05-29 18:50:54 +09:00
// Do not auto-expand the backing world while a pointer drag is active.
// Expansion shifts world coordinates; doing it mid-drag invalidates the
// pointer-to-camera baseline and can make the viewport appear to jump.
redraw ( { fastTerrain : true , allowWorldExpand : false } ) ;
2026-05-29 23:49:02 +09:00
recordInteractionLatency ( "pan" , startedAt , { zoom : state . zoom , fast : true } ) ;
2026-05-28 19:37:28 +09:00
} ) ;
}
2026-05-29 22:00:42 +09:00
function commitPendingPatch ( { redrawAfter = true } = { } ) {
if ( ! state . pendingPatch ? . world ) return false ;
state . world = state . pendingPatch . world ;
state . map = state . world . sourceMap || state . map ;
state . lastPatchResult = state . pendingPatch . result || state . world . lastPatchResult || state . lastPatchResult ;
state . pendingPatch = null ;
2026-08-08 17:41:30 +09:00
state . patchStatusMessage = "" ;
2026-05-29 22:00:42 +09:00
state . viewportMap = null ;
if ( redrawAfter ) {
renderStats ( displaySourceMap ( ) ) ;
redraw ( { fastTerrain : true , allowWorldExpand : false } ) ;
window . setTimeout ( ( ) => redraw ( { fastTerrain : false , allowWorldExpand : false } ) , 80 ) ;
}
return true ;
}
function discardPendingPatch ( { redrawAfter = true } = { } ) {
if ( ! state . pendingPatch ) return false ;
state . pendingPatch = null ;
2026-08-08 17:41:30 +09:00
state . patchStatusMessage = "" ;
2026-05-29 22:00:42 +09:00
state . viewportMap = null ;
if ( redrawAfter ) {
renderStats ( displaySourceMap ( ) ) ;
redraw ( { fastTerrain : false , allowWorldExpand : false } ) ;
}
return true ;
}
function hideSelectionOverlay ( options = { } ) {
const commitPreview = options . commitPreview === true ;
2026-08-08 17:41:30 +09:00
const discardPreview = options . discardPreview === true || ( ! commitPreview && options . keepPreview !== true && ! ! state . pendingPatch ) ;
2026-05-29 22:00:42 +09:00
if ( commitPreview ) commitPendingPatch ( { redrawAfter : false } ) ;
2026-08-08 17:41:30 +09:00
else if ( discardPreview ) discardPendingPatch ( { redrawAfter : false } ) ;
2026-05-28 19:37:28 +09:00
dragState . selectStart = null ;
dragState . selectEnd = null ;
2026-05-29 14:31:42 +09:00
dragState . selectPath = null ;
2026-05-28 19:37:28 +09:00
state . selectionRect = null ;
2026-05-28 23:51:55 +09:00
resetPatchVariant ( { update : false } ) ;
2026-05-29 14:31:42 +09:00
hideSelectionSvg ( ) ;
2026-05-28 19:37:28 +09:00
if ( selectionEl ) selectionEl . style . display = "none" ;
2026-05-29 22:00:42 +09:00
state . viewportMap = null ;
renderStats ( displaySourceMap ( ) ) ;
2026-05-28 19:37:28 +09:00
updatePatchControls ( ) ;
2026-08-08 17:41:30 +09:00
if ( commitPreview || discardPreview ) redraw ( { fastTerrain : false , allowWorldExpand : false } ) ;
2026-05-28 00:30:09 +09:00
}
2026-05-28 19:37:28 +09:00
function selectionPixelsToCells ( start , end ) {
2026-05-29 14:31:42 +09:00
const a = screenPointToWorldCell ( start ) ;
const b = screenPointToWorldCell ( end ) ;
if ( ! a || ! b ) return null ;
2026-05-28 19:37:28 +09:00
return {
2026-05-29 14:31:42 +09:00
x0 : Math . min ( a . x , b . x ) ,
y0 : Math . min ( a . y , b . y ) ,
x1 : Math . max ( a . x , b . x ) + 1 ,
y1 : Math . max ( a . y , b . y ) + 1 ,
2026-05-28 19:37:28 +09:00
} ;
2026-05-28 00:30:09 +09:00
}
2026-05-29 14:31:42 +09:00
function selectionPixelsToShape ( start , end , path = null ) {
if ( Array . isArray ( path ) && path . length >= 3 ) return selectionPathToShape ( path ) ;
return selectionPixelsToCells ( start , end ) ;
}
2026-05-28 19:37:28 +09:00
function handleMapPointerDown ( event ) {
if ( ! state . world || ! canvasShell ) return ;
if ( event . button !== 0 && event . button !== 2 ) return ;
dragState . pointerId = event . pointerId ;
dragState . startClientX = event . clientX ;
dragState . startClientY = event . clientY ;
dragState . startCameraX = state . camera . x ;
dragState . startCameraY = state . camera . y ;
2026-05-29 18:50:54 +09:00
dragState . lastClientX = event . clientX ;
dragState . lastClientY = event . clientY ;
dragState . panRemainderX = 0 ;
dragState . panRemainderY = 0 ;
2026-05-28 19:37:28 +09:00
tooltipEl ? . classList . remove ( "visible" ) ;
if ( event . button === 0 ) {
dragState . mode = "pan" ;
canvasShell . classList . add ( "panning" ) ;
} else {
2026-05-29 23:49:02 +09:00
if ( state . toolMode !== "patch" ) setToolMode ( "patch" ) ;
if ( state . pendingPatch ) discardPendingPatch ( { redrawAfter : false } ) ;
state . selectionRect = null ;
hideSelectionSvg ( ) ;
if ( selectionEl ) selectionEl . style . display = "none" ;
2026-05-28 19:37:28 +09:00
dragState . mode = "select" ;
dragState . selectStart = clampCanvasPoint ( event ) ;
dragState . selectEnd = dragState . selectStart ;
2026-05-29 14:31:42 +09:00
dragState . selectPath = [ dragState . selectStart ] ;
2026-05-28 19:37:28 +09:00
canvasShell . classList . add ( "selecting" ) ;
updateSelectionOverlay ( ) ;
}
canvas . setPointerCapture ? . ( event . pointerId ) ;
2026-05-28 00:30:09 +09:00
event . preventDefault ( ) ;
}
2026-05-28 19:37:28 +09:00
function handleMapPointerMove ( event ) {
if ( ! dragState . mode || dragState . pointerId !== event . pointerId || ! canvasShell ) return ;
tooltipEl ? . classList . remove ( "visible" ) ;
if ( dragState . mode === "pan" ) {
2026-05-29 18:50:54 +09:00
const rect = canvas . getBoundingClientRect ( ) ;
const maxDeltaX = Math . max ( 320 , rect . width * 0.72 ) ;
const maxDeltaY = Math . max ( 240 , rect . height * 0.72 ) ;
const rawDx = event . clientX - dragState . lastClientX ;
const rawDy = event . clientY - dragState . lastClientY ;
dragState . lastClientX = event . clientX ;
dragState . lastClientY = event . clientY ;
// Pointer capture can occasionally deliver a stale/outlier coordinate after
// a tab switch, resize, context-menu gesture, or OS-level event hiccup. A
// single implausibly large delta would otherwise become a large camera jump.
if ( Math . abs ( rawDx ) <= maxDeltaX && Math . abs ( rawDy ) <= maxDeltaY ) {
const cellSize = Math . max ( 1 , displayedCellSize ( ) ) ;
const totalX = dragState . panRemainderX + rawDx / cellSize ;
const totalY = dragState . panRemainderY + rawDy / cellSize ;
const dxCells = totalX < 0 ? Math . ceil ( totalX ) : Math . floor ( totalX ) ;
const dyCells = totalY < 0 ? Math . ceil ( totalY ) : Math . floor ( totalY ) ;
dragState . panRemainderX = totalX - dxCells ;
dragState . panRemainderY = totalY - dyCells ;
if ( dxCells || dyCells ) {
const base = dragState . pendingCamera || state . camera ;
const nextCamera = clampCameraForView ( {
x : base . x - dxCells ,
y : base . y - dyCells ,
} , viewportSizeForZoom ( state . zoom ) ) ;
schedulePanRedraw ( nextCamera ) ;
}
}
2026-05-28 19:37:28 +09:00
} else if ( dragState . mode === "select" ) {
dragState . selectEnd = clampCanvasPoint ( event ) ;
2026-05-29 14:31:42 +09:00
if ( ! dragState . selectPath || Math . hypot ( dragState . selectEnd . x - dragState . selectPath [ dragState . selectPath . length - 1 ] . x , dragState . selectEnd . y - dragState . selectPath [ dragState . selectPath . length - 1 ] . y ) >= 3 ) {
dragState . selectPath = [ ... ( dragState . selectPath || [ ] ) , dragState . selectEnd ] ;
}
2026-05-28 19:37:28 +09:00
updateSelectionOverlay ( ) ;
}
event . preventDefault ( ) ;
}
function handleMapPointerUp ( event ) {
if ( dragState . pointerId !== event . pointerId ) return ;
const wasPanning = dragState . mode === "pan" ;
if ( dragState . mode === "select" ) {
dragState . selectEnd = clampCanvasPoint ( event ) ;
2026-05-29 14:31:42 +09:00
if ( ! dragState . selectPath || dragState . selectPath . length < 2 ) dragState . selectPath = [ dragState . selectStart , dragState . selectEnd ] ;
else dragState . selectPath = [ ... dragState . selectPath , dragState . selectEnd ] ;
const shape = selectionPixelsToShape ( dragState . selectStart , dragState . selectEnd , dragState . selectPath ) ;
2026-05-28 19:37:28 +09:00
const width = Math . abs ( dragState . selectEnd . x - dragState . selectStart . x ) ;
const height = Math . abs ( dragState . selectEnd . y - dragState . selectStart . y ) ;
2026-05-29 14:31:42 +09:00
if ( shape && ( dragState . selectPath . length >= 3 || ( width >= 4 && height >= 4 ) ) ) {
state . selectionRect = shape ;
2026-05-28 23:51:55 +09:00
state . lastPatchResult = null ;
2026-08-08 17:41:30 +09:00
state . patchStatusMessage = "" ;
2026-05-28 23:51:55 +09:00
resetPatchVariant ( { update : false } ) ;
2026-05-28 19:37:28 +09:00
updateSelectionOverlayFromWorldRect ( ) ;
updatePatchControls ( ) ;
} else {
hideSelectionOverlay ( ) ;
}
}
canvas . releasePointerCapture ? . ( event . pointerId ) ;
if ( wasPanning && dragState . pendingCamera ) {
state . camera = dragState . pendingCamera ;
dragState . pendingCamera = null ;
}
clearDragMode ( ) ;
2026-05-29 23:49:02 +09:00
if ( wasPanning ) {
const startedAt = performance . now ( ) ;
redraw ( { fastTerrain : false } ) ;
recordInteractionLatency ( "pan settle" , startedAt , { zoom : state . zoom , fast : false } ) ;
}
2026-05-28 00:30:09 +09:00
event . preventDefault ( ) ;
}
2026-05-20 13:50:56 +09:00
function parseSeed ( seedText ) {
const numeric = Number . parseInt ( seedText , 10 ) ;
if ( Number . isFinite ( numeric ) ) return numeric >>> 0 ;
let hash = 2166136261 ;
for ( const ch of seedText ) hash = Math . imul ( hash ^ ch . charCodeAt ( 0 ) , 16777619 ) ;
return hash >>> 0 ;
}
2026-05-24 19:33:09 +09:00
function formatMs ( ms ) {
if ( ! Number . isFinite ( ms ) ) return "-" ;
return ms >= 1000 ? ` ${ ( ms / 1000 ) . toFixed ( 2 ) } s ` : ` ${ Math . round ( ms ) } ms ` ;
}
2026-05-29 23:49:02 +09:00
function formatSeconds ( value , digits = 4 ) {
if ( ! Number . isFinite ( value ) ) return "-" ;
return ` ${ value . toFixed ( digits ) } s ` ;
}
function pushCapped ( history , item , limit = 10 ) {
history . unshift ( item ) ;
if ( history . length > limit ) history . length = limit ;
}
function countTruthyCells ( mask ) {
if ( ! mask || typeof mask . length !== "number" ) return 0 ;
let count = 0 ;
for ( let i = 0 ; i < mask . length ; i ++ ) if ( mask [ i ] ) count ++ ;
return count ;
}
function mapAreaCells ( map ) {
if ( ! map ) return MAP _W * MAP _H ;
const focusedArea = countTruthyCells ( map . focusedPrefectureMask || map . prefectureMask || map . humanRegionMask ) ;
if ( focusedArea > 0 ) return focusedArea ;
return Math . max ( 1 , ( map . width || MAP _W ) * ( map . height || MAP _H ) ) ;
}
function formatAreaCells ( cells ) {
return ` ${ Math . max ( 0 , Math . round ( cells || 0 ) ) . toLocaleString ( ) } cells ` ;
}
function rectAreaCells ( rect ) {
if ( ! rect ) return 0 ;
return Math . max ( 0 , Math . round ( ( rect . x1 - rect . x0 ) * ( rect . y1 - rect . y0 ) ) ) ;
}
function terrainTypeLabel ( value ) {
const option = generationTypeInput ? Array . from ( generationTypeInput . options ) . find ( ( item ) => item . value === value ) : null ;
return option ? . textContent || value || "Auto" ;
}
function secondsPerThousandCells ( totalMs , areaCells ) {
const area = Math . max ( 1 , areaCells || 0 ) ;
return ( totalMs || 0 ) / area ;
}
function numericValues ( items , selector ) {
return ( items || [ ] )
. map ( selector )
. map ( Number )
. filter ( ( value ) => Number . isFinite ( value ) && value >= 0 ) ;
}
function percentile ( values , percentileRank ) {
const sorted = [ ... values ] . sort ( ( a , b ) => a - b ) ;
if ( ! sorted . length ) return NaN ;
if ( sorted . length === 1 ) return sorted [ 0 ] ;
const rank = Math . min ( Math . max ( percentileRank , 0 ) , 100 ) / 100 * ( sorted . length - 1 ) ;
const lower = Math . floor ( rank ) ;
const upper = Math . ceil ( rank ) ;
if ( lower === upper ) return sorted [ lower ] ;
const weight = rank - lower ;
return sorted [ lower ] * ( 1 - weight ) + sorted [ upper ] * weight ;
}
function summarizeValues ( values ) {
if ( ! values . length ) return null ;
const sum = values . reduce ( ( acc , value ) => acc + value , 0 ) ;
return {
count : values . length ,
avg : sum / values . length ,
max : Math . max ( ... values ) ,
p95 : percentile ( values , 95 ) ,
} ;
}
function renderMetricGrid ( container , metrics , emptyText = "No samples yet." ) {
if ( ! container ) return ;
container . innerHTML = "" ;
const visibleMetrics = ( metrics || [ ] ) . filter ( Boolean ) ;
if ( ! visibleMetrics . length ) {
renderEmptyState ( container , emptyText ) ;
return ;
}
for ( const metric of visibleMetrics ) {
const card = document . createElement ( "div" ) ;
card . className = "metric-card" ;
card . innerHTML = `
< span > $ { metric . label } < / s p a n >
< strong > $ { metric . value } < / s t r o n g >
$ { metric . sub ? ` <small> ${ metric . sub } </small> ` : "" }
` ;
container . append ( card ) ;
}
}
function formatPercent ( value , digits = 1 ) {
if ( ! Number . isFinite ( value ) ) return "-" ;
return ` ${ value . toFixed ( digits ) } % ` ;
}
function countArray ( value ) {
return Array . isArray ( value ) ? value . length : 0 ;
}
function countPaths ( paths ) {
return ( paths || [ ] ) . reduce ( ( sum , path ) => sum + ( Array . isArray ( path ) ? path . length : 0 ) , 0 ) ;
}
function renderDiagnosticGrid ( container , metrics , emptyText = "No diagnostics yet." ) {
renderMetricGrid ( container , metrics , emptyText ) ;
}
function renderDiagnosticTable ( container , rows , emptyText = "No diagnostics yet." ) {
if ( ! container ) return ;
container . innerHTML = "" ;
const visibleRows = ( rows || [ ] ) . filter ( Boolean ) ;
if ( ! visibleRows . length ) {
renderEmptyState ( container , emptyText ) ;
return ;
}
for ( const row of visibleRows ) {
const item = document . createElement ( "div" ) ;
item . className = "diagnostic-row" ;
item . innerHTML = `
< span > $ { row . label } < / s p a n >
< strong > $ { row . value } < / s t r o n g >
$ { row . sub ? ` <small> ${ row . sub } </small> ` : "" }
` ;
container . append ( item ) ;
}
}
function collectFeatureCounts ( map = activeMap ( ) ) {
if ( ! map ) return null ;
const roads = [
... ( map . nationalRoads || [ ] ) ,
... ( map . ringRoads || [ ] ) ,
... ( map . externalRoads || [ ] ) ,
... ( map . expressways || [ ] ) ,
... ( map . externalExpressways || [ ] ) ,
... ( map . minorRoads || [ ] ) ,
... ( map . icAccessRoads || [ ] ) ,
] ;
const railways = [
... ( map . railways || [ ] ) ,
... ( map . branchRailways || [ ] ) ,
... ( map . ringRailways || [ ] ) ,
... ( map . externalRailways || [ ] ) ,
] ;
const rivers = [
... ( map . mainRivers || [ ] ) ,
... ( map . tributaryRivers || [ ] ) ,
... ( map . smallStreams || [ ] ) ,
] ;
const settlements = [
... ( map . modernCities || [ ] ) ,
... ( map . satelliteCities || [ ] ) ,
... ( map . villages || [ ] ) ,
... ( map . markets || [ ] ) ,
... ( map . castles || [ ] ) ,
... ( map . ports || [ ] ) ,
... ( map . newTowns || [ ] ) ,
] ;
const adminBorders = [
... ( map . adminBorders || [ ] ) ,
... ( map . prefectureBorder || [ ] ) ,
... ( map . regionalPrefectureBorders || [ ] ) ,
] ;
return {
roads : roads . length ,
roadCells : countPaths ( roads ) ,
railways : railways . length ,
railwayCells : countPaths ( railways ) ,
rivers : rivers . length ,
riverCells : countPaths ( rivers ) ,
settlements : settlements . length ,
stations : countArray ( map . stations ) ,
labels : countArray ( state . hoverEntities ) ,
adminCenters : countArray ( map . adminCenters ) ,
adminBorders : adminBorders . length ,
adminBorderCells : countPaths ( adminBorders ) ,
industrialZones : countArray ( map . industrialZones ) ,
logisticsParks : countArray ( map . logisticsParks ) ,
} ;
}
function recordDiagnosticLog ( level , title , message = "" , meta = { } ) {
pushCapped ( state . diagnosticLog , {
id : Date . now ( ) + Math . random ( ) ,
createdAt : new Date ( ) ,
level : level || "info" ,
title : title || "Diagnostic" ,
message : String ( message || "" ) ,
meta ,
} ) ;
renderAdvancedData ( ) ;
}
function updateRenderDiagnostics ( options = { } , timings = { } ) {
const map = activeMap ( ) ;
const canvasRect = canvas ? . getBoundingClientRect ? . ( ) ;
const viewWidth = Math . max ( 1 , state . viewWidth || map ? . width || MAP _W ) ;
const viewHeight = Math . max ( 1 , state . viewHeight || map ? . height || MAP _H ) ;
state . diagnostics . lastViewport = {
viewWidth ,
viewHeight ,
cells : viewWidth * viewHeight ,
mapWidth : map ? . width || viewWidth ,
mapHeight : map ? . height || viewHeight ,
cssWidth : canvasRect ? . width || 0 ,
cssHeight : canvasRect ? . height || 0 ,
bitmapWidth : canvas ? . width || 0 ,
bitmapHeight : canvas ? . height || 0 ,
zoom : state . zoom || 1 ,
mode : state . mode ,
fast : ! ! options . fastTerrain ,
showFeatures : state . showFeatures && ! options . fastTerrain ,
showLabels : state . showLabels && ! options . fastTerrain ,
2026-08-08 17:41:30 +09:00
showSeamDiagnostics : state . showSeamDiagnostics && ! options . fastTerrain ,
2026-05-29 23:49:02 +09:00
viewportMs : timings . viewportMs || 0 ,
hoverMs : timings . hoverMs || 0 ,
drawMs : timings . drawMs || 0 ,
totalRenderMs : timings . totalRenderMs || 0 ,
2026-05-30 03:25:19 +09:00
renderBreakdown : timings . renderBreakdown || null ,
2026-05-29 23:49:02 +09:00
} ;
state . diagnostics . lastFeatureCounts = collectFeatureCounts ( map ) ;
}
function selectionWriteDiagnostics ( ) {
const rect = state . selectionRect ;
const validation = validatePatchRect ( rect , state . world ) ;
const currentSelection = validation . rect || rect ;
2026-08-08 17:41:30 +09:00
const rects = validation . ok ? buildPatchRects ( validation . rect , state . world , { patchMode : patchModeInput ? . value || "auto" } ) : null ;
2026-05-29 23:49:02 +09:00
const selectedArea = currentSelection ? . areaCells || rectAreaCells ( currentSelection ) ;
const writeArea = rects ? . writeRect ? rectAreaCells ( rects . writeRect ) : 0 ;
const last = state . patchRuns [ 0 ] ;
const lastRatio = last ? . areaCells ? ( last . writeAreaCells || 0 ) / Math . max ( 1 , last . areaCells ) * 100 : NaN ;
return [
{ label : "Current selection" , value : selectedArea ? formatAreaCells ( selectedArea ) : "none" , sub : validation . ok ? "valid" : ( rect ? validation . reason : "no active selection" ) } ,
2026-08-08 17:41:30 +09:00
{ label : "Resolved patch mode" , value : rects ? . patchMode || "-" , sub : rects ? ` ${ rects . patchModeAutoDetected ? "auto" : "explicit" } · ${ formatPercent ( ( rects . coverageStats ? . ungeneratedRatio || 0 ) * 100 ) } ungenerated ` : "requires valid selection" } ,
2026-05-29 23:49:02 +09:00
{ label : "Current write area" , value : writeArea ? formatAreaCells ( writeArea ) : "-" , sub : selectedArea ? ` ${ formatPercent ( writeArea / Math . max ( 1 , selectedArea ) * 100 ) } of selection ` : "requires valid selection" } ,
{ label : "Last patch selection" , value : last ? formatAreaCells ( last . areaCells ) : "-" , sub : last ? ` ${ last . kind || "Patch" } · ${ terrainTypeLabel ( last . terrainType ) } ` : "no patch runs" } ,
{ label : "Last patch write" , value : last ? . writeAreaCells ? formatAreaCells ( last . writeAreaCells ) : "-" , sub : Number . isFinite ( lastRatio ) ? ` ${ formatPercent ( lastRatio ) } of selection ` : "no patch runs" } ,
{ label : "Last patch variant" , value : last ? String ( last . variant ? ? "-" ) : "-" , sub : last ? . label || "no candidate" } ,
] ;
}
function renderViewportDiagnostics ( ) {
const viewport = state . diagnostics . lastViewport ;
2026-05-30 03:25:19 +09:00
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" } ,
] : [ ] ;
2026-05-29 23:49:02 +09:00
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" } ,
{ label : "Canvas bitmap" , value : ` ${ viewport . bitmapWidth } × ${ viewport . bitmapHeight } ` , sub : "render target" } ,
{ label : "Zoom" , value : ` ${ Number ( viewport . zoom || 1 ) . toFixed ( 2 ) } x ` , sub : viewport . fast ? "fast redraw" : "full redraw" } ,
{ label : "Viewport build" , value : formatMs ( viewport . viewportMs ) , sub : "getViewportMap" } ,
{ 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 ` } ,
2026-05-30 03:25:19 +09:00
... renderRows ,
2026-05-29 23:49:02 +09:00
] : [ ] , "No viewport render recorded yet." ) ;
const counts = state . diagnostics . lastFeatureCounts ;
renderDiagnosticTable ( advancedFeatureCountsEl , counts ? [
{ label : "Road paths" , value : counts . roads . toLocaleString ( ) , sub : ` ${ counts . roadCells . toLocaleString ( ) } path cells ` } ,
{ label : "Rail paths" , value : counts . railways . toLocaleString ( ) , sub : ` ${ counts . railwayCells . toLocaleString ( ) } path cells ` } ,
{ label : "River paths" , value : counts . rivers . toLocaleString ( ) , sub : ` ${ counts . riverCells . toLocaleString ( ) } path cells ` } ,
{ label : "Settlements" , value : counts . settlements . toLocaleString ( ) , sub : "cities, towns, ports, castles" } ,
{ label : "Stations" , value : counts . stations . toLocaleString ( ) , sub : "rail markers" } ,
{ label : "Hover labels" , value : counts . labels . toLocaleString ( ) , sub : "active hit targets" } ,
{ label : "Admin centers" , value : counts . adminCenters . toLocaleString ( ) , sub : "municipality labels" } ,
{ label : "Admin borders" , value : counts . adminBorders . toLocaleString ( ) , sub : ` ${ counts . adminBorderCells . toLocaleString ( ) } path cells ` } ,
{ label : "Industry / logistics" , value : ( counts . industrialZones + counts . logisticsParks ) . toLocaleString ( ) , sub : ` ${ counts . industrialZones } industrial, ${ counts . logisticsParks } logistics ` } ,
] : [ ] , "No feature counts recorded yet." ) ;
}
function renderPatchDiagnostics ( ) {
renderDiagnosticTable ( advancedPatchDiagnosticsEl , selectionWriteDiagnostics ( ) , "No patch diagnostics yet." ) ;
}
2026-08-08 17:41:30 +09:00
function currentSeamDiagnostics ( ) {
return state . pendingPatch ? . result ? . seamDiagnostics
|| state . lastPatchResult ? . seamDiagnostics
|| displaySourceMap ( ) ? . patchSeamDiagnostics
|| null ;
}
function seamDiagnosticRows ( ) {
const d = currentSeamDiagnostics ( ) ;
if ( ! d ) return [ ] ;
const roadTotal = Number ( d . roadPortalsBefore || 0 ) ;
const railTotal = Number ( d . railPortalsBefore || 0 ) ;
const duplicateParts = [
` ${ Number ( d . duplicateAdminBoundaryPairs || 0 ) } municipal ` ,
` ${ Number ( d . duplicatePrefectureBoundaryPairs || 0 ) } prefecture ` ,
` ${ Number ( d . overlappingAdminPrefecturePairs || 0 ) } overlap ` ,
] . join ( " / " ) ;
const modeSub = "production-sized full pipeline" ;
return [
{ label : "Status" , value : String ( d . status || "-" ) . toUpperCase ( ) , sub : ` ${ Number ( d . criticalCount || 0 ) } critical / ${ Number ( d . warningCount || 0 ) } warning signals ` } ,
{ label : "Patch mode" , value : d . patchMode || "-" , sub : ` ${ d . patchModeAutoDetected ? "auto-detected" : "explicit" } · ${ formatPercent ( Number ( d . ungeneratedSelectionRatio || 0 ) * 100 ) } ungenerated · overlap ${ Number ( d . expansionOverlap || 0 ) } cells ` } ,
{ label : "World sea level" , value : Number ( d . worldSeaLevel || 0 ) . toFixed ( 3 ) , sub : "shared by initial and additional generation" } ,
{ label : "Candidate mode" , value : d . patchGenerationMode || "-" , sub : modeSub } ,
{ label : "Candidate window" , value : ` ${ Number ( d . candidateWidth || 0 ) } × ${ Number ( d . candidateHeight || 0 ) } ` , sub : ` ${ formatPercent ( Number ( d . candidateAreaRatio || 0 ) * 100 ) } of full candidate area ` } ,
... ( d . qualityPolicyVersion ? [
{ label : "Expansion quality gate" , value : d . qualityHardPass ? "PASS" : "BEST AVAILABLE" , sub : ` ${ d . qualityPolicyVersion } · score ${ Number ( d . qualityScore || 0 ) . toFixed ( 3 ) } · selected variant ${ Number ( d . qualitySelectedVariant || 0 ) } ` } ,
{ label : "Candidate land quality" , value : formatPercent ( Number ( d . qualityLandRatio || 0 ) * 100 ) , sub : ` ${ d . qualityTerrainType || "-" } · ${ formatPercent ( Number ( d . qualityDevelopableRatio || 0 ) * 100 ) } developable · ${ formatPercent ( Number ( d . qualityLargestComponentRatio || 0 ) * 100 ) } largest component ` } ,
{ label : "Candidate place density" , value : Number ( d . qualityLabelCount || 0 ) . toLocaleString ( ) , sub : ` ${ Number ( d . qualitySettlementCount || 0 ) } settlements · ${ Number ( d . qualityLabelDensityPer1000 || 0 ) . toFixed ( 2 ) } labels / 1000 land cells ` } ,
{ label : "Merged patch quality" , value : d . qualityFinalHardPass ? "PASS" : "WARNING" , sub : ` ${ formatPercent ( Number ( d . qualityFinalOwnedLandRatio || 0 ) * 100 ) } land in owned interior · ${ Number ( d . qualityFinalLabelCount || 0 ) } labels / ${ Number ( d . qualityFinalSettlementCount || 0 ) } settlements ` } ,
] : [ ] ) ,
{ label : "Seam band" , value : Number ( d . seamBandCells || 0 ) . toLocaleString ( ) , sub : "cells inspected" } ,
{ label : "Coast flips" , value : Number ( d . seaFlipCells || 0 ) . toLocaleString ( ) , sub : ` ${ Number ( d . landToSeaCells || 0 ) } land→sea / ${ Number ( d . seaToLandCells || 0 ) } sea→land ` } ,
{ label : "Transport/coast conflicts" , value : Number ( d . transportLandToSeaConflicts || 0 ) . toLocaleString ( ) , sub : "existing road or rail cell changed to sea" } ,
{ label : "Road seam portals" , value : ` ${ Number ( d . roadPortalsConnected || 0 ) } / ${ roadTotal } ` , sub : ` ${ Number ( d . roadPortalsBroken || 0 ) } disconnected ` } ,
{ label : "Rail seam portals" , value : ` ${ Number ( d . railPortalsConnected || 0 ) } / ${ railTotal } ` , sub : ` ${ Number ( d . railPortalsBroken || 0 ) } disconnected ` } ,
{ label : "New seam boundaries" , value : ` ${ Number ( d . adminSeamBreakEdges || 0 ) } / ${ Number ( d . prefectureSeamBreakEdges || 0 ) } ` , sub : "municipal / prefecture edges created where the old ID was continuous" } ,
{ label : "Near-duplicate boundaries" , value : Number ( d . duplicateBoundaryPairs || 0 ) . toLocaleString ( ) , sub : duplicateParts } ,
{ label : "Established frontier elevation" , value : Number ( d . maxEstablishedFrontierElevationJump || 0 ) . toFixed ( 3 ) , sub : ` ${ Number ( d . establishedFrontierElevationEdges || 0 ) . toLocaleString ( ) } land edges inspected · hard limit ${ Number ( d . gateBudgets ? . maxEstablishedFrontierElevationJump || 0.075 ) . toFixed ( 3 ) } ` } ,
{ label : "Elevation cliffs" , value : Number ( d . elevationCliffEdges || 0 ) . toLocaleString ( ) , sub : ` max ${ Number ( d . maxElevationJump || 0 ) . toFixed ( 3 ) } / mean ${ Number ( d . meanElevationJump || 0 ) . toFixed ( 3 ) } ` } ,
{ label : "Map markers" , value : Number ( d . markerCount || 0 ) . toLocaleString ( ) , sub : state . showSeamDiagnostics ? "overlay visible" : "overlay hidden" } ,
] ;
}
function renderSeamDiagnostics ( ) {
renderDiagnosticTable ( advancedSeamDiagnosticsEl , seamDiagnosticRows ( ) , "Generate a patch preview to collect seam diagnostics." ) ;
}
2026-05-29 23:49:02 +09:00
function renderWorkerWorldDiagnostics ( ) {
const workerAvailable = typeof Worker !== "undefined" ;
const workerRows = [
{ label : "Worker API" , value : workerAvailable ? "available" : "unavailable" , sub : "browser capability" } ,
2026-08-08 17:41:30 +09:00
{ label : "Full-generation worker" , value : generationWorker ? "active" : "not active" , sub : state . diagnostics . lastGenerationWorkerUsed == null ? "created on demand" : ( state . diagnostics . lastGenerationWorkerUsed ? "last full generation used worker" : "last full generation used main thread" ) } ,
{ label : "Generation fallback" , value : state . diagnostics . lastGenerationWorkerFallbackReason || "none" , sub : "full-map generation" } ,
2026-05-29 23:49:02 +09:00
{ label : "Patch worker object" , value : patchWorker ? "active" : "not active" , sub : patchWorker ? "created" : "created on demand" } ,
{ label : "Last patch worker" , value : state . diagnostics . lastWorkerUsed == null ? "-" : ( state . diagnostics . lastWorkerUsed ? "used" : "main thread" ) , sub : state . diagnostics . lastPatchWorkerKind || "no patch run" } ,
2026-08-08 17:41:30 +09:00
{ label : "Patch fallback" , value : state . diagnostics . lastWorkerFallbackReason || "none" , sub : "last patch worker fallback" } ,
2026-05-29 23:49:02 +09:00
] ;
renderDiagnosticTable ( advancedWorkerDiagnosticsEl , workerRows ) ;
const world = displayWorld ( ) ;
const source = displaySourceMap ( ) ;
const expansion = state . diagnostics . lastWorldExpansion ;
renderDiagnosticTable ( advancedWorldDiagnosticsEl , [
{ label : "World size" , value : world ? ` ${ world . width } × ${ world . height } ` : "-" , sub : world ? formatAreaCells ( world . width * world . height ) : "no world" } ,
{ label : "Source map" , value : source ? ` ${ source . width || MAP _W } × ${ source . height || MAP _H } ` : "-" , sub : source ? formatAreaCells ( ( source . width || MAP _W ) * ( source . height || MAP _H ) ) : "no source" } ,
{ label : "Camera" , value : state . camera ? ` ${ Math . round ( state . camera . x || 0 ) } , ${ Math . round ( state . camera . y || 0 ) } ` : "-" , sub : "world-space origin" } ,
{ label : "World expansions" , value : state . diagnostics . worldExpansionCount . toLocaleString ( ) , sub : expansion ? ` last dx ${ expansion . dx } , dy ${ expansion . dy } ` : "none yet" } ,
{ label : "Pending patch" , value : state . pendingPatch ? "yes" : "no" , sub : state . pendingPatch ? ` ${ terrainTypeLabel ( state . pendingPatch . terrainType ) } · variant ${ state . pendingPatch . variant } ` : "committed world" } ,
] ) ;
}
function renderWarningHistory ( ) {
if ( ! advancedWarningHistoryEl ) return ;
advancedWarningHistoryEl . innerHTML = "" ;
if ( ! state . diagnosticLog . length ) {
renderEmptyState ( advancedWarningHistoryEl , "No warnings or errors recorded yet." ) ;
return ;
}
for ( const entry of state . diagnosticLog ) {
const row = document . createElement ( "div" ) ;
row . className = ` diagnostic-log-row ${ entry . level || "info" } ` ;
const time = entry . createdAt instanceof Date ? entry . createdAt . toLocaleTimeString ( ) : "-" ;
row . innerHTML = `
< span > $ { time } < / s p a n >
< strong > $ { entry . title } < / s t r o n g >
< p > $ { entry . message || "-" } < / p >
` ;
advancedWarningHistoryEl . append ( row ) ;
}
}
function performanceMetricsForRuns ( runs ) {
const totals = summarizeValues ( numericValues ( runs , ( run ) => run . totalMs ) ) ;
const perArea = summarizeValues ( numericValues ( runs , ( run ) => run . secondsPerThousand ) ) ;
if ( ! totals ) return [ ] ;
2026-05-30 03:25:19 +09:00
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" ;
2026-05-29 23:49:02 +09:00
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" } ,
2026-05-30 03:25:19 +09:00
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 ,
2026-05-29 23:49:02 +09:00
] ;
}
function recordGenerationRun ( map , terrainType ) {
if ( ! map ) return ;
const area = mapAreaCells ( map ) ;
const totalMs = Number . isFinite ( map . generationTotalMs )
? map . generationTotalMs
: ( map . generationTimings || [ ] ) . reduce ( ( sum , row ) => sum + ( Number ( row ? . ms ) || 0 ) , 0 ) ;
2026-05-30 03:25:19 +09:00
const featureTimings = ( map . transportDebug ? . featureTimings || [ ] ) . map ( ( row ) => ( {
label : ` Settlements / ${ row . key || "substage" } ` ,
ms : Number ( row . ms ) || 0 ,
} ) ) ;
2026-05-29 23:49:02 +09:00
pushCapped ( state . generationRuns , {
id : Date . now ( ) ,
createdAt : new Date ( ) ,
terrainType : terrainType || "auto" ,
areaCells : area ,
totalMs ,
secondsPerThousand : secondsPerThousandCells ( totalMs , area ) ,
timings : ( map . generationTimings || [ ] ) . map ( ( row ) => ( {
label : row . label || row . key || "Stage" ,
ms : Number ( row . ms ) || 0 ,
2026-05-30 03:25:19 +09:00
} ) ) . concat ( featureTimings ) ,
2026-05-29 23:49:02 +09:00
} ) ;
renderAdvancedData ( ) ;
}
function recordPatchRun ( result , terrainType , selectionRect , variant , meta = { } ) {
if ( ! result ) return ;
const timings = ( result . patchTimings || [ ] ) . map ( ( row ) => ( {
label : row . label || row . key || "Stage" ,
ms : Number ( row . ms ) || 0 ,
} ) ) ;
const totalMs = Number . isFinite ( meta . wallMs )
? meta . wallMs
: timings . reduce ( ( sum , row ) => sum + ( Number ( row . ms ) || 0 ) , 0 ) ;
const selectionArea = result . selectionShape ? . areaCells || selectionRect ? . areaCells || rectAreaCells ( selectionRect ) ;
const writeArea = rectAreaCells ( result . writeRect || result . rects ? . writeRect || selectionRect ) ;
const area = Math . max ( 1 , Math . round ( selectionArea || writeArea || 1 ) ) ;
const writeRatio = writeArea / Math . max ( 1 , area ) ;
const usedWorker = meta . worker === true ;
pushCapped ( state . patchRuns , {
id : Date . now ( ) ,
createdAt : new Date ( ) ,
kind : meta . kind || "Patch preview" ,
terrainType : terrainType || result . terrainType || "auto" ,
variant : Number . isFinite ( variant ) ? variant : result . variant ,
worker : usedWorker ,
label : result . label || "candidate" ,
2026-08-08 17:41:30 +09:00
patchGenerationMode : result . patchGenerationMode || "-" ,
patchMode : result . patchMode || "regeneration" ,
patchModeAutoDetected : ! ! result . patchModeAutoDetected ,
ungeneratedSelectionRatio : result . coverageStats ? . ungeneratedRatio || 0 ,
seamStatus : result . seamDiagnostics ? . status || "-" ,
2026-05-29 23:49:02 +09:00
areaCells : area ,
writeAreaCells : writeArea ,
writeRatio ,
totalMs ,
secondsPerThousand : secondsPerThousandCells ( totalMs , area ) ,
timings ,
} ) ;
state . diagnostics . lastWorkerUsed = usedWorker ;
state . diagnostics . lastPatchWorkerKind = meta . kind || "Patch preview" ;
if ( usedWorker ) state . diagnostics . lastWorkerFallbackReason = null ;
renderAdvancedData ( ) ;
}
function interactionAction ( kind = "" ) {
const label = String ( kind ) . toLowerCase ( ) ;
if ( label . includes ( "pan" ) ) return "pan" ;
if ( label . includes ( "zoom" ) ) return "zoom" ;
return label || "other" ;
}
function interactionPhase ( run ) {
return run . fast ? "fast" : "full" ;
}
function interactionGroupLabel ( action , phase ) {
const actionLabel = action === "pan" ? "Pan" : action === "zoom" ? "Zoom" : action ;
return ` ${ actionLabel } / ${ phase === "fast" ? "fast redraw" : "full redraw" } ` ;
}
function recordInteractionLatency ( kind , startedAt , meta = { } ) {
if ( ! Number . isFinite ( startedAt ) ) return ;
const ms = performance . now ( ) - startedAt ;
if ( ! Number . isFinite ( ms ) || ms < 0 ) return ;
const item = {
id : Date . now ( ) ,
createdAt : new Date ( ) ,
kind ,
ms ,
zoom : Number . isFinite ( meta . zoom ) ? meta . zoom : state . zoom ,
fast : meta . fast === true ,
} ;
item . action = interactionAction ( kind ) ;
item . phase = interactionPhase ( item ) ;
pushCapped ( state . interactionRuns , item ) ;
renderAdvancedData ( ) ;
}
function renderEmptyState ( container , text ) {
if ( ! container ) return ;
container . innerHTML = "" ;
const empty = document . createElement ( "div" ) ;
empty . className = "perf-empty" ;
empty . textContent = text ;
container . append ( empty ) ;
}
function appendRunHistory ( container , runs , options = { } ) {
if ( ! container ) return ;
container . innerHTML = "" ;
if ( ! runs . length ) {
renderEmptyState ( container , options . emptyText || "No runs recorded yet." ) ;
return ;
}
runs . forEach ( ( run , index ) => {
const item = document . createElement ( "details" ) ;
item . className = "perf-run" ;
if ( index === 0 ) item . open = true ;
const summary = document . createElement ( "summary" ) ;
summary . className = options . patch ? "perf-summary patch" : "perf-summary" ;
const name = options . patch
? ` ${ run . kind || "Patch" } · ${ terrainTypeLabel ( run . terrainType ) } `
: terrainTypeLabel ( run . terrainType ) ;
summary . innerHTML = `
< span class = "perf-rank" > # $ { index + 1 } < / s p a n >
< span > $ { name } < / s p a n >
< strong > $ { formatMs ( run . totalMs ) } < / s t r o n g >
< span > $ { formatAreaCells ( run . areaCells ) } $ { options . patch && run . writeAreaCells ? ` / write ${ formatAreaCells ( run . writeAreaCells ) } ` : "" } < / s p a n >
2026-05-30 03:25:19 +09:00
< span > $ { formatSeconds ( run . secondsPerThousand ) } / 1 k cells$ { run . areaCells && run . areaCells < 1000 ? " (fixed overhead)" : "" } < / s p a n >
2026-05-29 23:49:02 +09:00
` ;
const body = document . createElement ( "div" ) ;
body . className = "timing-grid" ;
if ( options . patch ) {
const meta = document . createElement ( "div" ) ;
meta . className = "timing-pill meta" ;
meta . innerHTML = ` <span>Variant</span><strong> ${ run . variant ? ? "-" } </strong> ` ;
const worker = document . createElement ( "div" ) ;
worker . className = "timing-pill meta" ;
worker . innerHTML = ` <span>Worker</span><strong> ${ run . worker ? "yes" : "no" } </strong> ` ;
const ratio = document . createElement ( "div" ) ;
ratio . className = "timing-pill meta" ;
ratio . innerHTML = ` <span>Write / selection</span><strong> ${ formatPercent ( ( run . writeRatio || 0 ) * 100 ) } </strong> ` ;
2026-08-08 17:41:30 +09:00
const candidateMode = document . createElement ( "div" ) ;
candidateMode . className = "timing-pill meta" ;
candidateMode . innerHTML = ` <span>Candidate</span><strong> ${ run . patchGenerationMode || "-" } </strong> ` ;
const seam = document . createElement ( "div" ) ;
seam . className = "timing-pill meta" ;
seam . innerHTML = ` <span>Seam</span><strong> ${ String ( run . seamStatus || "-" ) . toUpperCase ( ) } </strong> ` ;
body . append ( meta , worker , ratio , candidateMode , seam ) ;
2026-05-29 23:49:02 +09:00
}
for ( const row of run . timings ) {
const timing = document . createElement ( "div" ) ;
timing . className = "timing-pill" ;
timing . innerHTML = ` <span> ${ row . label } </span><strong> ${ formatMs ( row . ms ) } </strong> ` ;
body . append ( timing ) ;
}
if ( ! run . timings . length ) {
const empty = document . createElement ( "div" ) ;
empty . className = "perf-empty inline" ;
empty . textContent = "No stage breakdown available." ;
body . append ( empty ) ;
}
item . append ( summary , body ) ;
container . append ( item ) ;
} ) ;
}
function renderGenerationHistory ( ) {
renderMetricGrid ( advancedGenerationStatsEl , performanceMetricsForRuns ( state . generationRuns ) , "No generation runs recorded yet." ) ;
appendRunHistory ( advancedGenerationHistoryEl , state . generationRuns , {
emptyText : "No generation runs recorded yet." ,
} ) ;
}
function renderPatchHistory ( ) {
renderMetricGrid ( advancedPatchStatsEl , performanceMetricsForRuns ( state . patchRuns ) , "No patch generation runs recorded yet." ) ;
appendRunHistory ( advancedPatchHistoryEl , state . patchRuns , {
patch : true ,
emptyText : "No patch generation runs recorded yet." ,
} ) ;
}
function renderInteractionSummary ( ) {
if ( ! advancedInteractionStatsEl ) return ;
advancedInteractionStatsEl . innerHTML = "" ;
if ( ! state . interactionRuns . length ) {
renderEmptyState ( advancedInteractionStatsEl , "No pan or zoom operations recorded yet." ) ;
return ;
}
const groups = new Map ( ) ;
for ( const run of state . interactionRuns ) {
const action = run . action || interactionAction ( run . kind ) ;
const phase = run . phase || interactionPhase ( run ) ;
const key = ` ${ action } : ${ phase } ` ;
if ( ! groups . has ( key ) ) groups . set ( key , { action , phase , values : [ ] } ) ;
groups . get ( key ) . values . push ( run . ms ) ;
}
const table = document . createElement ( "div" ) ;
table . className = "aggregate-table" ;
for ( const group of groups . values ( ) ) {
const stats = summarizeValues ( group . values ) ;
const row = document . createElement ( "div" ) ;
row . className = "aggregate-row" ;
row . innerHTML = `
< span > $ { interactionGroupLabel ( group . action , group . phase ) } < / s p a n >
< small > n = $ { stats . count } < / s m a l l >
< strong > $ { formatMs ( stats . avg ) } < / s t r o n g >
< strong > $ { formatMs ( stats . max ) } < / s t r o n g >
< strong > $ { formatMs ( stats . p95 ) } < / s t r o n g >
` ;
table . append ( row ) ;
}
advancedInteractionStatsEl . append ( table ) ;
}
function renderInteractionHistory ( ) {
if ( ! advancedInteractionHistoryEl ) return ;
advancedInteractionHistoryEl . innerHTML = "" ;
if ( ! state . interactionRuns . length ) {
renderEmptyState ( advancedInteractionHistoryEl , "No pan or zoom operations recorded yet." ) ;
return ;
}
const table = document . createElement ( "div" ) ;
table . className = "interaction-table" ;
for ( const run of state . interactionRuns ) {
const row = document . createElement ( "div" ) ;
row . className = "interaction-row" ;
row . innerHTML = `
< span > $ { run . kind } < / s p a n >
< strong > $ { formatMs ( run . ms ) } < / s t r o n g >
< span > $ { Number ( run . zoom || 1 ) . toFixed ( 2 ) } x < / s p a n >
< span > $ { run . fast ? "fast" : "full" } < / s p a n >
` ;
table . append ( row ) ;
}
advancedInteractionHistoryEl . append ( table ) ;
}
function renderAdvancedData ( ) {
renderGenerationHistory ( ) ;
renderPatchHistory ( ) ;
renderInteractionSummary ( ) ;
renderInteractionHistory ( ) ;
renderViewportDiagnostics ( ) ;
renderPatchDiagnostics ( ) ;
2026-08-08 17:41:30 +09:00
renderSeamDiagnostics ( ) ;
2026-05-29 23:49:02 +09:00
renderWorkerWorldDiagnostics ( ) ;
renderWarningHistory ( ) ;
}
function reportValue ( value ) {
return value == null || value === "" ? "-" : String ( value ) ;
}
function reportRows ( title , rows = [ ] ) {
const lines = [ ` [ ${ title } ] ` ] ;
const visibleRows = ( rows || [ ] ) . filter ( Boolean ) ;
if ( ! visibleRows . length ) {
lines . push ( "- none" ) ;
return lines ;
}
for ( const row of visibleRows ) {
const sub = row . sub ? ` ( ${ row . sub } ) ` : "" ;
lines . push ( ` - ${ row . label } : ${ reportValue ( row . value ) } ${ sub } ` ) ;
}
return lines ;
}
function reportMetrics ( title , runs = [ ] ) {
const lines = reportRows ( title , performanceMetricsForRuns ( runs ) ) ;
if ( ! runs . length ) return lines ;
lines . push ( "Runs:" ) ;
runs . forEach ( ( run , index ) => {
const prefix = run . kind ? ` ${ run . kind } · ` : "" ;
const variant = run . kind ? ` , variant= ${ run . variant ? ? "-" } , worker= ${ run . worker ? "yes" : "no" } ` : "" ;
const write = run . writeAreaCells ? ` , write= ${ formatAreaCells ( run . writeAreaCells ) } , write/selection= ${ formatPercent ( ( run . writeRatio || 0 ) * 100 ) } ` : "" ;
lines . push ( ` ${ index + 1 } . ${ prefix } ${ terrainTypeLabel ( run . terrainType ) } : total= ${ formatMs ( run . totalMs ) } , area= ${ formatAreaCells ( run . areaCells ) } , sec/1000 cells= ${ formatSeconds ( run . secondsPerThousand ) } ${ variant } ${ write } ` ) ;
if ( run . timings ? . length ) {
lines . push ( ` breakdown: ${ run . timings . map ( ( row ) => ` ${ row . label } = ${ formatMs ( row . ms ) } ` ) . join ( ", " ) } ` ) ;
}
} ) ;
return lines ;
}
function interactionGroupsForReport ( ) {
const groups = new Map ( ) ;
for ( const run of state . interactionRuns ) {
const action = run . action || interactionAction ( run . kind ) ;
const phase = run . phase || interactionPhase ( run ) ;
const key = ` ${ action } : ${ phase } ` ;
if ( ! groups . has ( key ) ) groups . set ( key , { action , phase , values : [ ] } ) ;
groups . get ( key ) . values . push ( run . ms ) ;
}
return Array . from ( groups . values ( ) ) . map ( ( group ) => {
const stats = summarizeValues ( group . values ) ;
return {
label : interactionGroupLabel ( group . action , group . phase ) ,
value : ` n= ${ stats . count } , avg= ${ formatMs ( stats . avg ) } , max= ${ formatMs ( stats . max ) } , p95= ${ formatMs ( stats . p95 ) } ` ,
} ;
} ) ;
}
function reportInteractions ( ) {
const lines = reportRows ( "Viewport Interaction Latency" , interactionGroupsForReport ( ) ) ;
if ( ! state . interactionRuns . length ) return lines ;
lines . push ( "Recent operations:" ) ;
state . interactionRuns . forEach ( ( run , index ) => {
lines . push ( ` ${ index + 1 } . ${ run . kind } : ${ formatMs ( run . ms ) } , zoom= ${ Number ( run . zoom || 1 ) . toFixed ( 2 ) } x, phase= ${ run . fast ? "fast" : "full" } ` ) ;
} ) ;
return lines ;
}
function viewportDiagnosticRowsForReport ( ) {
const viewport = state . diagnostics . lastViewport ;
return 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" } ,
{ label : "Canvas bitmap" , value : ` ${ viewport . bitmapWidth } × ${ viewport . bitmapHeight } ` , sub : "render target" } ,
{ label : "Zoom" , value : ` ${ Number ( viewport . zoom || 1 ) . toFixed ( 2 ) } x ` , sub : viewport . fast ? "fast redraw" : "full redraw" } ,
{ label : "Viewport build" , value : formatMs ( viewport . viewportMs ) , sub : "getViewportMap" } ,
{ 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 ` } ,
] : [ ] ;
}
function featureCountRowsForReport ( ) {
const counts = state . diagnostics . lastFeatureCounts ;
return counts ? [
{ label : "Road paths" , value : counts . roads . toLocaleString ( ) , sub : ` ${ counts . roadCells . toLocaleString ( ) } path cells ` } ,
{ label : "Rail paths" , value : counts . railways . toLocaleString ( ) , sub : ` ${ counts . railwayCells . toLocaleString ( ) } path cells ` } ,
{ label : "River paths" , value : counts . rivers . toLocaleString ( ) , sub : ` ${ counts . riverCells . toLocaleString ( ) } path cells ` } ,
{ label : "Settlements" , value : counts . settlements . toLocaleString ( ) , sub : "cities, towns, ports, castles" } ,
{ label : "Stations" , value : counts . stations . toLocaleString ( ) , sub : "rail markers" } ,
{ label : "Hover labels" , value : counts . labels . toLocaleString ( ) , sub : "active hit targets" } ,
{ label : "Admin centers" , value : counts . adminCenters . toLocaleString ( ) , sub : "municipality labels" } ,
{ label : "Admin borders" , value : counts . adminBorders . toLocaleString ( ) , sub : ` ${ counts . adminBorderCells . toLocaleString ( ) } path cells ` } ,
{ label : "Industry / logistics" , value : ( counts . industrialZones + counts . logisticsParks ) . toLocaleString ( ) , sub : ` ${ counts . industrialZones } industrial, ${ counts . logisticsParks } logistics ` } ,
] : [ ] ;
}
function workerDiagnosticRowsForReport ( ) {
const workerAvailable = typeof Worker !== "undefined" ;
return [
{ label : "Worker API" , value : workerAvailable ? "available" : "unavailable" , sub : "browser capability" } ,
2026-08-08 17:41:30 +09:00
{ label : "Full-generation worker" , value : generationWorker ? "active" : "not active" , sub : state . diagnostics . lastGenerationWorkerUsed == null ? "created on demand" : ( state . diagnostics . lastGenerationWorkerUsed ? "last full generation used worker" : "last full generation used main thread" ) } ,
{ label : "Generation fallback" , value : state . diagnostics . lastGenerationWorkerFallbackReason || "none" , sub : "full-map generation" } ,
2026-05-29 23:49:02 +09:00
{ label : "Patch worker object" , value : patchWorker ? "active" : "not active" , sub : patchWorker ? "created" : "created on demand" } ,
{ label : "Last patch worker" , value : state . diagnostics . lastWorkerUsed == null ? "-" : ( state . diagnostics . lastWorkerUsed ? "used" : "main thread" ) , sub : state . diagnostics . lastPatchWorkerKind || "no patch run" } ,
2026-08-08 17:41:30 +09:00
{ label : "Patch fallback" , value : state . diagnostics . lastWorkerFallbackReason || "none" , sub : "last patch worker fallback" } ,
2026-05-29 23:49:02 +09:00
] ;
}
function worldDiagnosticRowsForReport ( ) {
const world = displayWorld ( ) ;
const source = displaySourceMap ( ) ;
const expansion = state . diagnostics . lastWorldExpansion ;
return [
{ label : "World size" , value : world ? ` ${ world . width } × ${ world . height } ` : "-" , sub : world ? formatAreaCells ( world . width * world . height ) : "no world" } ,
{ label : "Source map" , value : source ? ` ${ source . width || MAP _W } × ${ source . height || MAP _H } ` : "-" , sub : source ? formatAreaCells ( ( source . width || MAP _W ) * ( source . height || MAP _H ) ) : "no source" } ,
{ label : "Camera" , value : state . camera ? ` ${ Math . round ( state . camera . x || 0 ) } , ${ Math . round ( state . camera . y || 0 ) } ` : "-" , sub : "world-space origin" } ,
{ label : "World expansions" , value : state . diagnostics . worldExpansionCount . toLocaleString ( ) , sub : expansion ? ` last dx ${ expansion . dx } , dy ${ expansion . dy } ` : "none yet" } ,
{ label : "Pending patch" , value : state . pendingPatch ? "yes" : "no" , sub : state . pendingPatch ? ` ${ terrainTypeLabel ( state . pendingPatch . terrainType ) } · variant ${ state . pendingPatch . variant } ` : "committed world" } ,
] ;
}
function reportWarnings ( ) {
const lines = [ "[Warnings / Errors]" ] ;
if ( ! state . diagnosticLog . length ) {
lines . push ( "- none" ) ;
return lines ;
}
state . diagnosticLog . forEach ( ( entry , index ) => {
const time = entry . createdAt instanceof Date ? entry . createdAt . toISOString ( ) : "-" ;
lines . push ( ` - ${ index + 1 } . ${ time } ${ String ( entry . level || "info" ) . toUpperCase ( ) } ${ entry . title } : ${ entry . message || "-" } ` ) ;
} ) ;
return lines ;
}
function buildImportantDebugReport ( ) {
const source = displaySourceMap ( ) ;
const summaryRows = getStats ( source ) . map ( ( [ label , value ] ) => ( { label , value } ) ) ;
const contextRows = [
{ label : "Generated at" , value : new Date ( ) . toISOString ( ) } ,
{ label : "Seed" , value : seedInput ? . value || state . seedText || "-" } ,
{ label : "Generation type" , value : terrainTypeLabel ( generationTypeInput ? . value || state . generationType ) , sub : generationTypeInput ? . value || state . generationType } ,
{ label : "Patch terrain" , value : terrainTypeLabel ( patchTerrainTypeInput ? . value || "auto" ) , sub : patchTerrainTypeInput ? . value || "auto" } ,
2026-08-08 17:41:30 +09:00
{ label : "Patch mode" , value : patchModeInput ? . value || "auto" , sub : "auto separates expansion from regeneration" } ,
2026-05-29 23:49:02 +09:00
{ label : "Tool mode" , value : state . toolMode || "-" } ,
{ label : "Display mode" , value : state . mode || "-" } ,
{ label : "Features" , value : state . showFeatures ? "on" : "off" } ,
{ label : "Labels" , value : state . showLabels ? "on" : "off" } ,
] ;
return [
"Prefecture Map Generator — Important Debug Data" ,
"===============================================" ,
... reportRows ( "Context" , contextRows ) ,
"" ,
... reportRows ( "Current Map Summary" , summaryRows ) ,
"" ,
... reportMetrics ( "Full Generation Performance" , state . generationRuns ) ,
"" ,
... reportMetrics ( "Patch / Additional Generation Performance" , state . patchRuns ) ,
"" ,
... reportInteractions ( ) ,
"" ,
... reportRows ( "Viewport Diagnostics" , viewportDiagnosticRowsForReport ( ) ) ,
"" ,
... reportRows ( "Feature Counts" , featureCountRowsForReport ( ) ) ,
"" ,
... reportRows ( "Selection / Write Ratio" , selectionWriteDiagnostics ( ) ) ,
"" ,
2026-08-08 17:41:30 +09:00
... reportRows ( "Seam Diagnostics" , seamDiagnosticRows ( ) ) ,
"" ,
2026-05-29 23:49:02 +09:00
... reportRows ( "Worker Diagnostics" , workerDiagnosticRowsForReport ( ) ) ,
"" ,
... reportRows ( "World Diagnostics" , worldDiagnosticRowsForReport ( ) ) ,
"" ,
... reportWarnings ( ) ,
] . join ( "\n" ) ;
}
function setCopyDebugStatus ( text , isError = false ) {
if ( ! copyDebugStatusEl ) return ;
copyDebugStatusEl . textContent = text ;
copyDebugStatusEl . classList . toggle ( "error" , ! ! isError ) ;
window . clearTimeout ( setCopyDebugStatus . timer ) ;
setCopyDebugStatus . timer = window . setTimeout ( ( ) => {
if ( copyDebugStatusEl . textContent === text ) copyDebugStatusEl . textContent = "" ;
copyDebugStatusEl . classList . remove ( "error" ) ;
} , 1800 ) ;
}
function copyTextFallback ( text ) {
const textarea = document . createElement ( "textarea" ) ;
textarea . value = text ;
textarea . setAttribute ( "readonly" , "" ) ;
textarea . style . position = "fixed" ;
textarea . style . left = "-9999px" ;
textarea . style . top = "0" ;
document . body . append ( textarea ) ;
textarea . select ( ) ;
const ok = document . execCommand ( "copy" ) ;
textarea . remove ( ) ;
if ( ! ok ) throw new Error ( "Copy command failed" ) ;
}
async function copyImportantDebugData ( event ) {
event ? . preventDefault ? . ( ) ;
event ? . stopPropagation ? . ( ) ;
const text = buildImportantDebugReport ( ) ;
try {
if ( navigator . clipboard ? . writeText ) await navigator . clipboard . writeText ( text ) ;
else copyTextFallback ( text ) ;
setCopyDebugStatus ( "Copied" ) ;
} catch ( error ) {
console . warn ( "Failed to copy debug data" , error ) ;
try {
copyTextFallback ( text ) ;
setCopyDebugStatus ( "Copied" ) ;
} catch ( fallbackError ) {
console . warn ( "Fallback copy failed" , fallbackError ) ;
setCopyDebugStatus ( "Copy failed" , true ) ;
}
}
}
2026-05-24 19:33:09 +09:00
function renderTimingRows ( timings = [ ] ) {
if ( ! progressTimingsEl ) return ;
progressTimingsEl . innerHTML = "" ;
for ( const row of timings ) {
const item = document . createElement ( "div" ) ;
item . className = "progress-timing-row" ;
const label = document . createElement ( "span" ) ;
label . textContent = row . label ;
const value = document . createElement ( "strong" ) ;
value . textContent = formatMs ( row . ms ) ;
item . append ( label , value ) ;
progressTimingsEl . append ( item ) ;
}
}
function updateGenerationProgress ( event ) {
if ( ! progressEl ) return ;
progressEl . classList . remove ( "hidden" ) ;
2026-05-24 22:52:22 +09:00
if ( event ? . status === "start" ) generationCurrentStage = event . label || "Preparing" ;
2026-05-24 19:33:09 +09:00
if ( progressStageEl ) {
2026-05-24 22:52:22 +09:00
const elapsed = generationStartedAt ? ` / elapsed ${ formatMs ( performance . now ( ) - generationStartedAt ) } ` : "" ;
2026-05-24 19:33:09 +09:00
progressStageEl . textContent = event ? . status === "done"
2026-05-24 22:52:22 +09:00
? ` Completed: ${ event . label } / ${ formatMs ( event . ms ) } ${ elapsed } `
: ` Running: ${ event ? . label || generationCurrentStage || "Preparing" } ${ elapsed } ` ;
2026-05-24 19:33:09 +09:00
}
renderTimingRows ( event ? . timings || [ ] ) ;
}
function setProgressVisible ( visible , message = "Preparing" ) {
if ( ! progressEl ) return ;
progressEl . classList . toggle ( "hidden" , ! visible ) ;
2026-05-24 22:52:22 +09:00
if ( visible ) {
generationStartedAt = performance . now ( ) ;
generationCurrentStage = message ;
if ( generationTimer ) window . clearInterval ( generationTimer ) ;
generationTimer = window . setInterval ( ( ) => {
if ( progressStageEl && ! progressEl . classList . contains ( "hidden" ) ) {
progressStageEl . textContent = ` Running: ${ generationCurrentStage || "Preparing" } / elapsed ${ formatMs ( performance . now ( ) - generationStartedAt ) } ` ;
}
} , 100 ) ;
} else if ( generationTimer ) {
window . clearInterval ( generationTimer ) ;
generationTimer = null ;
}
2026-05-24 19:33:09 +09:00
if ( progressStageEl ) progressStageEl . textContent = message ;
if ( visible ) renderTimingRows ( [ ] ) ;
}
function nextFrame ( ) {
return new Promise ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
}
2026-05-29 23:49:02 +09:00
function countInside ( items ) {
return ( items || [ ] ) . filter ( ( item ) => item ? . insidePrefecture !== false ) . length ;
}
function totalRailLineCount ( map ) {
return ( map . railways || [ ] ) . length + ( map . branchRailways || [ ] ) . length + ( map . ringRailways || [ ] ) . length + ( map . externalRailways || [ ] ) . length ;
}
2026-05-20 13:50:56 +09:00
function getStats ( map ) {
2026-05-29 23:49:02 +09:00
if ( ! map ) return [ ] ;
2026-05-20 13:50:56 +09:00
return [
[ "Population" , ( map . totalPopulation || 0 ) . toLocaleString ( ) ] ,
2026-05-29 23:49:02 +09:00
[ "Municipalities" , ( map . adminCenters || [ ] ) . length . toLocaleString ( ) ] ,
[ "Major cities" , countInside ( map . modernCities ) . toLocaleString ( ) ] ,
[ "Ports" , countInside ( map . ports ) . toLocaleString ( ) ] ,
[ "Rail lines" , totalRailLineCount ( map ) . toLocaleString ( ) ] ,
[ "Generation" , map . generationTotalMs ? formatMs ( map . generationTotalMs ) : "-" ] ,
2026-05-20 13:50:56 +09:00
] ;
}
function renderStats ( map ) {
2026-05-29 23:49:02 +09:00
if ( ! statsEl ) return ;
2026-05-20 13:50:56 +09:00
statsEl . innerHTML = "" ;
for ( const [ labelText , valueText ] of getStats ( map ) ) {
const row = document . createElement ( "div" ) ;
row . className = "stat-row" ;
const label = document . createElement ( "span" ) ;
label . textContent = labelText ;
const value = document . createElement ( "strong" ) ;
value . textContent = String ( valueText ) ;
row . append ( label , value ) ;
statsEl . append ( row ) ;
}
}
2026-05-29 23:49:02 +09:00
const legendItems = {
base : [
[ "terrain-swatch" , "Terrain shading" ] ,
[ "river-major" , "Rivers and lakes" , "line" ] ,
[ "border-swatch" , "Prefecture border" ] ,
] ,
all : [
[ "city-icon" , "City / major town" , "icon" ] ,
[ "rail-line" , "Railway" , "line" ] ,
[ "road-line" , "Major road" , "line" ] ,
[ "river-major" , "River" , "line" ] ,
[ "border-swatch" , "Prefecture border" ] ,
] ,
terrain : [
[ "terrain-swatch" , "Elevation and relief" ] ,
[ "sea-swatch" , "Sea / lake" ] ,
[ "river-major" , "River system" , "line" ] ,
[ "border-swatch" , "Prefecture border" ] ,
] ,
modern : [
[ "city-icon" , "Modern city" , "icon" ] ,
[ "station-icon" , "Station" , "icon" ] ,
[ "rail-line" , "Railway" , "line" ] ,
[ "minor-road-line" , "Local road" , "line" ] ,
] ,
history : [
[ "town-icon" , "Market / village" , "icon" ] ,
[ "castle-icon" , "Castle / ruins" , "icon" ] ,
[ "port-icon" , "Historical port" , "icon" ] ,
[ "old-road-line" , "Premodern road" , "line" ] ,
] ,
landuse : [
[ "urban-swatch" , "Urban land use" ] ,
[ "industry-icon" , "Industry / logistics" , "icon" ] ,
[ "city-icon" , "City core" , "icon" ] ,
[ "river-major" , "Water body" , "line" ] ,
] ,
admin : [
[ "admin-swatch" , "Municipal border" ] ,
[ "border-swatch" , "Prefecture border" ] ,
[ "city-icon" , "Admin center" , "icon" ] ,
] ,
} ;
function legendRowsForMode ( mode ) {
return legendItems [ mode ] || legendItems . all ;
}
function renderLegendGrid ( container , rows ) {
if ( ! container ) return ;
container . innerHTML = "" ;
for ( const [ className , text , kind = "swatch" ] of rows ) {
const row = document . createElement ( "div" ) ;
row . className = "legend-row" ;
const mark = document . createElement ( "span" ) ;
mark . className = kind === "line" ? ` legend-line ${ className } ` : kind === "icon" ? ` legend-icon ${ className } ` : ` legend-swatch ${ className } ` ;
const label = document . createElement ( "span" ) ;
label . textContent = text ;
row . append ( mark , label ) ;
container . append ( row ) ;
}
}
function renderLegend ( ) {
const rows = legendRowsForMode ( state . mode ) ;
renderLegendGrid ( mainLegendGrid , rows ) ;
renderLegendGrid ( floatingLegendGrid , rows . slice ( 0 , 5 ) ) ;
}
2026-05-26 16:56:18 +09:00
function buildHoverEntities ( map ) {
return [
2026-05-20 13:50:56 +09:00
... ( map . modernCities || [ ] ) ,
... ( map . ports || [ ] ) ,
... ( map . stations || [ ] ) ,
... ( map . interchanges || [ ] ) ,
... ( map . industrialZones || [ ] ) ,
... ( map . logisticsParks || [ ] ) ,
... ( map . newTowns || [ ] ) ,
... ( map . castles || [ ] ) ,
... ( map . markets || [ ] ) ,
... ( map . villages || [ ] ) ,
... ( map . adminCenters || [ ] ) ,
] ;
2026-05-26 16:56:18 +09:00
}
function nearestEntity ( items , x , y , maxDistance = 5 ) {
2026-05-20 13:50:56 +09:00
let best = null ;
let bestD = maxDistance ;
2026-05-29 14:31:42 +09:00
for ( const item of items || [ ] ) {
if ( ! item || ! Number . isFinite ( item . x ) || ! Number . isFinite ( item . y ) ) continue ;
2026-05-20 13:50:56 +09:00
const d = Math . hypot ( item . x - x , item . y - y ) ;
if ( d < bestD ) { best = item ; bestD = d ; }
}
return best ;
}
function landuseName ( value ) {
2026-05-24 17:38:51 +09:00
return landuseLabel ( value ) ;
2026-05-20 13:50:56 +09:00
}
2026-05-29 14:31:42 +09:00
const ADMIN _ID _KEYS = [ "adminId" , "municipalityId" , "adminNumericId" , "id" , "numericId" ] ;
const PREFECTURE _ID _KEYS = [ "prefectureRegionId" , "prefectureId" , "id" , "numericId" ] ;
const MUNICIPALITY _NAME _KEYS = [ "municipalityName" , "name" , "canonicalSettlementName" , "municipalityRootName" , "generatedMunicipalityName" , "label" ] ;
const PREFECTURE _NAME _KEYS = [ "prefectureName" , "prefectureRegionName" , "regionName" , "name" , "label" ] ;
const POPULATION _KEYS = [ "municipalityPopulation" , "adminPopulation" , "population" , "estimatedPopulation" ] ;
function numericIdOf ( item , keys = ADMIN _ID _KEYS ) {
for ( const key of keys ) {
const value = item ? . [ key ] ;
if ( Number . isFinite ( value ) ) return Math . floor ( value ) ;
}
return null ;
}
function hasNumericId ( item , id , keys = ADMIN _ID _KEYS ) {
if ( ! item || id == null || id < 0 ) return false ;
return keys . some ( ( key ) => Number . isFinite ( item ? . [ key ] ) && Math . floor ( item [ key ] ) === Math . floor ( id ) ) ;
}
2026-05-29 22:00:42 +09:00
function numericPrefectureId ( item ) {
const value = numericIdOf ( item , PREFECTURE _ID _KEYS ) ;
return value == null ? - 1 : value ;
}
2026-05-29 14:31:42 +09:00
function firstUsableText ( item , keys ) {
for ( const key of keys ) {
2026-05-28 23:51:55 +09:00
const value = item ? . [ key ] ;
2026-05-29 14:31:42 +09:00
if ( ! looksNumericName ( value ) ) return String ( value ) . trim ( ) ;
}
return "" ;
}
function firstPopulation ( item ) {
for ( const key of POPULATION _KEYS ) {
const value = item ? . [ key ] ;
if ( Number . isFinite ( value ) && value > 0 ) return Math . round ( value ) ;
2026-05-28 23:51:55 +09:00
}
return null ;
}
function adminCenterForId ( map , adminId ) {
if ( ! map || adminId == null || adminId < 0 ) return null ;
2026-05-29 14:31:42 +09:00
const centers = ( map . adminCenters || [ ] ) . filter ( Boolean ) ;
const exact = centers . find ( ( center ) => hasNumericId ( center , adminId ) ) ;
if ( exact ) return exact ;
// Some legacy/admin debug arrays were once addressed by array index. Keep this
// only as a guarded fallback so numeric IDs are not mistaken for indexes.
const direct = map . adminCenters ? . [ adminId ] ;
return hasNumericId ( direct , adminId ) ? direct : null ;
2026-05-28 23:51:55 +09:00
}
function looksNumericName ( name ) {
if ( ! name ) return true ;
const text = String ( name ) . trim ( ) ;
2026-05-29 22:00:42 +09:00
return ! text || /^県域\d*$/u . test ( text ) || /^-?\d+(?:\s*[,, ]\s*\d+)*$/u . test ( text ) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i . test ( text ) ;
2026-05-20 13:50:56 +09:00
}
2026-05-29 14:31:42 +09:00
function nearestNamedAdminCenter ( map , cellIndex , maxDistance = 36 , adminId = null ) {
2026-05-28 23:51:55 +09:00
if ( ! map || cellIndex < 0 ) return null ;
const x = cellIndex % map . width ;
const y = Math . floor ( cellIndex / map . width ) ;
let best = null ;
let bestD = maxDistance ;
for ( const center of map . adminCenters || [ ] ) {
2026-05-29 14:31:42 +09:00
if ( ! center || ! Number . isFinite ( center . x ) || ! Number . isFinite ( center . y ) ) continue ;
if ( adminId != null && adminId >= 0 && ! hasNumericId ( center , adminId ) ) continue ;
if ( ! firstUsableText ( center , MUNICIPALITY _NAME _KEYS ) ) continue ;
2026-05-28 23:51:55 +09:00
const d = Math . hypot ( center . x - x , center . y - y ) ;
if ( d < bestD ) { best = center ; bestD = d ; }
}
return best ;
}
function adminName ( map , adminId , cellIndex = - 1 ) {
2026-05-29 14:31:42 +09:00
const center = adminCenterForId ( map , adminId ) || nearestNamedAdminCenter ( map , cellIndex , 54 , adminId ) ;
const name = firstUsableText ( center , MUNICIPALITY _NAME _KEYS ) ;
if ( name ) return name ;
2026-05-28 23:51:55 +09:00
return adminId >= 0 ? "Unnamed municipality" : "-" ;
}
function adminPopulation ( map , adminId , cellIndex = - 1 ) {
2026-05-29 14:31:42 +09:00
const center = adminCenterForId ( map , adminId ) || nearestNamedAdminCenter ( map , cellIndex , 54 , adminId ) ;
const centerPop = firstPopulation ( center ) ;
if ( centerPop !== null ) return centerPop ;
let sum = 0 ;
let found = false ;
for ( const key of [ "modernCities" , "satelliteCities" , "ports" , "markets" , "villages" ] ) {
for ( const p of map ? . [ key ] || [ ] ) {
if ( ! hasNumericId ( p , adminId ) ) continue ;
const pop = firstPopulation ( p ) ;
if ( pop !== null ) { sum += pop ; found = true ; }
}
}
return found ? sum : null ;
2026-05-26 16:56:18 +09:00
}
2026-05-29 22:00:42 +09:00
function worldSourceMap ( ) {
return displayWorld ( ) ? . sourceMap || null ;
}
function prefectureRegionById ( id , maps = [ ] ) {
if ( ! Number . isFinite ( id ) || id < 0 ) return null ;
for ( const source of maps ) {
const region = ( source ? . prefectureRegions || [ ] ) . find ( ( p ) => hasNumericId ( p , id , PREFECTURE _ID _KEYS ) ) ;
if ( region ) return region ;
}
return null ;
}
function prefectureNameForId ( id , adminId = - 1 ) {
if ( ! Number . isFinite ( id ) || id < 0 ) return "" ;
const sources = [ activeMap ( ) , worldSourceMap ( ) ] . filter ( Boolean ) ;
const region = prefectureRegionById ( id , sources ) ;
2026-05-29 14:31:42 +09:00
const regionName = firstUsableText ( region , PREFECTURE _NAME _KEYS ) ;
if ( regionName ) return regionName ;
2026-05-29 22:00:42 +09:00
for ( const source of sources ) {
for ( const center of source ? . adminCenters || [ ] ) {
if ( adminId >= 0 && ! hasNumericId ( center , adminId ) ) continue ;
const centerPref = numericPrefectureId ( center ) ;
if ( centerPref >= 0 && centerPref !== id ) continue ;
const name = firstUsableText ( center , [ "prefectureName" , "prefectureRegionName" , "regionName" ] ) ;
if ( name ) return name ;
}
}
return "" ;
}
function prefectureNameForCell ( map , i ) {
const id = map . prefectureRegionId ? . [ i ] ? ? - 1 ;
const direct = prefectureNameForId ( id ) ;
if ( direct ) return direct ;
2026-05-29 15:49:09 +09:00
const adminId = map . adminId ? . [ i ] ? ? - 1 ;
2026-05-29 22:00:42 +09:00
const mappedPref = adminId >= 0 ? map . municipalityToPrefectureId ? . [ adminId ] ? ? state . world ? . sourceMap ? . municipalityToPrefectureId ? . [ adminId ] ? ? - 1 : - 1 ;
const mapped = prefectureNameForId ( mappedPref , adminId ) ;
if ( mapped ) return mapped ;
2026-05-29 15:49:09 +09:00
const center = mappedPref === id ? nearestNamedAdminCenter ( map , i , 90 , adminId ) : null ;
2026-05-29 14:31:42 +09:00
const fromCenter = firstUsableText ( center , [ "prefectureName" , "prefectureRegionName" , "regionName" ] ) ;
return fromCenter || ( id >= 0 ? "Unnamed prefecture" : "-" ) ;
2026-05-26 15:32:27 +09:00
}
2026-05-20 13:50:56 +09:00
function updateTooltip ( event ) {
2026-05-28 19:37:28 +09:00
const map = activeMap ( ) ;
if ( ! map || ! tooltipEl || dragState . mode ) return ;
2026-05-20 13:50:56 +09:00
const rect = canvas . getBoundingClientRect ( ) ;
2026-05-28 00:30:09 +09:00
const cell = mapClientToCell ( event ) ;
if ( ! cell ) return ;
const { x , y } = cell ;
2026-05-28 19:37:28 +09:00
if ( x < 0 || y < 0 || x >= map . width || y >= map . height ) {
2026-05-20 13:50:56 +09:00
tooltipEl . classList . remove ( "visible" ) ;
return ;
}
2026-05-28 19:37:28 +09:00
const i = y * map . width + x ;
const worldCell = viewportCellToWorldCell ( { x , y } ) ;
2026-05-26 16:56:18 +09:00
const entity = nearestEntity ( state . hoverEntities , x , y ) ;
2026-05-28 19:37:28 +09:00
const elevation = map . elevation ? . [ i ] ? ? 0 ;
2026-05-28 23:51:55 +09:00
const density = map . populationDensity ? . [ i ] ? ? map . settlementScore ? . [ i ] ? ? 0 ;
2026-05-28 19:37:28 +09:00
const hoveredAdminId = map . adminId ? . [ i ] ? ? - 1 ;
2026-05-28 23:51:55 +09:00
const hoveredAdminPopulation = adminPopulation ( map , hoveredAdminId , i ) ;
2026-05-28 19:37:28 +09:00
const coordinateText = worldCell ? ` World ${ worldCell . x } , ${ worldCell . y } / View ${ x } , ${ y } ` : ` ${ x } , ${ y } ` ;
2026-05-29 14:31:42 +09:00
const entityName = firstUsableText ( entity , [ "name" , "facilityLabel" , "municipalityName" , "canonicalSettlementName" , "kind" ] ) ;
2026-05-28 19:37:28 +09:00
const entityTitle = entity
2026-05-29 14:31:42 +09:00
? ` ${ entityName || adminName ( map , hoveredAdminId , i ) || entity . kind || "Feature" } / ${ entity . kind || "Feature" } `
2026-05-28 19:37:28 +09:00
: coordinateText ;
2026-05-20 13:50:56 +09:00
const lines = [
2026-05-28 19:37:28 +09:00
` <strong> ${ entityTitle } </strong> ` ,
` Prefecture: ${ prefectureNameForCell ( map , i ) } ` ,
2026-05-28 23:51:55 +09:00
` Admin: ${ adminName ( map , hoveredAdminId , i ) } ` ,
2026-05-26 16:56:18 +09:00
` Admin Pop: ${ hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation . toLocaleString ( ) } ` ,
2026-05-28 19:37:28 +09:00
` Land: ${ map . sea ? . [ i ] ? "Sea" : landuseName ( map . landuse ? . [ i ] ) } ` ,
` Elevation: ${ elevation . toFixed ( 3 ) } / Slope: ${ ( map . slope ? . [ i ] ? ? 0 ) . toFixed ( 3 ) } ` ,
` River: ${ ( map . river ? . [ i ] ? ? 0 ) . toFixed ( 2 ) } / Density: ${ density . toFixed ( 2 ) } ` ,
2026-05-20 13:50:56 +09:00
] ;
if ( entity ? . population ) lines . splice ( 1 , 0 , ` Population: ${ entity . population . toLocaleString ( ) } ` ) ;
tooltipEl . innerHTML = lines . join ( "<br>" ) ;
2026-05-26 15:32:27 +09:00
const margin = 8 ;
const offset = 14 ;
const maxLeft = Math . max ( margin , rect . width - tooltipEl . offsetWidth - margin ) ;
const maxTop = Math . max ( margin , rect . height - tooltipEl . offsetHeight - margin ) ;
const desiredLeft = event . clientX - rect . left + offset ;
const desiredTop = event . clientY - rect . top + offset ;
tooltipEl . style . left = ` ${ Math . min ( Math . max ( margin , desiredLeft ) , maxLeft ) } px ` ;
tooltipEl . style . top = ` ${ Math . min ( Math . max ( margin , desiredTop ) , maxTop ) } px ` ;
2026-05-20 13:50:56 +09:00
tooltipEl . classList . add ( "visible" ) ;
}
2026-05-26 16:56:18 +09:00
function renderModeButtons ( ) {
2026-05-29 23:49:02 +09:00
if ( ! modeGrid ) return ;
2026-05-26 16:56:18 +09:00
modeGrid . innerHTML = "" ;
2026-05-20 13:50:56 +09:00
for ( const [ key , label ] of modes ) {
const button = document . createElement ( "button" ) ;
button . type = "button" ;
button . textContent = label ;
button . className = key === state . mode ? "mode-button active" : "mode-button" ;
button . addEventListener ( "click" , ( ) => {
state . mode = key ;
renderModeButtons ( ) ;
2026-05-29 23:49:02 +09:00
renderLegend ( ) ;
2026-05-20 13:50:56 +09:00
redraw ( ) ;
} ) ;
modeGrid . append ( button ) ;
}
2026-05-29 23:49:02 +09:00
renderLegend ( ) ;
2026-05-20 13:50:56 +09:00
}
2026-08-08 17:41:30 +09:00
function rejectGenerationJobs ( error ) {
for ( const pending of generationPendingJobs . values ( ) ) pending . reject ( error ) ;
generationPendingJobs . clear ( ) ;
}
function resetGenerationWorker ( reason = "Generation worker reset" , rejectPending = true ) {
const worker = generationWorker ;
generationWorker = null ;
worker ? . terminate ? . ( ) ;
if ( rejectPending && generationPendingJobs . size ) rejectGenerationJobs ( new Error ( reason ) ) ;
}
function createGenerationWorker ( ) {
if ( generationWorker ) return generationWorker ;
if ( typeof Worker === "undefined" ) {
state . diagnostics . lastGenerationWorkerFallbackReason = "Worker API unavailable" ;
return null ;
}
try {
const worker = new Worker ( new URL ( "./generationWorker.js" , import . meta . url ) , { type : "module" } ) ;
worker . addEventListener ( "message" , ( event ) => {
const data = event . data || { } ;
const pending = generationPendingJobs . get ( data . id ) ;
if ( ! pending ) return ;
if ( data . type === "progress" ) {
pending . onProgress ? . ( data . event || { } ) ;
return ;
}
if ( data . type !== "result" ) return ;
generationPendingJobs . delete ( data . id ) ;
if ( data . ok ) pending . resolve ( { map : data . map , worker : true } ) ;
else pending . reject ( new Error ( data . error || "Generation worker failed" ) ) ;
} ) ;
worker . addEventListener ( "error" , ( event ) => {
if ( generationWorker !== worker ) return ;
const reason = event ? . message || "Generation worker runtime error" ;
state . diagnostics . lastGenerationWorkerFallbackReason = reason ;
recordDiagnosticLog ( "error" , "Generation worker reset" , reason ) ;
resetGenerationWorker ( reason , true ) ;
} ) ;
worker . addEventListener ( "messageerror" , ( ) => {
if ( generationWorker !== worker ) return ;
const reason = "Generation worker message clone failed" ;
state . diagnostics . lastGenerationWorkerFallbackReason = reason ;
recordDiagnosticLog ( "error" , "Generation worker reset" , reason ) ;
resetGenerationWorker ( reason , true ) ;
} ) ;
generationWorker = worker ;
return worker ;
} catch ( error ) {
state . diagnostics . lastGenerationWorkerFallbackReason = error ? . message || "Generation worker creation failed" ;
generationWorker = null ;
return null ;
}
}
function runGenerationInWorker ( seed , options = { } , onProgress = null ) {
const worker = createGenerationWorker ( ) ;
if ( ! worker ) return null ;
const id = ++ generationJobSeq ;
return new Promise ( ( resolve , reject ) => {
generationPendingJobs . set ( id , { resolve , reject , onProgress } ) ;
try {
worker . postMessage ( { id , seed , options } ) ;
} catch ( error ) {
generationPendingJobs . delete ( id ) ;
reject ( error ) ;
}
} ) ;
}
async function generateFullMap ( seed , options = { } ) {
// Full-map generation used to run on the UI thread. A slow browser could
// therefore trigger the browser's long-running-script watchdog after tens of
// seconds even though generation itself was still making progress. Keep the
// complete CPU-bound pipeline in a worker; only progress messages and the
// finished map cross back to the UI thread.
const workerPromise = runGenerationInWorker ( seed , { terrainType : options . terrainType } , options . onProgress ) ;
if ( workerPromise ) {
const job = await workerPromise ;
state . diagnostics . lastGenerationWorkerUsed = true ;
state . diagnostics . lastGenerationWorkerFallbackReason = null ;
return job . map ;
}
// Do not restart a CPU-heavy full generation on the UI thread. On slower
// browsers that old compatibility fallback could hit the long-running-script
// watchdog after tens of seconds. Modern browser execution therefore
// requires a worker; if workers are blocked, fail immediately with a useful
// diagnostic instead of appearing to generate and then being interrupted.
state . diagnostics . lastGenerationWorkerUsed = false ;
const reason = state . diagnostics . lastGenerationWorkerFallbackReason || "Generation worker unavailable" ;
throw new Error ( ` ${ reason } . Full-map generation requires Web Worker support (serve the app over HTTP/HTTPS if local file workers are blocked). ` ) ;
}
2026-05-24 19:33:09 +09:00
async function regenerate ( ) {
2026-08-08 17:41:30 +09:00
const requestId = ++ generationRequestSeq ;
++ patchRequestSeq ;
state . patchBusy = false ;
state . patchBusyVariant = null ;
state . patchStatusMessage = "" ;
if ( patchWorker ) { patchWorker . terminate ? . ( ) ; patchWorker = null ; }
2026-05-20 13:50:56 +09:00
state . seedText = seedInput . value ;
2026-05-28 16:45:00 +09:00
state . generationType = generationTypeInput ? . value || "auto" ;
2026-08-08 17:41:30 +09:00
// A deliberate new Generate request supersedes an older one. Do not leave
// multiple full-map jobs queued behind a busy worker.
if ( generationPendingJobs . size ) resetGenerationWorker ( "Generation superseded by a newer request" , true ) ;
2026-05-24 19:33:09 +09:00
setProgressVisible ( true , "Preparing generation..." ) ;
await nextFrame ( ) ;
try {
2026-08-08 17:41:30 +09:00
const map = await generateFullMap ( parseSeed ( state . seedText ) , { onProgress : updateGenerationProgress , terrainType : state . generationType } ) ;
if ( requestId !== generationRequestSeq ) return ;
state . map = map ;
2026-05-28 19:37:28 +09:00
state . world = createWorldMap ( state . map ) ;
state . camera = createInitialCamera ( state . world ) ;
state . lastPatchResult = null ;
2026-05-29 22:00:42 +09:00
state . pendingPatch = null ;
2026-05-28 23:51:55 +09:00
resetPatchVariant ( { update : false } ) ;
2026-05-29 22:00:42 +09:00
hideSelectionOverlay ( { discardPreview : true } ) ;
2026-05-29 23:49:02 +09:00
recordGenerationRun ( state . map , state . generationType ) ;
2026-05-24 19:33:09 +09:00
renderStats ( state . map ) ;
redraw ( ) ;
2026-08-08 17:41:30 +09:00
if ( progressStageEl ) {
const workerText = state . diagnostics . lastGenerationWorkerUsed ? " in worker" : "" ;
progressStageEl . textContent = ` Done ${ workerText } in ${ formatMs ( state . map . generationTotalMs || 0 ) } ` ;
}
2026-05-24 19:33:09 +09:00
renderTimingRows ( state . map . generationTimings || [ ] ) ;
window . setTimeout ( ( ) => setProgressVisible ( false ) , 900 ) ;
} catch ( error ) {
2026-08-08 17:41:30 +09:00
// Superseding an old request is an expected cancellation, not a generation
// failure. The newer request owns the progress UI.
if ( requestId !== generationRequestSeq ) return ;
2026-05-29 23:49:02 +09:00
const reason = error ? . message || String ( error || "unknown generation error" ) ;
recordDiagnosticLog ( "error" , "Full generation failed" , reason , { terrainType : state . generationType } ) ;
if ( progressStageEl ) progressStageEl . textContent = ` Generation failed: ${ reason } ` ;
2026-08-08 17:41:30 +09:00
console . error ( "Full generation failed" , error ) ;
2026-05-24 19:33:09 +09:00
}
2026-05-20 13:50:56 +09:00
}
2026-05-28 19:37:28 +09:00
2026-05-28 23:51:55 +09:00
function derivePatchSeed ( rect , terrainType , variant = 0 ) {
2026-05-28 19:37:28 +09:00
let h = parseSeed ( state . seedText ) ^ 0x9e3779b9 ;
h = Math . imul ( h ^ ( rect . x0 | 0 ) , 1664525 ) >>> 0 ;
h = Math . imul ( h ^ ( rect . y0 | 0 ) , 1013904223 ) >>> 0 ;
h = Math . imul ( h ^ ( rect . x1 | 0 ) , 2246822519 ) >>> 0 ;
h = Math . imul ( h ^ ( rect . y1 | 0 ) , 3266489917 ) >>> 0 ;
2026-05-28 23:51:55 +09:00
h = Math . imul ( h ^ normalizePatchVariant ( variant ) , 668265263 ) >>> 0 ;
2026-05-28 19:37:28 +09:00
for ( const ch of String ( terrainType || "auto" ) ) h = Math . imul ( h ^ ch . charCodeAt ( 0 ) , 16777619 ) >>> 0 ;
return h >>> 0 ;
}
2026-05-28 23:51:55 +09:00
2026-05-29 22:00:42 +09:00
function beginZoomVisual ( oldZoom , event ) {
if ( zoomVisualState ) return ;
const rect = canvas . getBoundingClientRect ( ) ;
zoomVisualState = {
baseRect : rect ,
startZoom : clampZoom ( oldZoom || state . zoom || 1 ) ,
originX : Math . min ( Math . max ( event . clientX - rect . left , 0 ) , rect . width ) ,
originY : Math . min ( Math . max ( event . clientY - rect . top , 0 ) , rect . height ) ,
} ;
canvas . style . transformOrigin = ` ${ zoomVisualState . originX } px ${ zoomVisualState . originY } px ` ;
canvas . style . willChange = "transform" ;
canvas . classList . add ( "is-zooming" ) ;
if ( selectionSvgEl ) selectionSvgEl . style . visibility = "hidden" ;
}
function scheduleZoomVisualUpdate ( ) {
if ( ! zoomVisualState || zoomRedrawRaf != null ) return ;
zoomRedrawRaf = requestAnimationFrame ( ( ) => {
zoomRedrawRaf = null ;
if ( ! zoomVisualState ) return ;
const scale = clampZoom ( state . zoom || 1 ) / Math . max ( 1e-6 , zoomVisualState . startZoom || 1 ) ;
canvas . style . transform = ` scale( ${ scale } ) ` ;
} ) ;
}
function finishZoomVisual ( ) {
if ( zoomRedrawRaf != null ) {
cancelAnimationFrame ( zoomRedrawRaf ) ;
zoomRedrawRaf = null ;
}
if ( zoomVisualState ) {
canvas . style . transform = "" ;
canvas . style . transformOrigin = "" ;
canvas . style . willChange = "" ;
canvas . classList . remove ( "is-zooming" ) ;
if ( selectionSvgEl ) selectionSvgEl . style . visibility = "" ;
zoomVisualState = null ;
}
}
2026-05-28 23:51:55 +09:00
function handleCanvasWheel ( event ) {
if ( ! state . world || ! activeMap ( ) ) return ;
event . preventDefault ( ) ;
2026-05-29 23:49:02 +09:00
if ( zoomLatencyStartedAt == null ) zoomLatencyStartedAt = performance . now ( ) ;
2026-05-28 23:51:55 +09:00
tooltipEl ? . classList . remove ( "visible" ) ;
2026-05-29 14:31:42 +09:00
const beforeSize = viewportSizeForZoom ( state . zoom ) ;
const beforeCell = mapClientToCell ( event , beforeSize ) ;
2026-05-28 23:51:55 +09:00
const beforeWorld = beforeCell ? viewportCellToWorldCell ( beforeCell ) : null ;
const oldZoom = clampZoom ( state . zoom || 1 ) ;
2026-05-29 14:31:42 +09:00
const delta = event . deltaY < 0 ? 1.10 : 1 / 1.10 ;
2026-05-28 23:51:55 +09:00
const nextZoom = clampZoom ( oldZoom * delta ) ;
if ( Math . abs ( nextZoom - oldZoom ) < 0.001 ) return ;
state . zoom = nextZoom ;
2026-05-29 14:31:42 +09:00
const nextSize = syncViewportSize ( ) ;
2026-05-28 23:51:55 +09:00
if ( beforeWorld ) {
2026-05-29 14:31:42 +09:00
const afterCell = mapClientToCell ( event , nextSize ) ;
2026-05-28 23:51:55 +09:00
if ( afterCell ) {
2026-05-29 14:31:42 +09:00
state . camera = clampCameraForView ( {
2026-05-28 23:51:55 +09:00
x : beforeWorld . x - afterCell . x ,
y : beforeWorld . y - afterCell . y ,
2026-05-29 14:31:42 +09:00
} , nextSize ) ;
2026-05-28 23:51:55 +09:00
}
}
2026-05-29 14:31:42 +09:00
2026-05-29 22:00:42 +09:00
// Wheel events can fire dozens of times per second. During the gesture, keep
// the last rendered bitmap and only transform it on the GPU; rebuild the
// viewport and labels once the gesture settles.
beginZoomVisual ( oldZoom , event ) ;
scheduleZoomVisualUpdate ( ) ;
2026-05-29 14:31:42 +09:00
if ( zoomSettledTimer != null ) clearTimeout ( zoomSettledTimer ) ;
zoomSettledTimer = window . setTimeout ( ( ) => {
zoomSettledTimer = null ;
2026-05-29 23:49:02 +09:00
const startedAt = zoomLatencyStartedAt || performance . now ( ) ;
zoomLatencyStartedAt = null ;
2026-05-29 22:00:42 +09:00
finishZoomVisual ( ) ;
2026-05-29 14:31:42 +09:00
redraw ( { fastTerrain : false , allowWorldExpand : false } ) ;
2026-05-29 23:49:02 +09:00
recordInteractionLatency ( "wheel zoom" , startedAt , { zoom : state . zoom } ) ;
2026-05-29 22:00:42 +09:00
} , 170 ) ;
}
function cloneForPatchPreview ( value , seen = new Map ( ) ) {
if ( value == null || typeof value !== "object" ) return value ;
if ( ArrayBuffer . isView ( value ) ) return new value . constructor ( value ) ;
if ( value instanceof ArrayBuffer ) return value . slice ( 0 ) ;
if ( seen . has ( value ) ) return seen . get ( value ) ;
if ( value instanceof Map ) {
const out = new Map ( ) ;
seen . set ( value , out ) ;
for ( const [ k , v ] of value . entries ( ) ) out . set ( cloneForPatchPreview ( k , seen ) , cloneForPatchPreview ( v , seen ) ) ;
return out ;
}
if ( Array . isArray ( value ) ) {
const out = [ ] ;
seen . set ( value , out ) ;
for ( const item of value ) out . push ( cloneForPatchPreview ( item , seen ) ) ;
return out ;
}
const out = { } ;
seen . set ( value , out ) ;
for ( const [ key , item ] of Object . entries ( value ) ) out [ key ] = cloneForPatchPreview ( item , seen ) ;
return out ;
}
2026-08-08 17:41:30 +09:00
function clonePatchPreviewWorld ( world ) {
if ( typeof structuredClone === "function" ) {
try {
return structuredClone ( world ) ;
} catch ( error ) {
recordDiagnosticLog ( "warning" , "Native patch clone fallback" , error ? . message || String ( error ) ) ;
}
}
return cloneForPatchPreview ( world ) ;
}
function previewPatchDelta ( baseWorld , previewWorld , rectLike ) {
const rect = rectLike ? . writeRect || rectLike || null ;
if ( ! baseWorld || ! previewWorld || ! rect ) return null ;
const x0 = Math . max ( 0 , Math . floor ( rect . x0 || 0 ) ) ;
const y0 = Math . max ( 0 , Math . floor ( rect . y0 || 0 ) ) ;
const x1 = Math . min ( Math . max ( baseWorld . width || 0 , previewWorld . width || 0 ) , Math . ceil ( rect . x1 || 0 ) ) ;
const y1 = Math . min ( Math . max ( baseWorld . height || 0 , previewWorld . height || 0 ) , Math . ceil ( rect . y1 || 0 ) ) ;
const terrainFields = [ "elevation" , "sea" , "landuse" , "populationDensity" ] ;
const adminFields = [ "adminId" , "municipalityId" , "prefectureRegionId" ] ;
let changedCells = 0 ;
let terrainChangedCells = 0 ;
let adminChangedCells = 0 ;
for ( let y = y0 ; y < y1 ; y ++ ) {
for ( let x = x0 ; x < x1 ; x ++ ) {
const bi = worldIndexOf ( baseWorld , x , y ) ;
const pi = worldIndexOf ( previewWorld , x , y ) ;
if ( bi < 0 || pi < 0 ) continue ;
let terrainChanged = false ;
let adminChanged = false ;
for ( const name of terrainFields ) {
const a = baseWorld . fields ? . [ name ] ? . [ bi ] ;
const b = previewWorld . fields ? . [ name ] ? . [ pi ] ;
if ( a !== b && ! ( Number . isNaN ( a ) && Number . isNaN ( b ) ) ) { terrainChanged = true ; break ; }
}
for ( const name of adminFields ) {
const a = baseWorld . fields ? . [ name ] ? . [ bi ] ;
const b = previewWorld . fields ? . [ name ] ? . [ pi ] ;
if ( a !== b ) { adminChanged = true ; break ; }
}
if ( terrainChanged || adminChanged ) changedCells ++ ;
if ( terrainChanged ) terrainChangedCells ++ ;
if ( adminChanged ) adminChangedCells ++ ;
}
}
return { changedCells , terrainChangedCells , adminChangedCells } ;
}
function markPreviewRenderRevision ( world , variant ) {
if ( ! world ) return 0 ;
const revision = ++ state . renderRevision ;
world . renderRevision = revision ;
world . previewVariant = variant ;
return revision ;
}
2026-05-29 22:00:42 +09:00
function createPatchWorker ( ) {
2026-05-29 23:49:02 +09:00
if ( patchWorker ) return patchWorker ;
if ( typeof Worker === "undefined" ) {
state . diagnostics . lastWorkerFallbackReason = "Worker API unavailable" ;
return null ;
}
2026-05-29 22:00:42 +09:00
try {
patchWorker = new Worker ( new URL ( "./mapPatchWorker.js" , import . meta . url ) , { type : "module" } ) ;
2026-08-08 17:41:30 +09:00
const worker = patchWorker ;
worker . addEventListener ( "error" , ( event ) => {
if ( patchWorker !== worker ) return ;
2026-05-29 23:49:02 +09:00
const reason = event ? . message || "Patch worker runtime error" ;
state . diagnostics . lastWorkerFallbackReason = reason ;
recordDiagnosticLog ( "warning" , "Patch worker reset" , reason ) ;
2026-08-08 17:41:30 +09:00
worker . terminate ? . ( ) ;
2026-05-29 22:00:42 +09:00
patchWorker = null ;
} ) ;
2026-05-29 23:49:02 +09:00
} catch ( error ) {
state . diagnostics . lastWorkerFallbackReason = error ? . message || "Patch worker creation failed" ;
2026-05-29 22:00:42 +09:00
patchWorker = null ;
}
return patchWorker ;
}
function runPatchInWorker ( world , rect , options ) {
const worker = createPatchWorker ( ) ;
if ( ! worker ) return null ;
const id = ++ patchJobSeq ;
return new Promise ( ( resolve , reject ) => {
2026-08-08 17:41:30 +09:00
let settled = false ;
let watchdogTimer = null ;
const resetWatchdog = ( ) => {
if ( watchdogTimer != null ) clearTimeout ( watchdogTimer ) ;
watchdogTimer = window . setTimeout ( ( ) => {
fail ( new Error ( ` Patch worker produced no progress for ${ Math . round ( PATCH _WORKER _INACTIVITY _WATCHDOG _MS / 1000 ) } seconds and was stopped. ` ) ) ;
} , PATCH _WORKER _INACTIVITY _WATCHDOG _MS ) ;
} ;
2026-05-29 22:00:42 +09:00
const cleanup = ( ) => {
2026-08-08 17:41:30 +09:00
if ( watchdogTimer != null ) { clearTimeout ( watchdogTimer ) ; watchdogTimer = null ; }
2026-05-29 22:00:42 +09:00
worker . removeEventListener ( "message" , onMessage ) ;
worker . removeEventListener ( "error" , onError ) ;
worker . removeEventListener ( "messageerror" , onMessageError ) ;
2026-08-08 17:41:30 +09:00
if ( activePatchCancel === cancelJob ) activePatchCancel = null ;
if ( patchWorker === worker ) patchWorker = null ;
worker . terminate ? . ( ) ;
} ;
const succeed = ( value ) => { if ( settled ) return ; settled = true ; cleanup ( ) ; resolve ( value ) ; } ;
const fail = ( error ) => { if ( settled ) return ; settled = true ; cleanup ( ) ; reject ( error instanceof Error ? error : new Error ( String ( error || "Patch worker failed" ) ) ) ; } ;
const cancelJob = ( reason = "Patch generation cancelled by user." ) => {
const error = new Error ( reason ) ;
error . name = "AbortError" ;
fail ( error ) ;
2026-05-29 22:00:42 +09:00
} ;
const onMessage = ( event ) => {
if ( event . data ? . id !== id ) return ;
2026-08-08 17:41:30 +09:00
resetWatchdog ( ) ;
if ( event . data ? . type === "progress" ) { updateGenerationProgress ( event . data . progress || { } ) ; return ; }
if ( event . data . ok ) succeed ( { world : event . data . world , result : event . data . result , worker : true } ) ;
else fail ( new Error ( event . data . error || "Patch worker failed" ) ) ;
2026-05-29 22:00:42 +09:00
} ;
2026-08-08 17:41:30 +09:00
const onError = ( event ) => fail ( new Error ( event . message || "Patch worker error" ) ) ;
const onMessageError = ( ) => fail ( new Error ( "Patch worker message clone failed" ) ) ;
2026-05-29 22:00:42 +09:00
worker . addEventListener ( "message" , onMessage ) ;
worker . addEventListener ( "error" , onError ) ;
worker . addEventListener ( "messageerror" , onMessageError ) ;
2026-08-08 17:41:30 +09:00
activePatchCancel = cancelJob ;
resetWatchdog ( ) ;
const previewWorld = clonePatchPreviewWorld ( world ) ;
const transfer = Array . from ( collectTransferableBuffers ( previewWorld ) ) ;
try { worker . postMessage ( { id , world : previewWorld , rect , options } , transfer ) ; }
catch ( error ) { fail ( error ) ; }
2026-05-29 22:00:42 +09:00
} ) ;
}
2026-08-08 17:41:30 +09:00
function cancelPatchGeneration ( { clearSelection = false } = { } ) {
if ( ! state . patchBusy ) return false ;
patchRequestSeq ++ ;
const cancel = activePatchCancel ;
activePatchCancel = null ;
cancel ? . ( "Patch generation cancelled by user." ) ;
patchWorker ? . terminate ? . ( ) ;
patchWorker = null ;
state . patchBusy = false ;
state . patchBusyVariant = null ;
state . patchStatusMessage = "Generation cancelled." ;
setProgressVisible ( false ) ;
if ( clearSelection ) hideSelectionOverlay ( { discardPreview : true } ) ;
else updatePatchControls ( ) ;
return true ;
}
2026-05-29 22:00:42 +09:00
async function generatePatchPreviewWorld ( baseWorld , rect , options ) {
const workerPromise = runPatchInWorker ( baseWorld , rect , options ) ;
if ( workerPromise ) {
try {
return await workerPromise ;
} catch ( error ) {
2026-05-29 23:49:02 +09:00
const reason = error ? . message || String ( error || "unknown worker failure" ) ;
2026-08-08 17:41:30 +09:00
// A worker that dies after a long patch must not silently restart the same
// CPU-heavy job on the UI thread. That old fallback could turn a worker
// failure into a second long freeze and trigger the browser watchdog.
2026-05-29 23:49:02 +09:00
state . diagnostics . lastWorkerFallbackReason = reason ;
2026-08-08 17:41:30 +09:00
recordDiagnosticLog ( "error" , "Patch worker failed" , reason ) ;
2026-05-29 22:00:42 +09:00
patchWorker ? . terminate ? . ( ) ;
patchWorker = null ;
2026-08-08 17:41:30 +09:00
throw error ;
2026-05-29 22:00:42 +09:00
}
}
2026-08-08 17:41:30 +09:00
const reason = state . diagnostics . lastWorkerFallbackReason || "Patch worker unavailable" ;
throw new Error ( ` ${ reason } . Patch generation requires Web Worker support (serve the app over HTTP/HTTPS if local file workers are blocked). ` ) ;
2026-05-28 23:51:55 +09:00
}
2026-05-29 23:49:02 +09:00
async function generateSelectedPatch ( kind = "Patch preview" ) {
2026-08-08 17:41:30 +09:00
if ( state . patchBusy ) return ;
2026-05-28 19:37:28 +09:00
const validation = validatePatchRect ( state . selectionRect , state . world ) ;
if ( ! validation . ok ) {
2026-05-29 23:49:02 +09:00
recordDiagnosticLog ( "warning" , "Invalid patch selection" , validation . reason || "Selection is not valid." ) ;
2026-05-28 19:37:28 +09:00
updatePatchControls ( ) ;
return ;
}
2026-08-08 17:41:30 +09:00
const baseWorld = state . world ;
const requestId = ++ patchRequestSeq ;
2026-05-28 19:37:28 +09:00
const terrainType = patchTerrainTypeInput ? . value || generationTypeInput ? . value || "auto" ;
2026-08-08 17:41:30 +09:00
const patchMode = patchModeInput ? . value || "auto" ;
2026-05-28 23:51:55 +09:00
const variant = readPatchVariant ( ) ;
const seed = derivePatchSeed ( validation . rect , terrainType , variant ) ;
2026-08-08 17:41:30 +09:00
state . patchBusy = true ;
state . patchBusyVariant = variant ;
state . patchStatusMessage = "" ;
updatePatchControls ( ) ;
setProgressVisible ( true , ` Generating preview variant ${ variant } ... ` ) ;
2026-05-28 19:37:28 +09:00
await nextFrame ( ) ;
2026-05-29 23:49:02 +09:00
const patchStartedAt = performance . now ( ) ;
2026-05-28 19:37:28 +09:00
try {
2026-08-08 17:41:30 +09:00
const job = await generatePatchPreviewWorld ( baseWorld , validation . rect , {
terrainType ,
patchMode ,
seed ,
variant ,
// An interactive Alternative click is already the retry mechanism. Do not
// silently run a second complete patch attempt, which can double wall time
// and look like a browser-enforced interruption on slower machines.
maxQualityRetries : 0 ,
// Alternative is itself the candidate search UI. Generate exactly the
// requested variant instead of internally searching variant N/N+1/N+2;
// overlapping internal searches could select the same candidate on two
// consecutive Alternative clicks and made the map look unchanged.
qualityTerrainAttempts : 1 ,
// Preview generation may remain visible when the single requested
// expansion candidate only misses a terrain/human-geography quality floor.
// Hard seam-continuity failures are never accepted as best-available and
// are rolled back instead of displaying a visibly broken generation edge.
acceptBestAvailableQuality : true ,
includeSeamVisualization : state . showSeamDiagnostics ,
} ) ;
if ( requestId !== patchRequestSeq || state . world !== baseWorld ) return ;
2026-05-29 22:00:42 +09:00
const result = job . result ;
2026-05-28 19:37:28 +09:00
if ( ! result . ok ) {
2026-08-08 17:41:30 +09:00
const reason = result . reason || result . code || "invalid selection" ;
const showing = state . pendingPatch ? . variant ;
state . patchStatusMessage = showing == null
? ` Variant ${ variant } was rejected ( ${ reason } ); no preview was applied. `
: ` Variant ${ variant } was rejected ( ${ reason } ); still showing preview variant ${ showing } . ` ;
2026-05-29 23:49:02 +09:00
recordDiagnosticLog ( "warning" , "Patch failed" , reason , { kind , terrainType , variant } ) ;
2026-08-08 17:41:30 +09:00
if ( progressStageEl ) progressStageEl . textContent = state . patchStatusMessage ;
2026-05-28 19:37:28 +09:00
updatePatchControls ( ) ;
return ;
}
2026-05-29 23:49:02 +09:00
const patchWallMs = performance . now ( ) - patchStartedAt ;
2026-08-08 17:41:30 +09:00
const previewDelta = previewPatchDelta ( baseWorld , job . world , result . rects || validation . rect ) || { changedCells : 0 , terrainChangedCells : 0 , adminChangedCells : 0 } ;
result . previewDelta = previewDelta ;
markPreviewRenderRevision ( job . world , variant ) ;
state . pendingPatch = { world : job . world , result , rect : validation . rect , terrainType , seed , variant , worker : job . worker , previewDelta } ;
2026-05-29 23:49:02 +09:00
recordPatchRun ( result , terrainType , validation . rect , variant , { kind , worker : job . worker , wallMs : patchWallMs } ) ;
2026-05-29 22:00:42 +09:00
state . viewportMap = null ;
2026-08-08 17:41:30 +09:00
if ( progressStageEl ) progressStageEl . textContent = ` Rendering preview variant ${ variant } ... ` ;
await nextFrame ( ) ;
const renderStartedAt = performance . now ( ) ;
// Render the finished candidate in full immediately. A fast redraw followed
// by a delayed full redraw made completion ambiguous and could preserve a
// visually stale cached frame long enough to look like the result was lost.
redraw ( { fastTerrain : false , allowWorldExpand : false } ) ;
const renderMs = performance . now ( ) - renderStartedAt ;
2026-05-29 22:00:42 +09:00
renderStats ( displaySourceMap ( ) ) ;
2026-08-08 17:41:30 +09:00
state . patchStatusMessage = previewDelta . changedCells > 0
? ` Variant ${ variant } is displayed (render revision ${ job . world . renderRevision } ); ${ previewDelta . changedCells . toLocaleString ( ) } cells differ from the committed map. `
: ` Variant ${ variant } completed but is identical to the committed map in the write area. ` ;
2026-05-28 19:37:28 +09:00
updatePatchControls ( ) ;
2026-08-08 17:41:30 +09:00
if ( progressStageEl ) {
const modeText = ` ${ result . patchMode || patchMode } / ${ result . patchGenerationMode } ` ;
const quality = result . candidateQuality ;
const qualityText = quality
? ` / quality ${ quality . hardPass ? "PASS" : "best available" } ${ Number ( quality . score || 0 ) . toFixed ( 3 ) } / selected terrain variant ${ Number ( quality . selectedVariant || 0 ) } `
: "" ;
progressStageEl . textContent = ` Preview variant ${ variant } displayed ${ job . worker ? " from worker" : "" } : ${ previewDelta . changedCells . toLocaleString ( ) } changed cells / generation ${ formatMs ( patchWallMs ) } / render ${ formatMs ( renderMs ) } ${ qualityText } / ${ modeText } . ` ;
}
if ( result . candidateQuality && ! result . candidateQuality . hardPass ) {
recordDiagnosticLog ( "warning" , "Expansion quality floor not fully met" , ` Selected the best available production candidate (score ${ Number ( result . candidateQuality . score || 0 ) . toFixed ( 3 ) } ). ` , { terrainType , variant , candidateQuality : result . candidateQuality } ) ;
}
2026-05-29 15:49:09 +09:00
renderTimingRows ( result . patchTimings || [ ] ) ;
2026-08-08 17:41:30 +09:00
window . setTimeout ( ( ) => setProgressVisible ( false ) , 1400 ) ;
2026-05-28 19:37:28 +09:00
} catch ( error ) {
2026-08-08 17:41:30 +09:00
if ( error ? . name === "AbortError" ) {
state . patchStatusMessage = "Generation cancelled." ;
updatePatchControls ( ) ;
return ;
}
if ( requestId !== patchRequestSeq ) return ;
2026-05-29 23:49:02 +09:00
const reason = error ? . message || String ( error || "unknown patch error" ) ;
2026-08-08 17:41:30 +09:00
const showing = state . pendingPatch ? . variant ;
state . patchStatusMessage = showing == null
? ` Variant ${ variant } failed ( ${ reason } ); no preview was applied. `
: ` Variant ${ variant } failed ( ${ reason } ); still showing preview variant ${ showing } . ` ;
2026-05-29 23:49:02 +09:00
recordDiagnosticLog ( "error" , "Patch exception" , reason , { kind , terrainType , variant } ) ;
2026-08-08 17:41:30 +09:00
if ( progressStageEl ) progressStageEl . textContent = state . patchStatusMessage ;
console . error ( "Patch generation failed" , error ) ;
} finally {
if ( requestId === patchRequestSeq ) {
state . patchBusy = false ;
state . patchBusyVariant = null ;
updatePatchControls ( ) ;
if ( ! progressEl ? . classList . contains ( "hidden" ) ) window . setTimeout ( ( ) => setProgressVisible ( false ) , 1800 ) ;
}
2026-05-28 19:37:28 +09:00
}
}
2026-05-28 23:51:55 +09:00
async function generateAlternativePatch ( ) {
2026-08-08 17:41:30 +09:00
if ( state . patchBusy ) return ;
2026-05-28 23:51:55 +09:00
const validation = validatePatchRect ( state . selectionRect , state . world ) ;
if ( ! validation . ok ) {
updatePatchControls ( ) ;
return ;
}
setPatchVariant ( readPatchVariant ( ) + 1 , { update : false } ) ;
2026-05-29 23:49:02 +09:00
await generateSelectedPatch ( "Patch alternative" ) ;
2026-05-28 23:51:55 +09:00
}
2026-05-28 19:37:28 +09:00
function redraw ( options = { } ) {
2026-05-29 23:49:02 +09:00
const redrawStartedAt = performance . now ( ) ;
2026-05-29 22:00:42 +09:00
const renderWorld = displayWorld ( ) ;
if ( ! renderWorld ) return ;
2026-05-29 14:31:42 +09:00
const viewSize = syncViewportSize ( ) ;
2026-08-08 17:41:30 +09:00
// Patch workers clone world coordinates at launch. Expanding the committed
// world while a patch is running shifts origin/fields in-place and makes the
// returning preview use a stale coordinate frame. Defer automatic expansion
// until generation has finished.
const mayExpand = ! state . pendingPatch && ! state . patchBusy && options . allowWorldExpand !== false ;
2026-05-29 22:00:42 +09:00
const expansion = mayExpand ? ensureWorldPaddingForCamera ( state . world , state . camera , viewSize . width , viewSize . height ) : null ;
2026-05-28 23:51:55 +09:00
if ( expansion ? . expanded ) {
2026-05-29 18:50:54 +09:00
const ex = expansion . dx || 0 ;
const ey = expansion . dy || 0 ;
2026-05-29 23:49:02 +09:00
state . diagnostics . worldExpansionCount += 1 ;
state . diagnostics . lastWorldExpansion = {
at : new Date ( ) ,
dx : ex ,
dy : ey ,
worldWidth : state . world ? . width || 0 ,
worldHeight : state . world ? . height || 0 ,
viewWidth : viewSize . width ,
viewHeight : viewSize . height ,
} ;
2026-05-29 18:50:54 +09:00
state . camera = { x : ( state . camera ? . x || 0 ) + ex , y : ( state . camera ? . y || 0 ) + ey } ;
if ( dragState . mode === "pan" ) {
dragState . startCameraX += ex ;
dragState . startCameraY += ey ;
if ( dragState . pendingCamera ) dragState . pendingCamera = { x : dragState . pendingCamera . x + ex , y : dragState . pendingCamera . y + ey } ;
}
2026-05-28 23:51:55 +09:00
if ( state . selectionRect ) {
state . selectionRect = {
2026-05-29 14:31:42 +09:00
... state . selectionRect ,
2026-05-29 18:50:54 +09:00
x0 : state . selectionRect . x0 + ex ,
y0 : state . selectionRect . y0 + ey ,
x1 : state . selectionRect . x1 + ex ,
y1 : state . selectionRect . y1 + ey ,
polygon : Array . isArray ( state . selectionRect . polygon ) ? state . selectionRect . polygon . map ( ( p ) => ( { x : p . x + ex , y : p . y + ey } ) ) : state . selectionRect . polygon ,
2026-05-28 23:51:55 +09:00
} ;
}
}
2026-05-29 22:00:42 +09:00
const activeWorldForRender = displayWorld ( ) ;
state . camera = clampCameraForView ( state . camera , viewSize , activeWorldForRender ) ;
2026-05-29 23:49:02 +09:00
const viewportStartedAt = performance . now ( ) ;
2026-05-29 22:00:42 +09:00
state . viewportMap = getViewportMap ( activeWorldForRender , state . camera , viewSize . width , viewSize . height , { light : ! ! options . fastTerrain } ) ;
2026-05-29 23:49:02 +09:00
const viewportMs = performance . now ( ) - viewportStartedAt ;
const hoverStartedAt = performance . now ( ) ;
2026-05-29 14:31:42 +09:00
state . hoverEntities = options . fastTerrain ? [ ] : buildHoverEntities ( state . viewportMap ) ;
2026-05-29 23:49:02 +09:00
const hoverMs = performance . now ( ) - hoverStartedAt ;
const drawStartedAt = performance . now ( ) ;
2026-05-30 03:25:19 +09:00
const renderBreakdown = drawMap ( canvas , state . viewportMap , {
2026-05-20 13:50:56 +09:00
mode : state . mode ,
2026-05-29 14:31:42 +09:00
showFeatures : state . showFeatures && ! options . fastTerrain ,
2026-05-28 19:37:28 +09:00
showLabels : state . showLabels && ! options . fastTerrain ,
2026-08-08 17:41:30 +09:00
showSeamDiagnostics : state . showSeamDiagnostics && ! options . fastTerrain ,
2026-05-28 19:37:28 +09:00
continuousTerrain : ! options . fastTerrain ,
2026-05-29 14:31:42 +09:00
fastTerrain : ! ! options . fastTerrain ,
2026-05-28 23:51:55 +09:00
zoom : state . zoom || 1 ,
2026-05-20 13:50:56 +09:00
} ) ;
2026-05-29 23:49:02 +09:00
const drawMs = performance . now ( ) - drawStartedAt ;
2026-05-28 23:51:55 +09:00
applyCanvasZoom ( ) ;
2026-05-28 19:37:28 +09:00
if ( state . selectionRect && dragState . mode !== "select" ) updateSelectionOverlayFromWorldRect ( ) ;
2026-05-29 23:49:02 +09:00
updateRenderDiagnostics ( options , {
viewportMs ,
hoverMs ,
drawMs ,
totalRenderMs : performance . now ( ) - redrawStartedAt ,
2026-05-30 03:25:19 +09:00
renderBreakdown ,
2026-05-29 23:49:02 +09:00
} ) ;
renderAdvancedData ( ) ;
2026-05-20 13:50:56 +09:00
}
function init ( ) {
renderModeButtons ( ) ;
2026-05-29 23:49:02 +09:00
setToolMode ( "pan" ) ;
2026-05-20 13:50:56 +09:00
2026-05-29 23:49:02 +09:00
generateMapButton ? . addEventListener ( "click" , regenerate ) ;
2026-05-20 13:50:56 +09:00
seedInput . addEventListener ( "change" , regenerate ) ;
seedInput . addEventListener ( "keydown" , ( event ) => {
if ( event . key === "Enter" ) regenerate ( ) ;
} ) ;
2026-05-28 16:45:00 +09:00
generationTypeInput ? . addEventListener ( "change" , regenerate ) ;
2026-05-28 23:51:55 +09:00
patchTerrainTypeInput ? . addEventListener ( "change" , ( ) => {
2026-05-29 22:00:42 +09:00
discardPendingPatch ( { redrawAfter : true } ) ;
2026-05-28 23:51:55 +09:00
state . lastPatchResult = null ;
2026-08-08 17:41:30 +09:00
state . patchStatusMessage = "" ;
2026-05-28 23:51:55 +09:00
resetPatchVariant ( { update : false } ) ;
updatePatchControls ( ) ;
} ) ;
2026-08-08 17:41:30 +09:00
patchModeInput ? . addEventListener ( "change" , ( ) => {
discardPendingPatch ( { redrawAfter : true } ) ;
state . lastPatchResult = null ;
state . patchStatusMessage = "" ;
updatePatchControls ( ) ;
renderAdvancedData ( ) ;
} ) ;
patchVariantInput ? . addEventListener ( "change" , ( ) => {
state . patchStatusMessage = "" ;
setPatchVariant ( patchVariantInput . value ) ;
} ) ;
2026-05-28 23:51:55 +09:00
patchVariantInput ? . addEventListener ( "keydown" , ( event ) => {
if ( event . key === "Enter" ) {
setPatchVariant ( patchVariantInput . value ) ;
generateSelectedPatch ( ) ;
}
} ) ;
2026-05-29 23:49:02 +09:00
generatePatchButton ? . addEventListener ( "click" , ( ) => generateSelectedPatch ( "Patch preview" ) ) ;
2026-05-28 23:51:55 +09:00
alternativePatchButton ? . addEventListener ( "click" , generateAlternativePatch ) ;
2026-05-29 23:49:02 +09:00
applyPatchButton ? . addEventListener ( "click" , ( ) => hideSelectionOverlay ( { commitPreview : true } ) ) ;
discardPatchButton ? . addEventListener ( "click" , ( ) => hideSelectionOverlay ( { discardPreview : true } ) ) ;
2026-08-08 17:41:30 +09:00
clearPatchSelectionButton ? . addEventListener ( "click" , ( ) => hideSelectionOverlay ( { discardPreview : true } ) ) ;
cancelPatchButton ? . addEventListener ( "click" , ( ) => cancelPatchGeneration ( ) ) ;
2026-05-29 23:49:02 +09:00
toolPanButton ? . addEventListener ( "click" , ( ) => setToolMode ( "pan" ) ) ;
toolPatchButton ? . addEventListener ( "click" , ( ) => setToolMode ( "patch" ) ) ;
copyImportantDataButton ? . addEventListener ( "click" , copyImportantDebugData ) ;
zoomInButton ? . addEventListener ( "click" , ( ) => setZoomKeepingCenter ( ( state . zoom || 1 ) * 1.2 ) ) ;
zoomOutButton ? . addEventListener ( "click" , ( ) => setZoomKeepingCenter ( ( state . zoom || 1 ) / 1.2 ) ) ;
zoomResetButton ? . addEventListener ( "click" , ( ) => setZoomKeepingCenter ( 1 ) ) ;
centerMapButton ? . addEventListener ( "click" , recenterMap ) ;
2026-05-28 16:45:00 +09:00
2026-05-20 13:50:56 +09:00
randomSeedButton . addEventListener ( "click" , ( ) => {
seedInput . value = String ( Math . floor ( Math . random ( ) * 9999999 ) ) ;
regenerate ( ) ;
} ) ;
showFeaturesInput . addEventListener ( "change" , ( ) => {
state . showFeatures = showFeaturesInput . checked ;
redraw ( ) ;
} ) ;
showLabelsInput . addEventListener ( "change" , ( ) => {
state . showLabels = showLabelsInput . checked ;
redraw ( ) ;
} ) ;
2026-08-08 17:41:30 +09:00
showSeamDiagnosticsInput ? . addEventListener ( "change" , ( ) => {
state . showSeamDiagnostics = showSeamDiagnosticsInput . checked ;
redraw ( { fastTerrain : false , allowWorldExpand : false } ) ;
} ) ;
2026-05-20 13:50:56 +09:00
2026-05-28 00:30:09 +09:00
canvasShell ? . setAttribute ( "tabindex" , "0" ) ;
2026-05-28 19:37:28 +09:00
canvas . addEventListener ( "contextmenu" , ( event ) => event . preventDefault ( ) ) ;
2026-05-28 23:51:55 +09:00
canvas . addEventListener ( "wheel" , handleCanvasWheel , { passive : false } ) ;
2026-05-28 19:37:28 +09:00
canvas . addEventListener ( "pointerdown" , handleMapPointerDown ) ;
canvas . addEventListener ( "pointermove" , handleMapPointerMove ) ;
canvas . addEventListener ( "pointerup" , handleMapPointerUp ) ;
canvas . addEventListener ( "pointercancel" , handleMapPointerUp ) ;
2026-05-20 13:50:56 +09:00
canvas . addEventListener ( "mousemove" , updateTooltip ) ;
2026-05-28 00:30:09 +09:00
canvas . addEventListener ( "mouseleave" , ( ) => {
tooltipEl ? . classList . remove ( "visible" ) ;
} ) ;
2026-08-08 17:41:30 +09:00
window . addEventListener ( "keydown" , ( event ) => {
if ( event . key !== "Escape" ) return ;
if ( state . patchBusy ) {
cancelPatchGeneration ( ) ;
event . preventDefault ( ) ;
return ;
}
if ( ! state . selectionRect ) return ;
hideSelectionOverlay ( { discardPreview : true } ) ;
event . preventDefault ( ) ;
} ) ;
2026-05-20 13:50:56 +09:00
2026-05-29 23:49:02 +09:00
let resizeRaf = null ;
window . addEventListener ( "resize" , ( ) => {
if ( resizeRaf ) cancelAnimationFrame ( resizeRaf ) ;
resizeRaf = requestAnimationFrame ( ( ) => {
resizeRaf = null ;
if ( state . world ) redraw ( { allowWorldExpand : false } ) ;
else applyCanvasZoom ( ) ;
} ) ;
} ) ;
2026-05-28 19:37:28 +09:00
updatePatchControls ( ) ;
2026-05-29 23:49:02 +09:00
renderAdvancedData ( ) ;
2026-05-20 13:50:56 +09:00
regenerate ( ) ;
}
init ( ) ;