2026-08-10 13:59:33 +09:00
import { drawMap , drawMapCooperative } 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-10 13:59:33 +09:00
import { PATCH _MIN _AREA , PATCH _MIN _HEIGHT , PATCH _MIN _WIDTH , buildLargeExpansionTiles , buildPatchRects , validatePatchRect } from "./mapPatch.js" ;
import { hashCommittedWorldAsync , materializeCommittedWorldDeltaCooperative } from "./committedWorldDelta.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 : "" ,
2026-08-10 13:59:33 +09:00
fullGenerationBusy : false ,
selectionRevision : 0 ,
committedRevision : 0 ,
2026-08-08 17:41:30 +09:00
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" ) ;
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-08-10 13:59:33 +09:00
let progressRevision = 0 ;
let progressHideTimer = 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 ;
2026-08-10 13:59:33 +09:00
let activePatchOperation = null ;
let patchGeometryPreviewCache = null ;
let patchWorkerEpoch = 0 ;
let patchWorkerConstructorCount = 0 ;
let patchSearchSeries = { contextId : null , consumedCandidateIds : new Set ( ) , nextVariant : null } ;
2026-08-11 21:51:07 +09:00
const PATCH _SEARCH _BATCH _SIZE = 3 ;
const PATCH _SEARCH _DEFAULT _LIMIT = 12 ;
const PATCH _SEARCH _LARGE _LIMIT = 12 ;
2026-08-10 13:59:33 +09:00
const PATCH _WORKER _STALL _MS = 120_000 ;
const PATCH _NON _COOPERATIVE _DEADLINE _MS = 300_000 ;
2026-08-08 17:41:30 +09:00
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.
2026-08-10 13:59:33 +09:00
const input = points || [ ] ;
let pathLength = 0 ;
for ( let i = 1 ; i < input . length ; i ++ ) pathLength += Math . hypot ( input [ i ] . x - input [ i - 1 ] . x , input [ i ] . y - input [ i - 1 ] . y ) ;
// Bound polygon complexity before it reaches alpha/coverage geometry. The
// adaptive spacing preserves the full drawn contour while preventing a long
// gesture from turning every affected-cell operation into A x thousands.
const minDistance = Math . max ( 1.5 , Math . min ( 3 , displayedCellSize ( ) * 0.35 ) , pathLength / 256 ) ;
for ( const p of input ) {
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 } = { } ) {
2026-08-10 13:59:33 +09:00
resetPatchSearchSeries ( ) ;
2026-05-28 23:51:55 +09:00
return setPatchVariant ( 0 , { update } ) ;
}
2026-08-10 13:59:33 +09:00
function replaceSelectionRect ( rect ) {
state . selectionRect = rect || null ;
state . selectionRevision = ( state . selectionRevision || 0 ) + 1 ;
resetPatchSearchSeries ( ) ;
return state . selectionRect ;
}
function selectionSignature ( rect ) {
if ( ! rect ) return "none" ;
const polygon = Array . isArray ( rect . polygon )
? rect . polygon . map ( ( point ) => ` ${ Math . round ( point . x ) } , ${ Math . round ( point . y ) } ` ) . join ( ";" )
: "" ;
return ` ${ rect . kind || "rect" } : ${ rect . x0 } , ${ rect . y0 } , ${ rect . x1 } , ${ rect . y1 } : ${ polygon } ` ;
}
function resetPatchSearchSeries ( ) {
patchSearchSeries = { contextId : null , consumedCandidateIds : new Set ( ) , nextVariant : null } ;
}
function advanceCommittedRevision ( { preservePatchWorker = false } = { } ) {
state . committedRevision = ( state . committedRevision || 0 ) + 1 ;
resetPatchSearchSeries ( ) ;
// A persistent mirror is valid only for the exact committed revision. Apply,
// full regeneration, and backing-world resize replace/reshape that content;
// terminate the idle Worker so it cannot retain the old full world alongside
// the new committed map. The next patch rebuilds one fresh mirror.
if ( patchWorker && ! state . patchBusy && ! preservePatchWorker ) {
patchWorker . terminate ? . ( ) ;
patchWorker = null ;
}
return state . committedRevision ;
}
function acknowledgePatchApply ( worker , pendingPatch , committedRevision ) {
const applyToken = pendingPatch ? . result ? . applyToken ;
if ( ! worker || ! applyToken ) return Promise . resolve ( false ) ;
const ackId = ` apply- ${ committedRevision } - ${ Date . now ( ) } - ${ Math . random ( ) . toString ( 16 ) . slice ( 2 ) } ` ;
return new Promise ( ( resolve ) => {
let settled = false ;
const finish = ( ok ) => {
if ( settled ) return ;
settled = true ;
window . clearTimeout ( timeout ) ;
worker . removeEventListener ( "message" , onMessage ) ;
if ( ! ok && patchWorker === worker ) {
patchWorker = null ;
worker . terminate ? . ( ) ;
}
resolve ( ok ) ;
} ;
const onMessage = ( event ) => {
const data = event . data || { } ;
if ( data . type !== "patch-apply-ack-result" || data . ackId !== ackId ) return ;
if ( data . ok
&& Number ( data . mirrorCommittedRevision ) === committedRevision
&& data . mirrorHash === pendingPatch . result ? . acceptedWorldHash ) {
worker . _ _mirrorCommittedRevision = committedRevision ;
worker . _ _mirrorBaseWorld = state . world ;
recordDiagnosticLog ( "info" , "Patch Apply mirror acknowledged" , ` Worker mirror advanced to committed revision ${ committedRevision } . ` , { committedRevision , applyToken } ) ;
finish ( true ) ;
} else {
recordDiagnosticLog ( "warning" , "Patch Apply mirror rejected" , data . error || "Worker mirror ACK mismatch." , { committedRevision , applyToken } ) ;
finish ( false ) ;
}
} ;
const timeout = window . setTimeout ( ( ) => {
recordDiagnosticLog ( "warning" , "Patch Apply mirror timed out" , "The committed map is safe, but the stale Worker mirror was discarded." , { committedRevision , applyToken } ) ;
finish ( false ) ;
} , 30_000 ) ;
worker . addEventListener ( "message" , onMessage ) ;
try {
worker . postMessage ( {
type : "patch-apply-ack" ,
ackId ,
applyToken ,
baseCommittedRevision : pendingPatch . baseCommittedRevision ,
committedRevision ,
} ) ;
} catch ( error ) {
recordDiagnosticLog ( "warning" , "Patch Apply mirror dispatch failed" , error ? . message || String ( error ) , { committedRevision , applyToken } ) ;
finish ( false ) ;
}
} ) ;
}
function isPatchOperationCurrent ( operation ) {
return ! ! operation
&& activePatchOperation === operation
&& state . patchBusy
&& operation . requestId === patchRequestSeq
&& operation . baseWorld === state . world
&& operation . committedRevision === state . committedRevision
&& operation . selectionRevision === state . selectionRevision
&& operation . selectionSignature === selectionSignature ( state . selectionRect ) ;
}
function previewPatchRects ( validation ) {
if ( ! validation ? . ok || ! state . world ) return null ;
const patchMode = patchModeInput ? . value || "auto" ;
const key = [
state . selectionRevision , selectionSignature ( validation . rect ) , patchMode ,
state . world . width , state . world . height , state . world . patchGenerationSerial || 0 ,
] . join ( "|" ) ;
if ( patchGeometryPreviewCache ? . key === key ) return patchGeometryPreviewCache . rects ;
const rects = buildPatchRects ( validation . rect , state . world , { patchMode , _geometryOnly : true } ) ;
patchGeometryPreviewCache = { key , rects } ;
return rects ;
}
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-10 13:59:33 +09:00
const patchBusy = ! ! state . patchBusy ;
const fullBusy = ! ! state . fullGenerationBusy ;
const busy = patchBusy || fullBusy ;
2026-08-08 17:41:30 +09:00
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 ;
2026-08-10 13:59:33 +09:00
if ( cancelPatchButton ) cancelPatchButton . disabled = ! patchBusy ;
2026-08-08 17:41:30 +09:00
if ( patchVariantInput ) patchVariantInput . disabled = busy ;
2026-08-10 13:59:33 +09:00
if ( patchTerrainTypeInput ) patchTerrainTypeInput . disabled = busy ;
if ( patchModeInput ) patchModeInput . disabled = busy ;
if ( showSeamDiagnosticsInput ) showSeamDiagnosticsInput . disabled = busy ;
if ( generateMapButton ) generateMapButton . disabled = busy ;
if ( randomSeedButton ) randomSeedButton . disabled = busy ;
if ( seedInput ) seedInput . disabled = busy ;
if ( generationTypeInput ) generationTypeInput . 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-08-10 13:59:33 +09:00
const rects = previewPatchRects ( validation ) ;
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. `
2026-08-10 13:59:33 +09:00
: state . fullGenerationBusy
? " Full-map generation is running; patch actions are temporarily locked."
2026-08-08 17:41:30 +09:00
: "" ;
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 ;
2026-08-10 13:59:33 +09:00
const acceptedPatch = state . pendingPatch ;
const applyWorker = acceptedPatch . worker && acceptedPatch . result ? . applyToken ? patchWorker : null ;
state . world = acceptedPatch . world ;
2026-08-11 21:51:07 +09:00
// Seam diagnostics are a preview aid. Once the patch is applied, disable the
// magenta/red dashed overlay in both the committed source data and UI state so
// it cannot remain stuck on the map after the selection overlay disappears.
if ( state . world ? . sourceMap ? . patchSeamDiagnostics ) {
state . world . sourceMap . patchSeamDiagnostics . enabled = false ;
}
state . showSeamDiagnostics = false ;
if ( showSeamDiagnosticsInput ) showSeamDiagnosticsInput . checked = false ;
2026-08-10 13:59:33 +09:00
const committedRevision = advanceCommittedRevision ( { preservePatchWorker : ! ! applyWorker } ) ;
2026-05-29 22:00:42 +09:00
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 ;
2026-08-10 13:59:33 +09:00
if ( applyWorker ) acknowledgePatchApply ( applyWorker , acceptedPatch , committedRevision ) ;
2026-05-29 22:00:42 +09:00
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 ;
2026-08-10 13:59:33 +09:00
const discardedToken = state . pendingPatch . result ? . applyToken ;
if ( discardedToken && patchWorker ) {
try { patchWorker . postMessage ( { type : "patch-apply-discard" , applyToken : discardedToken } ) ; }
catch { /* Worker cleanup is best-effort; the next search replaces its pending delta. */ }
}
2026-05-29 22:00:42 +09:00
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-08-10 13:59:33 +09:00
replaceSelectionRect ( 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 ;
2026-08-10 13:59:33 +09:00
if ( event . button === 2 && ( state . patchBusy || state . fullGenerationBusy ) ) return ;
2026-05-28 19:37:28 +09:00
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" ) ;
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 ) {
2026-08-10 13:59:33 +09:00
( dragState . selectPath || ( dragState . selectPath = [ ] ) ) . push ( dragState . selectEnd ) ;
2026-05-29 14:31:42 +09:00
}
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" ;
2026-08-10 13:59:33 +09:00
const wasCancelled = event . type === "pointercancel" ;
if ( wasCancelled ) {
canvas . releasePointerCapture ? . ( event . pointerId ) ;
clearDragMode ( ) ;
if ( state . selectionRect ) updateSelectionOverlayFromWorldRect ( ) ;
else hideSelectionSvg ( ) ;
updatePatchControls ( ) ;
return ;
}
2026-05-28 19:37:28 +09:00
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 ] ;
2026-08-10 13:59:33 +09:00
else dragState . selectPath . push ( dragState . selectEnd ) ;
2026-05-29 14:31:42 +09:00
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 ) ) ) {
2026-08-10 13:59:33 +09:00
if ( state . pendingPatch ) discardPendingPatch ( { redrawAfter : true } ) ;
replaceSelectionRect ( 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 {
2026-08-10 13:59:33 +09:00
if ( state . selectionRect ) updateSelectionOverlayFromWorldRect ( ) ;
else hideSelectionSvg ( ) ;
updatePatchControls ( ) ;
2026-05-28 19:37:28 +09:00
}
}
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-10 13:59:33 +09:00
const rects = previewPatchRects ( validation ) ;
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 ? [
2026-08-11 21:51:07 +09:00
{ label : "Expansion quality gate" , value : d . qualityHardPass ? "PASS" : "FAIL" , sub : ` ${ d . qualityPolicyVersion } · score ${ Number ( d . qualityScore || 0 ) . toFixed ( 3 ) } · selected variant ${ Number ( d . qualitySelectedVariant || 0 ) } ` } ,
2026-08-08 17:41:30 +09:00
{ 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 [
2026-08-10 13:59:33 +09:00
{ label : "Samples" , value : String ( totals . count ) , sub : "retained runs, including failures and cancellations" } ,
2026-05-29 23:49:02 +09:00
{ 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 ;
2026-08-10 13:59:33 +09:00
const estimatedTileCount = Math . max ( 1 , Number ( meta . estimatedTileCount || result . tileCount || 1 ) ) ;
const actualCandidateCount = Math . max ( 1 , Array . isArray ( result . searchAttempts ) && result . searchAttempts . length
? result . searchAttempts . length
: Number ( result . candidateOrdinal || meta . candidateOrdinal || 1 ) ) ;
// Tiling and automatic candidate search, not selected area alone, determine
// production cost. A thin selection can cross canonical tile boundaries and
// be materially more expensive than a larger single-tile patch.
const performanceBudgetMs = estimatedTileCount <= 1 && actualCandidateCount <= 1 ? 30_000 : 60_000 ;
const performanceBudgetMet = totalMs <= performanceBudgetMs ;
2026-05-29 23:49:02 +09:00
pushCapped ( state . patchRuns , {
id : Date . now ( ) ,
createdAt : new Date ( ) ,
kind : meta . kind || "Patch preview" ,
2026-08-10 13:59:33 +09:00
status : meta . status || ( result . ok === false ? "rejected" : "success" ) ,
searchStatus : result . searchStatus || meta . searchStatus || null ,
candidateOrdinal : Number ( result . candidateOrdinal || meta . candidateOrdinal || 0 ) ,
candidateCount : Number ( result . candidateCount || meta . candidateCount || 0 ) ,
searchAttempts : Array . isArray ( result . searchAttempts ) ? result . searchAttempts : [ ] ,
executions : Array . isArray ( result . executions ) ? result . executions : [ ] ,
2026-05-29 23:49:02 +09:00
terrainType : terrainType || result . terrainType || "auto" ,
variant : Number . isFinite ( variant ) ? variant : result . variant ,
2026-08-10 13:59:33 +09:00
requestedVariant : Number . isFinite ( meta . requestedVariant ) ? meta . requestedVariant : result . requestedVariant ,
seed : Number . isFinite ( meta . seed ) ? meta . seed >>> 0 : ( Number . isFinite ( result . actualSeed ) ? result . actualSeed >>> 0 : result . seed ) ,
2026-05-29 23:49:02 +09:00
worker : usedWorker ,
2026-08-10 13:59:33 +09:00
workerStartCount : Number ( meta . workerStartCount || result . workerStartCount || 0 ) ,
workerEpoch : Number ( meta . workerEpoch || result . workerEpoch || 0 ) ,
committedRevision : Number ( meta . committedRevision ? ? result . baseCommittedRevision ? ? state . committedRevision ? ? 0 ) ,
inputDispatchMs : Number ( meta . inputDispatchMs || result . inputDispatchMs || 0 ) ,
inputMirrorReused : meta . inputMirrorReused === true || result . inputMirrorReused === true ,
estimatedTileCount ,
qualityWorkerRetryCount : Number ( result . qualityWorkerRetryCount || 0 ) ,
workerRestartCount : Number ( result . workerRestartCount || 0 ) ,
renderMs : Number ( meta . renderMs || 0 ) ,
2026-05-29 23:49:02 +09:00
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 ,
2026-08-10 13:59:33 +09:00
performanceBudgetMs ,
performanceBudgetMet ,
2026-05-29 23:49:02 +09:00
secondsPerThousand : secondsPerThousandCells ( totalMs , area ) ,
timings ,
2026-08-10 13:59:33 +09:00
} , 100 ) ;
2026-05-29 23:49:02 +09:00
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 ) {
2026-08-10 13:59:33 +09:00
const status = document . createElement ( "div" ) ;
status . className = "timing-pill meta" ;
status . innerHTML = ` <span>Status</span><strong> ${ String ( run . status || "-" ) . toUpperCase ( ) } </strong> ` ;
const candidate = document . createElement ( "div" ) ;
candidate . className = "timing-pill meta" ;
candidate . innerHTML = ` <span>Candidate</span><strong> ${ run . candidateOrdinal || "-" } / ${ run . candidateCount || "-" } </strong> ` ;
2026-05-29 23:49:02 +09:00
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> ` ;
2026-08-10 13:59:33 +09:00
const budget = document . createElement ( "div" ) ;
budget . className = "timing-pill meta" ;
budget . innerHTML = ` <span>Budget</span><strong> ${ run . performanceBudgetMet ? "PASS" : "FAIL" } / ${ formatMs ( run . performanceBudgetMs ) } </strong> ` ;
body . append ( status , candidate , meta , worker , ratio , candidateMode , seam , budget ) ;
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 ) ;
}
}
2026-08-10 13:59:33 +09:00
function writeProgressStage ( label , { includeElapsed = true } = { } ) {
if ( ! progressStageEl ) return ;
progressStageEl . textContent = label ;
if ( ! includeElapsed || ! generationStartedAt ) return ;
const elapsed = document . createElement ( "span" ) ;
elapsed . dataset . progressElapsed = "true" ;
elapsed . setAttribute ( "aria-hidden" , "true" ) ;
elapsed . textContent = ` / elapsed ${ formatMs ( performance . now ( ) - generationStartedAt ) } ` ;
progressStageEl . append ( elapsed ) ;
}
2026-05-24 19:33:09 +09:00
function updateGenerationProgress ( event ) {
if ( ! progressEl ) return ;
2026-08-10 13:59:33 +09:00
if ( Array . isArray ( window . _ _additionalGenerationE2EProgress ) ) {
window . _ _additionalGenerationE2EProgress . push ( {
at : performance . now ( ) ,
phase : event ? . phase || event ? . key || null ,
workUnitId : event ? . workUnitId || null ,
completed : event ? . completed ,
total : event ? . total ,
boundedWork : event ? . boundedWork === true ,
cooperative : event ? . cooperative !== false ,
startedAtWorker : event ? . startedAtWorker ,
lastAdvancedAtWorker : event ? . lastAdvancedAtWorker ,
} ) ;
2026-05-24 19:33:09 +09:00
}
2026-08-10 13:59:33 +09:00
progressEl . classList . remove ( "hidden" ) ;
const candidatePrefix = Number ( event ? . candidateCount || 0 ) > 0 && Number ( event ? . candidateOrdinal || 0 ) > 0
? ` Candidate ${ event . candidateOrdinal } / ${ event . candidateCount } : `
: "" ;
if ( event ? . label ) generationCurrentStage = ` ${ candidatePrefix } ${ event . label } ` ;
const completedText = Number . isFinite ( event ? . ms ) ? ` / ${ formatMs ( event . ms ) } ` : "" ;
writeProgressStage ( event ? . status === "done"
? ` Completed: ${ generationCurrentStage || event ? . label || "Done" } ${ completedText } `
: ` Running: ${ generationCurrentStage || "Preparing" } ` ) ;
2026-05-24 19:33:09 +09:00
renderTimingRows ( event ? . timings || [ ] ) ;
}
function setProgressVisible ( visible , message = "Preparing" ) {
if ( ! progressEl ) return ;
2026-08-10 13:59:33 +09:00
if ( progressHideTimer ) {
window . clearTimeout ( progressHideTimer ) ;
progressHideTimer = null ;
}
2026-05-24 19:33:09 +09:00
progressEl . classList . toggle ( "hidden" , ! visible ) ;
2026-05-24 22:52:22 +09:00
if ( visible ) {
2026-08-10 13:59:33 +09:00
progressRevision ++ ;
2026-05-24 22:52:22 +09:00
generationStartedAt = performance . now ( ) ;
generationCurrentStage = message ;
2026-08-10 13:59:33 +09:00
writeProgressStage ( ` Running: ${ message } ` ) ;
2026-05-24 22:52:22 +09:00
if ( generationTimer ) window . clearInterval ( generationTimer ) ;
generationTimer = window . setInterval ( ( ) => {
if ( progressStageEl && ! progressEl . classList . contains ( "hidden" ) ) {
2026-08-10 13:59:33 +09:00
const elapsed = progressStageEl . querySelector ( "[data-progress-elapsed]" ) ;
if ( elapsed ) elapsed . textContent = ` / elapsed ${ formatMs ( performance . now ( ) - generationStartedAt ) } ` ;
2026-05-24 22:52:22 +09:00
}
2026-08-10 13:59:33 +09:00
} , 1000 ) ;
2026-05-24 22:52:22 +09:00
} else if ( generationTimer ) {
window . clearInterval ( generationTimer ) ;
generationTimer = null ;
}
2026-05-24 19:33:09 +09:00
if ( progressStageEl ) progressStageEl . textContent = message ;
if ( visible ) renderTimingRows ( [ ] ) ;
}
2026-08-10 13:59:33 +09:00
function finishProgress ( message , timings = null , hideDelay = 0 ) {
if ( generationTimer ) {
window . clearInterval ( generationTimer ) ;
generationTimer = null ;
}
generationCurrentStage = message || generationCurrentStage ;
if ( progressStageEl ) progressStageEl . textContent = message || "Done" ;
if ( timings ) renderTimingRows ( timings ) ;
const revision = progressRevision ;
if ( progressHideTimer ) window . clearTimeout ( progressHideTimer ) ;
progressHideTimer = hideDelay > 0 ? window . setTimeout ( ( ) => {
if ( revision !== progressRevision ) return ;
progressHideTimer = null ;
setProgressVisible ( false , message || "Done" ) ;
} , hideDelay ) : null ;
}
2026-05-24 19:33:09 +09:00
function nextFrame ( ) {
return new Promise ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
}
2026-08-10 13:59:33 +09:00
function yieldPatchMainThread ( ) {
if ( typeof globalThis . scheduler ? . yield === "function" ) return globalThis . scheduler . yield ( ) ;
return new Promise ( ( resolve ) => window . setTimeout ( resolve , 0 ) ) ;
}
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 ) ;
}
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 ;
2026-08-11 21:51:07 +09:00
const gap = 18 ;
const cursorX = event . clientX - rect . left ;
const cursorY = event . clientY - rect . top ;
const tooltipWidth = tooltipEl . offsetWidth ;
const tooltipHeight = tooltipEl . offsetHeight ;
const maxLeft = Math . max ( margin , rect . width - tooltipWidth - margin ) ;
const maxTop = Math . max ( margin , rect . height - tooltipHeight - margin ) ;
// Keep following the cursor all the way to the bottom. Near the lower edge,
// prefer moving to the cursor's left/right while pinning the tooltip to the
// bottom margin; the old vertical flip made it appear to stop moving well
// before the cursor reached the bottom of the map.
const roomRight = rect . width - cursorX - gap ;
const roomLeft = cursorX - gap ;
const canRight = roomRight >= tooltipWidth ;
const canLeft = roomLeft >= tooltipWidth ;
let left = canRight ? cursorX + gap
: canLeft ? cursorX - gap - tooltipWidth
: Math . min ( Math . max ( margin , cursorX + gap ) , maxLeft ) ;
let top = Math . min ( Math . max ( margin , cursorY + 10 ) , maxTop ) ;
left = Math . min ( Math . max ( margin , left ) , maxLeft ) ;
// Cursor exclusion is a hard invariant. If horizontal separation is not
// available (very narrow viewport), then and only then move vertically.
const exclusion = 12 ;
const overlapsCursor = ( ) => cursorX >= left - exclusion && cursorX <= left + tooltipWidth + exclusion
&& cursorY >= top - exclusion && cursorY <= top + tooltipHeight + exclusion ;
if ( overlapsCursor ( ) ) {
const leftAlt = cursorX - gap - tooltipWidth ;
const rightAlt = cursorX + gap ;
if ( leftAlt >= margin ) left = leftAlt ;
else if ( rightAlt + tooltipWidth <= rect . width - margin ) left = rightAlt ;
}
if ( overlapsCursor ( ) ) {
const above = cursorY - gap - tooltipHeight ;
const below = cursorY + gap ;
if ( above >= margin ) top = above ;
else if ( below + tooltipHeight <= rect . height - margin ) top = below ;
}
left = Math . min ( Math . max ( margin , left ) , maxLeft ) ;
top = Math . min ( Math . max ( margin , top ) , maxTop ) ;
tooltipEl . style . left = ` ${ left } px ` ;
tooltipEl . style . top = ` ${ top } 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-10 13:59:33 +09:00
if ( state . fullGenerationBusy ) return ;
const requestedSeedText = seedInput . value ;
const requestedGenerationType = generationTypeInput ? . value || "auto" ;
2026-08-08 17:41:30 +09:00
const requestId = ++ generationRequestSeq ;
2026-08-10 13:59:33 +09:00
if ( state . patchBusy || activePatchCancel ) {
cancelPatchGeneration ( { reason : "Patch generation superseded by full-map generation." , silentProgress : true } ) ;
}
state . fullGenerationBusy = true ;
2026-08-08 17:41:30 +09:00
state . patchStatusMessage = "" ;
2026-08-10 13:59:33 +09:00
updatePatchControls ( ) ;
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-10 13:59:33 +09:00
const map = await generateFullMap ( parseSeed ( requestedSeedText ) , { onProgress : updateGenerationProgress , terrainType : requestedGenerationType } ) ;
2026-08-08 17:41:30 +09:00
if ( requestId !== generationRequestSeq ) return ;
2026-08-10 13:59:33 +09:00
state . seedText = requestedSeedText ;
state . generationType = requestedGenerationType ;
state . world = createWorldMap ( map ) ;
state . map = state . world . sourceMap ;
advanceCommittedRevision ( ) ;
2026-05-28 19:37:28 +09:00
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-08-10 13:59:33 +09:00
recordGenerationRun ( map , state . generationType ) ;
2026-05-24 19:33:09 +09:00
renderStats ( state . map ) ;
redraw ( ) ;
2026-08-10 13:59:33 +09:00
const workerText = state . diagnostics . lastGenerationWorkerUsed ? " in worker" : "" ;
finishProgress ( ` Done ${ workerText } in ${ formatMs ( state . map . generationTotalMs || 0 ) } ` , state . map . generationTimings || [ ] , 900 ) ;
2026-05-24 19:33:09 +09:00
} 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" ) ;
2026-08-10 13:59:33 +09:00
recordDiagnosticLog ( "error" , "Full generation failed" , reason , { terrainType : requestedGenerationType } ) ;
finishProgress ( ` Generation failed: ${ reason } ` , null , 1800 ) ;
2026-08-08 17:41:30 +09:00
console . error ( "Full generation failed" , error ) ;
2026-08-10 13:59:33 +09:00
} finally {
if ( requestId === generationRequestSeq ) {
state . fullGenerationBusy = false ;
updatePatchControls ( ) ;
}
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-08-10 13:59:33 +09:00
function derivePatchSeed ( world , terrainType , variant = 0 ) {
// Candidate placement already receives padding-invariant world coordinates.
// Keep the seed independent of selection bounds and backing-array padding so
// shared geography is reproducible when a selection grows or the world shifts.
let h = ( Number . isFinite ( world ? . seed ) ? world . seed >>> 0 : parseSeed ( state . seedText ) ) ^ 0x9e3779b9 ;
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-08-10 13:59:33 +09:00
function patchSearchContextId ( world , rect , terrainType , requestedPatchMode , resolvedPatchMode , candidateWindowSignature ) {
return [
state . committedRevision || 0 ,
selectionSignature ( rect ) ,
terrainType || "auto" ,
requestedPatchMode || "auto" ,
resolvedPatchMode || requestedPatchMode || "auto" ,
candidateWindowSignature || "default-window" ,
world ? . width || 0 ,
world ? . height || 0 ,
world ? . originX || 0 ,
world ? . originY || 0 ,
2026-08-11 21:51:07 +09:00
"quality-batched-production-search-v4|initial-quality-oracle-transport-parity-v3" ,
2026-08-10 13:59:33 +09:00
] . join ( "|" ) ;
}
function estimatePatchTileCount ( world , rect , resolvedPatchMode = "expansion" ) {
if ( ! world || ! rect ) return 1 ;
const regeneration = String ( resolvedPatchMode || "" ) . toLowerCase ( ) === "regeneration" ;
// Keep search-policy sizing tied to the actual production tiler. The former
// MAP_W/1.72 heuristic predated selection-anchored Expansion tiling and could
// classify a real four-tile request as 16 tiles, unnecessarily shrinking the
// candidate search window. This function is UI planning only; constructing the
// tile geometry is deterministic and does not generate any candidate content.
const tiles = buildLargeExpansionTiles ( rect , world , regeneration ? {
_largeSelectionThresholdWidth : MAP _W ,
_largeSelectionThresholdHeight : MAP _H ,
_tileCoreWidth : MAP _W ,
_tileCoreHeight : MAP _H ,
} : { } ) ;
return Math . max ( 1 , tiles . length || 1 ) ;
}
function buildPatchCandidatePlan ( world , rect , terrainType , requestedPatchMode , resolvedPatchMode , candidateWindowSignature , startVariant , { explicitFirst = true } = { } ) {
const contextId = patchSearchContextId ( world , rect , terrainType , requestedPatchMode , resolvedPatchMode , candidateWindowSignature ) ;
if ( patchSearchSeries . contextId !== contextId ) {
patchSearchSeries = { contextId , consumedCandidateIds : new Set ( ) , nextVariant : null } ;
}
const estimatedTileCount = estimatePatchTileCount ( world , rect , resolvedPatchMode ) ;
const candidateLimit = estimatedTileCount >= 16 ? PATCH _SEARCH _LARGE _LIMIT : PATCH _SEARCH _DEFAULT _LIMIT ;
const plan = [ ] ;
let variant = normalizePatchVariant ( startVariant ) ;
let scanned = 0 ;
while ( plan . length < candidateLimit && scanned < candidateLimit + patchSearchSeries . consumedCandidateIds . size + 8 ) {
const seed = derivePatchSeed ( world , terrainType , variant ) ;
const candidateId = ` ${ contextId } | ${ variant } | ${ seed } ` ;
if ( ( explicitFirst && plan . length === 0 && scanned === 0 ) || ! patchSearchSeries . consumedCandidateIds . has ( candidateId ) ) {
plan . push ( { candidateOrdinal : plan . length + 1 , candidateId , variant , seed } ) ;
}
variant = ( variant + 1 ) >>> 0 ;
scanned ++ ;
}
return { contextId , candidateLimit , estimatedTileCount , plan } ;
}
function consumePatchSearchAttempts ( contextId , attempts = [ ] ) {
if ( patchSearchSeries . contextId !== contextId ) return ;
for ( const attempt of attempts ) {
2026-08-11 21:51:07 +09:00
if ( attempt ? . status !== "rejected" && attempt ? . status !== "evaluated" && attempt ? . status !== "success" ) continue ;
2026-08-10 13:59:33 +09:00
const candidateId = ` ${ contextId } | ${ normalizePatchVariant ( attempt . variant ) } | ${ normalizePatchVariant ( attempt . seed ) } ` ;
patchSearchSeries . consumedCandidateIds . add ( candidateId ) ;
}
}
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 ) ;
}
2026-08-10 13:59:33 +09:00
function previewValuesEqual ( a , b ) {
if ( a === b || ( Number . isNaN ( a ) && Number . isNaN ( b ) ) ) return true ;
if ( a == null || b == null || typeof a !== "object" || typeof b !== "object" ) return false ;
if ( ArrayBuffer . isView ( a ) || ArrayBuffer . isView ( b ) ) {
if ( ! ArrayBuffer . isView ( a ) || ! ArrayBuffer . isView ( b ) || a . constructor !== b . constructor || a . length !== b . length ) return false ;
for ( let index = 0 ; index < a . length ; index ++ ) if ( ! previewValuesEqual ( a [ index ] , b [ index ] ) ) return false ;
return true ;
}
if ( Array . isArray ( a ) || Array . isArray ( b ) ) {
if ( ! Array . isArray ( a ) || ! Array . isArray ( b ) || a . length !== b . length ) return false ;
for ( let index = 0 ; index < a . length ; index ++ ) if ( ! previewValuesEqual ( a [ index ] , b [ index ] ) ) return false ;
return true ;
2026-08-08 17:41:30 +09:00
}
2026-08-10 13:59:33 +09:00
const aKeys = Object . keys ( a ) ;
const bKeys = Object . keys ( b ) ;
if ( aKeys . length !== bKeys . length ) return false ;
for ( let index = 0 ; index < aKeys . length ; index ++ ) {
const key = aKeys [ index ] ;
if ( key !== bKeys [ index ] || ! previewValuesEqual ( a [ key ] , b [ key ] ) ) return false ;
}
return true ;
2026-08-08 17:41:30 +09:00
}
function previewPatchDelta ( baseWorld , previewWorld , rectLike ) {
2026-08-10 13:59:33 +09:00
const rect = rectLike ? . transportReachRect || rectLike ? . repairRect || rectLike ? . writeRect || rectLike || null ;
2026-08-08 17:41:30 +09:00
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 ) ) ;
2026-08-10 13:59:33 +09:00
const terrainFields = new Set ( [ "elevation" , "slope" , "sea" , "landMask" , "plain" , "landuse" , "populationDensity" ] ) ;
const adminFields = new Set ( [ "adminId" , "municipalityId" , "prefectureRegionId" , "prefectureMask" , "humanRegionMask" ] ) ;
const fieldNames = new Set ( [ ... Object . keys ( baseWorld . fields || { } ) , ... Object . keys ( previewWorld . fields || { } ) ] ) ;
2026-08-08 17:41:30 +09:00
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 ;
2026-08-10 13:59:33 +09:00
let cellChanged = false ;
for ( const name of fieldNames ) {
2026-08-08 17:41:30 +09:00
const a = baseWorld . fields ? . [ name ] ? . [ bi ] ;
const b = previewWorld . fields ? . [ name ] ? . [ pi ] ;
2026-08-10 13:59:33 +09:00
if ( a === b || ( Number . isNaN ( a ) && Number . isNaN ( b ) ) ) continue ;
cellChanged = true ;
if ( terrainFields . has ( name ) ) terrainChanged = true ;
if ( adminFields . has ( name ) ) adminChanged = true ;
2026-08-08 17:41:30 +09:00
}
2026-08-10 13:59:33 +09:00
if ( cellChanged ) changedCells ++ ;
2026-08-08 17:41:30 +09:00
if ( terrainChanged ) terrainChangedCells ++ ;
if ( adminChanged ) adminChangedCells ++ ;
}
}
2026-08-10 13:59:33 +09:00
const featureKeys = [
"villages" , "markets" , "castles" , "ports" , "modernCities" , "satelliteCities" , "stations" ,
"interchanges" , "industrialZones" , "logisticsParks" , "newTowns" , "adminCenters" , "externalGateways" ,
"prefectureRegions" , "premodernRoads" , "minorRoads" , "nationalRoads" , "ringRoads" , "externalRoads" ,
"railways" , "branchRailways" , "ringRailways" , "externalRailways" , "expressways" , "externalExpressways" ,
"mainRivers" , "tributaryRivers" , "smallStreams" , "prefectureBorder" , "regionalPrefectureBorders" , "adminBorders" ,
] ;
const featureLayersChanged = featureKeys . reduce ( ( count , key ) => count
+ Number ( ! previewValuesEqual ( baseWorld . sourceMap ? . [ key ] || [ ] , previewWorld . sourceMap ? . [ key ] || [ ] ) ) , 0 ) ;
return { changedCells , terrainChangedCells , adminChangedCells , featureLayersChanged , identical : changedCells === 0 && featureLayersChanged === 0 } ;
2026-08-08 17:41:30 +09:00
}
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-10 13:59:33 +09:00
patchWorkerEpoch ++ ;
patchWorkerConstructorCount ++ ;
patchWorker . _ _patchWorkerEpoch = patchWorkerEpoch ;
patchWorker . _ _patchWorkerConstructorOrdinal = patchWorkerConstructorCount ;
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 ;
}
2026-08-10 13:59:33 +09:00
function isPlainMirrorObject ( value ) {
if ( ! value || typeof value !== "object" || Array . isArray ( value ) || ArrayBuffer . isView ( value ) ) return false ;
const prototype = Object . getPrototypeOf ( value ) ;
return prototype === Object . prototype || prototype === null ;
}
function preparePatchMirrorTransferValue ( value ) {
if ( ArrayBuffer . isView ( value ) ) {
if ( value instanceof DataView ) {
const buffer = value . buffer . slice ( value . byteOffset , value . byteOffset + value . byteLength ) ;
return { value : new DataView ( buffer ) , transfer : [ buffer ] } ;
}
const copy = new value . constructor ( value ) ;
return { value : copy , transfer : [ copy . buffer ] } ;
}
if ( value instanceof ArrayBuffer ) {
const copy = value . slice ( 0 ) ;
return { value : copy , transfer : [ copy ] } ;
}
return { value , transfer : [ ] } ;
}
function patchMirrorSourceEntryNeedsChunking ( value ) {
if ( ! isPlainMirrorObject ( value ) ) return false ;
// The geography metadata object contains a large family of fixed-map typed
// rasters. Sending that object as one structured-clone job creates a long
// main-thread task even though each raster is small enough to transfer
// responsively on its own. Split any source object with direct binary views.
return Object . values ( value ) . some ( ( entry ) => ArrayBuffer . isView ( entry ) || entry instanceof ArrayBuffer ) ;
}
function buildPatchMirrorManifest ( world ) {
const rootKeys = Object . keys ( world || { } ) . filter ( ( key ) => key !== "fields" && key !== "sourceMap" && key !== "generatedMask" ) ;
const fieldKeys = Object . keys ( world ? . fields || { } ) ;
const sourceKeys = Object . keys ( world ? . sourceMap || { } ) ;
const expandedSourceObjects = { } ;
for ( const key of sourceKeys ) {
const value = world . sourceMap [ key ] ;
if ( patchMirrorSourceEntryNeedsChunking ( value ) ) expandedSourceObjects [ key ] = Object . keys ( value ) ;
}
return {
rootKeys ,
fieldKeys ,
sourceKeys ,
expandedSourceObjects ,
hasGeneratedMask : ! ! world ? . generatedMask ,
} ;
}
function validatePatchWorkerProgress ( operation , progress ) {
if ( ! operation ) return { ok : true , advanced : true } ;
const phaseOrdinal = Number ( progress ? . phaseOrdinal || 0 ) ;
const phase = String ( progress ? . phase || progress ? . key || "patch" ) ;
const explicitWorkUnitId = progress ? . workUnitId != null && String ( progress . workUnitId ) . length > 0 ;
const workUnitId = String ( explicitWorkUnitId ? progress . workUnitId : ( progress ? . key || phase ) ) ;
const protocol = operation . progressProtocol || ( operation . progressProtocol = {
phaseOrdinal : 0 ,
phase : null ,
units : new Map ( ) ,
} ) ;
if ( phaseOrdinal > 0 && phaseOrdinal < protocol . phaseOrdinal ) {
return { ok : false , reason : ` Progress phase moved backward from ${ protocol . phaseOrdinal } to ${ phaseOrdinal } . ` } ;
}
if ( phaseOrdinal > 0 && phaseOrdinal === protocol . phaseOrdinal && protocol . phase && phase !== protocol . phase ) {
return { ok : false , reason : ` Progress phase identity changed inside ordinal ${ phaseOrdinal } : ${ protocol . phase } -> ${ phase } . ` } ;
}
const phaseAdvanced = phaseOrdinal > protocol . phaseOrdinal || ( protocol . phase == null && phase !== protocol . phase ) ;
if ( phaseAdvanced ) {
protocol . phaseOrdinal = Math . max ( protocol . phaseOrdinal , phaseOrdinal ) ;
protocol . phase = phase ;
}
if ( progress ? . boundedWork !== true ) {
// Older or intrinsically message-based stages do not yet expose a finite
// work total. Keep their heartbeat behavior, but record that they cannot
// satisfy the bounded-work release gate.
operation . unboundedProgressPhases || = new Set ( ) ;
operation . unboundedProgressPhases . add ( phase ) ;
// Unbounded messages are telemetry only: repeated messages must never keep
// a runaway cooperative stage alive. The watchdog is armed on first entry
// and only a finite counter advance (or an explicit non-cooperative stage
// transition) can refresh its deadline.
return { ok : true , advanced : phaseAdvanced , bounded : false , phaseAdvanced } ;
}
const completed = Number ( progress . completed ) ;
const total = Number ( progress . total ) ;
if ( ! explicitWorkUnitId ) {
return { ok : false , reason : ` Bounded progress is missing an explicit workUnitId for ${ phase } / ${ workUnitId } . ` } ;
}
if ( ! Number . isFinite ( completed ) || ! Number . isFinite ( total ) || completed < 0 || total < 0 || completed > total ) {
return { ok : false , reason : ` Invalid bounded progress for ${ phase } / ${ workUnitId } : ${ completed } / ${ total } . ` } ;
}
// A finite workUnitId names one bounded invocation for the complete search.
// Phase changes are presentation/telemetry boundaries and must never reset
// its monotonicity, otherwise a restarted producer can evade the watchdog.
const unitKey = workUnitId ;
const previous = protocol . units . get ( unitKey ) ;
if ( previous && ( total !== previous . total || completed < previous . completed ) ) {
return { ok : false , reason : ` Non-monotonic bounded progress for ${ phase } / ${ workUnitId } : ${ completed } / ${ total } after ${ previous . completed } / ${ previous . total } . ` } ;
}
const advanced = phaseAdvanced || ! previous || completed > previous . completed ;
protocol . units . set ( unitKey , { completed , total , phase } ) ;
return { ok : true , advanced , bounded : true , phaseAdvanced } ;
}
function runPatchInWorker ( world , rect , options , operation = null ) {
const workerWasWarm = ! ! patchWorker ;
2026-05-29 22:00:42 +09:00
const worker = createPatchWorker ( ) ;
if ( ! worker ) return null ;
2026-08-10 13:59:33 +09:00
const workerEpoch = Number ( worker . _ _patchWorkerEpoch || patchWorkerEpoch || 0 ) ;
const mirrorReused = ! ! operation
&& worker . _ _mirrorCommittedRevision === operation . committedRevision
&& worker . _ _mirrorBaseWorld === world ;
if ( operation ) {
operation . workerEpoch = workerEpoch ;
operation . lastWorkerEventSeq = 0 ;
operation . progressProtocol = null ;
}
2026-05-29 22:00:42 +09:00
const id = ++ patchJobSeq ;
return new Promise ( ( resolve , reject ) => {
2026-08-08 17:41:30 +09:00
let settled = false ;
let watchdogTimer = null ;
2026-08-10 13:59:33 +09:00
let dispatchMs = 0 ;
let mirrorBuildMs = 0 ;
let mirrorBuilt = false ;
let mirrorSyncSequence = 0 ;
let mirrorSyncWaiter = null ;
const mirrorSyncId = ` ${ id } : ${ workerEpoch } : ${ operation ? . committedRevision ? ? - 1 } ` ;
const workerFailure = ( message , code , recoverable = true ) => {
const error = new Error ( message ) ;
error . name = "PatchWorkerError" ;
error . code = code ;
error . recoverable = recoverable ;
return error ;
} ;
const resetWatchdog = ( progress = null , { advanced = true } = { } ) => {
// Always arm a deadline when entering work. Once armed, telemetry that
// does not advance a finite counter cannot extend it indefinitely.
if ( ! advanced && watchdogTimer != null ) return ;
2026-08-08 17:41:30 +09:00
if ( watchdogTimer != null ) clearTimeout ( watchdogTimer ) ;
2026-08-10 13:59:33 +09:00
const stageKey = String ( progress ? . phase || progress ? . key || "patch" ) ;
const nonCooperative = progress ? . nonCooperative === true || progress ? . cooperative === false ;
const deadlineMs = nonCooperative ? PATCH _NON _COOPERATIVE _DEADLINE _MS : PATCH _WORKER _STALL _MS ;
2026-08-08 17:41:30 +09:00
watchdogTimer = window . setTimeout ( ( ) => {
2026-08-10 13:59:33 +09:00
fail ( workerFailure (
nonCooperative
? ` Patch worker did not leave non-cooperative phase ${ stageKey } within ${ Math . round ( deadlineMs / 1000 ) } seconds and was stopped. `
: ` Patch worker bounded work stopped advancing during ${ stageKey } for ${ Math . round ( deadlineMs / 1000 ) } seconds and was stopped. ` ,
nonCooperative ? "worker-noncooperative-deadline" : "worker-stalled"
) ) ;
} , deadlineMs ) ;
} ;
const rejectMirrorWaiter = ( error ) => {
if ( ! mirrorSyncWaiter ) return ;
const waiter = mirrorSyncWaiter ;
mirrorSyncWaiter = null ;
waiter . reject ( error ) ;
2026-08-08 17:41:30 +09:00
} ;
2026-08-10 13:59:33 +09:00
const cleanup = ( terminateWorker = false ) => {
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 ;
2026-08-10 13:59:33 +09:00
if ( mirrorSyncWaiter ) {
const error = new Error ( "Patch mirror synchronization stopped." ) ;
error . name = "AbortError" ;
rejectMirrorWaiter ( error ) ;
}
if ( terminateWorker ) {
if ( patchWorker === worker ) patchWorker = null ;
worker . terminate ? . ( ) ;
}
2026-08-08 17:41:30 +09:00
} ;
2026-08-10 13:59:33 +09:00
const succeed = ( value ) => { if ( settled ) return ; settled = true ; cleanup ( false ) ; resolve ( value ) ; } ;
const fail = ( error ) => { if ( settled ) return ; settled = true ; cleanup ( true ) ; reject ( error instanceof Error ? error : new Error ( String ( error || "Patch worker failed" ) ) ) ; } ;
2026-08-08 17:41:30 +09:00
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
} ;
2026-08-10 13:59:33 +09:00
const onMessage = async ( event ) => {
2026-05-29 22:00:42 +09:00
if ( event . data ? . id !== id ) return ;
2026-08-10 13:59:33 +09:00
if ( event . data ? . type === "patch-mirror-sync-ack" ) {
if ( event . data . syncId !== mirrorSyncId || ! mirrorSyncWaiter ) return ;
if ( Number ( event . data . sequence || 0 ) !== mirrorSyncWaiter . sequence ) return ;
const waiter = mirrorSyncWaiter ;
mirrorSyncWaiter = null ;
if ( event . data . ok ) {
resetWatchdog ( { phase : "mirror-sync" , key : event . data . stage || "mirror-sync" , cooperative : true } ) ;
waiter . resolve ( event . data ) ;
} else {
waiter . reject ( workerFailure ( event . data . error || "Patch worker mirror synchronization failed." , "worker-mirror-sync" , true ) ) ;
}
return ;
}
if ( operation && event . data ? . searchId && event . data . searchId !== operation . searchId ) return ;
if ( operation && Number . isFinite ( event . data ? . workerEpoch ) && event . data . workerEpoch !== operation . workerEpoch ) return ;
if ( operation && ! isPatchOperationCurrent ( operation ) ) {
cancelJob ( "Patch generation superseded by a newer operation." ) ;
return ;
}
if ( event . data ? . type === "progress" ) {
const progress = event . data . progress || { } ;
let protocolResult = { ok : true , advanced : true } ;
if ( operation ) {
if ( progress . searchId !== operation . searchId || progress . workerEpoch !== operation . workerEpoch ) return ;
if ( Number ( progress . eventSeq || 0 ) <= Number ( operation . lastWorkerEventSeq || 0 ) ) return ;
operation . lastWorkerEventSeq = Number ( progress . eventSeq || 0 ) ;
protocolResult = validatePatchWorkerProgress ( operation , progress ) ;
if ( ! protocolResult . ok ) {
fail ( workerFailure ( protocolResult . reason , "worker-progress-invariant" , false ) ) ;
return ;
}
operation . currentCandidateOrdinal = Number ( progress . candidateOrdinal || operation . currentCandidateOrdinal || 0 ) ;
operation . currentVariant = Number . isFinite ( progress . variant ) ? progress . variant >>> 0 : operation . currentVariant ;
operation . currentSeed = Number . isFinite ( progress . seed ) ? progress . seed >>> 0 : operation . currentSeed ;
if ( progress . attemptSummary ? . status === "rejected" ) {
const summaries = operation . completedAttemptSummaries || ( operation . completedAttemptSummaries = [ ] ) ;
if ( ! summaries . some ( ( entry ) => entry . candidateId === progress . attemptSummary . candidateId ) ) {
summaries . push ( progress . attemptSummary ) ;
}
}
}
resetWatchdog ( progress , { advanced : protocolResult . advanced } ) ;
updateGenerationProgress ( progress ) ;
return ;
}
if ( event . data . ok ) {
let previewWorld = event . data . world || null ;
if ( ! previewWorld && event . data . worldDelta ) {
try {
// Materialize the accepted preview only after the Worker has
// restored its committed mirror. This avoids keeping a second full
// candidate world alive throughout production generation.
// Materialize only changed fields/layers. Cloning the complete
// committed world here duplicated every unchanged raster and the
// complete metadata graph immediately before preview rendering.
previewWorld = await materializeCommittedWorldDeltaCooperative ( world , event . data . worldDelta , {
consumeMetadata : true ,
yieldControl : yieldPatchMainThread ,
shouldCancel : ( ) => settled || ( operation && ! isPatchOperationCurrent ( operation ) ) ,
chunkBytes : 4 * 1024 * 1024 ,
} ) ;
// Verify the exact Worker-completed world before publication. The
// cooperative hash is bit-identical to the Worker hash but yields
// between bounded chunks, so Cancel remains serviceable while a
// large padded preview is being audited.
const expectedPreviewHash = event . data . result ? . acceptedWorldHash || null ;
if ( expectedPreviewHash ) {
const previewHash = await hashCommittedWorldAsync ( previewWorld , {
yieldControl : yieldPatchMainThread ,
shouldAbort : ( ) => settled || ( operation && ! isPatchOperationCurrent ( operation ) ) ,
} ) ;
if ( previewHash !== expectedPreviewHash ) {
throw new Error ( ` Accepted preview hash mismatch ( ${ previewHash } != ${ expectedPreviewHash } ). ` ) ;
}
}
} catch ( error ) {
fail ( workerFailure ( error ? . message || String ( error ) , "preview-delta-apply-failed" , false ) ) ;
return ;
}
}
if ( operation && Number ( event . data . mirrorCommittedRevision ) === operation . committedRevision ) {
worker . _ _mirrorCommittedRevision = operation . committedRevision ;
worker . _ _mirrorBaseWorld = world ;
}
succeed ( {
world : previewWorld ,
result : event . data . result ,
worker : true ,
workerEpoch ,
workerStartCount : workerWasWarm ? 0 : 1 ,
dispatchMs ,
mirrorBuildMs ,
mirrorReused ,
mirrorBuilt ,
} ) ;
}
else {
const terminalCode = event . data . code || "generation-error" ;
const recoverable = terminalCode === "worker-mirror-stale" ;
fail ( workerFailure ( event . data . error || "Patch worker failed" , terminalCode , recoverable ) ) ;
}
2026-05-29 22:00:42 +09:00
} ;
2026-08-10 13:59:33 +09:00
const onError = ( event ) => fail ( workerFailure ( event . message || "Patch worker error" , "worker-crash" ) ) ;
const onMessageError = ( ) => fail ( workerFailure ( "Patch worker message clone failed" , "worker-message-clone" , false ) ) ;
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 ;
2026-08-10 13:59:33 +09:00
if ( operation && ! isPatchOperationCurrent ( operation ) ) {
cancelJob ( "Patch generation superseded before worker launch." ) ;
return ;
}
const accountDispatch = ( startedAt ) => {
const elapsed = performance . now ( ) - startedAt ;
dispatchMs += elapsed ;
if ( operation ) operation . maxInputDispatchMs = Math . max ( Number ( operation . maxInputDispatchMs || 0 ) , elapsed ) ;
return elapsed ;
} ;
const sendMirrorSyncMessage = ( type , payload = { } , transfer = [ ] ) => {
if ( settled ) {
const error = new Error ( "Patch mirror synchronization was cancelled." ) ;
error . name = "AbortError" ;
return Promise . reject ( error ) ;
}
const sequence = ++ mirrorSyncSequence ;
return new Promise ( ( resolveSync , rejectSync ) => {
if ( mirrorSyncWaiter ) {
rejectSync ( workerFailure ( "Patch mirror synchronization protocol overlap." , "worker-mirror-sync" , false ) ) ;
return ;
}
mirrorSyncWaiter = { sequence , resolve : resolveSync , reject : rejectSync } ;
try {
const startedAt = performance . now ( ) ;
worker . postMessage ( { id , type , syncId : mirrorSyncId , sequence , ... payload } , transfer ) ;
accountDispatch ( startedAt ) ;
resetWatchdog ( { phase : "mirror-sync" , key : type , cooperative : true } ) ;
} catch ( error ) {
mirrorSyncWaiter = null ;
rejectSync ( workerFailure ( error ? . message || String ( error ) , "worker-post-message" , false ) ) ;
}
} ) ;
} ;
const sendMirrorValue = async ( type , payload , value ) => {
const prepared = preparePatchMirrorTransferValue ( value ) ;
await sendMirrorSyncMessage ( type , { ... payload , value : prepared . value } , prepared . transfer ) ;
} ;
const synchronizeCommittedMirror = async ( ) => {
const startedAt = performance . now ( ) ;
const manifest = buildPatchMirrorManifest ( world ) ;
await sendMirrorSyncMessage ( "patch-mirror-sync-start" , {
committedRevision : operation . committedRevision ,
manifest ,
} ) ;
for ( const key of manifest . rootKeys ) {
if ( settled ) throw new DOMException ( "Patch mirror synchronization cancelled." , "AbortError" ) ;
await sendMirrorValue ( "patch-mirror-sync-root" , { key } , world [ key ] ) ;
}
for ( const key of manifest . fieldKeys ) {
if ( settled ) throw new DOMException ( "Patch mirror synchronization cancelled." , "AbortError" ) ;
await sendMirrorValue ( "patch-mirror-sync-field" , { key } , world . fields [ key ] ) ;
}
if ( manifest . hasGeneratedMask ) {
await sendMirrorValue ( "patch-mirror-sync-generated-mask" , { } , world . generatedMask ) ;
}
for ( const key of manifest . sourceKeys ) {
if ( settled ) throw new DOMException ( "Patch mirror synchronization cancelled." , "AbortError" ) ;
const value = world . sourceMap [ key ] ;
const childKeys = manifest . expandedSourceObjects [ key ] ;
if ( Array . isArray ( childKeys ) ) {
await sendMirrorSyncMessage ( "patch-mirror-sync-source-object-start" , { key } ) ;
for ( const childKey of childKeys ) {
if ( settled ) throw new DOMException ( "Patch mirror synchronization cancelled." , "AbortError" ) ;
await sendMirrorValue ( "patch-mirror-sync-source-object-entry" , { key , childKey } , value [ childKey ] ) ;
}
} else {
await sendMirrorValue ( "patch-mirror-sync-source" , { key } , value ) ;
}
}
const finishAck = await sendMirrorSyncMessage ( "patch-mirror-sync-finish" , { } ) ;
mirrorBuildMs = performance . now ( ) - startedAt ;
mirrorBuilt = true ;
worker . _ _mirrorCommittedRevision = operation . committedRevision ;
worker . _ _mirrorBaseWorld = world ;
if ( operation ) operation . mirrorBuildMs = Math . max ( Number ( operation . mirrorBuildMs || 0 ) , mirrorBuildMs ) ;
return finishAck ;
} ;
const dispatchCandidate = ( reuseCommittedMirror , includeWorld = null ) => {
const startedAt = performance . now ( ) ;
worker . postMessage ( {
id ,
world : includeWorld ,
rect ,
options ,
search : operation ? {
searchId : operation . searchId ,
operationId : operation . operationId ,
committedRevision : operation . committedRevision ,
workerEpoch ,
executionAttempt : operation . executionAttempt || 1 ,
totalCandidateCount : operation . candidateLimit || operation . candidatePlan . length ,
reuseCommittedMirror ,
resolvedPatchMode : operation . resolvedPatchMode ,
2026-08-11 21:51:07 +09:00
selectBestCandidate : operation . selectBestCandidate === true ,
draftSelection : operation . draftSelection === true ,
2026-08-10 13:59:33 +09:00
candidatePlan : operation . candidatePlan ,
} : null ,
} ) ;
accountDispatch ( startedAt ) ;
resetWatchdog ( { key : "candidate-dispatch" , phase : "input-clone-dispatch" , nonCooperative : true } ) ;
} ;
const launch = async ( ) => {
if ( operation ) {
if ( ! mirrorReused ) await synchronizeCommittedMirror ( ) ;
if ( settled || ! isPatchOperationCurrent ( operation ) ) {
const error = new Error ( "Patch generation superseded before candidate dispatch." ) ;
error . name = "AbortError" ;
throw error ;
}
// A synchronized mirror is now authoritative in the Worker; never send
// the complete world in the candidate request. This keeps cold/rebuild
// input work in sub-megabyte, cancellable main-thread chunks.
dispatchCandidate ( true , null ) ;
} else {
// Backward-compatible internal path. Production calls always provide an
// operation and therefore use the chunked persistent-mirror protocol.
dispatchCandidate ( false , world ) ;
}
} ;
launch ( ) . catch ( ( error ) => {
if ( ! settled ) fail ( error ? . name === "AbortError" ? error : workerFailure ( error ? . message || String ( error ) , error ? . code || "worker-mirror-sync" , error ? . recoverable !== false ) ) ;
} ) ;
2026-05-29 22:00:42 +09:00
} ) ;
}
2026-08-10 13:59:33 +09:00
function cancelPatchGeneration ( { clearSelection = false , reason = "Patch generation cancelled by user." , silentProgress = false } = { } ) {
if ( ! state . patchBusy && ! activePatchCancel ) return false ;
const cancelledOperation = activePatchOperation ;
2026-08-08 17:41:30 +09:00
patchRequestSeq ++ ;
2026-08-10 13:59:33 +09:00
activePatchOperation = null ;
2026-08-08 17:41:30 +09:00
const cancel = activePatchCancel ;
activePatchCancel = null ;
2026-08-10 13:59:33 +09:00
cancel ? . ( reason ) ;
2026-08-08 17:41:30 +09:00
patchWorker ? . terminate ? . ( ) ;
patchWorker = null ;
state . patchBusy = false ;
state . patchBusyVariant = null ;
2026-08-10 13:59:33 +09:00
let deferredRunRecord = null ;
if ( cancelledOperation ? . baseWorld && Number . isFinite ( cancelledOperation . startedAt ) ) {
const cancelledVariant = Number . isFinite ( cancelledOperation . currentVariant )
? cancelledOperation . currentVariant >>> 0
: cancelledOperation . requestedVariant ;
const cancelledSeed = Number . isFinite ( cancelledOperation . currentSeed )
? cancelledOperation . currentSeed >>> 0
: cancelledOperation . requestedSeed ;
const cancelledWallMs = performance . now ( ) - cancelledOperation . startedAt ;
// renderAdvancedData() is intentionally outside the trusted-input critical
// path. On a long diagnostics history it can build hundreds of DOM nodes;
// cancellation itself must only reject/terminate the job, unlock controls,
// and preserve the committed canvas before returning to the event loop.
deferredRunRecord = ( ) => recordPatchRun (
{ ok : false , reason , patchMode : cancelledOperation . patchMode } ,
cancelledOperation . terrainType ,
cancelledOperation . selectionRect ,
cancelledVariant ,
{
kind : "Patch preview" , worker : true , wallMs : cancelledWallMs ,
status : reason . includes ( "superseded" ) ? "superseded" : "cancelled" , seed : cancelledSeed ,
requestedVariant : cancelledOperation . requestedVariant ,
candidateOrdinal : cancelledOperation . currentCandidateOrdinal ,
candidateCount : cancelledOperation . candidatePlan ? . length || 0 ,
workerEpoch : cancelledOperation . workerEpoch ,
committedRevision : cancelledOperation . committedRevision ,
estimatedTileCount : cancelledOperation . estimatedTileCount ,
}
) ;
}
state . patchStatusMessage = reason . includes ( "superseded" ) ? "" : "Generation cancelled." ;
if ( ! silentProgress ) setProgressVisible ( false ) ;
2026-08-08 17:41:30 +09:00
if ( clearSelection ) hideSelectionOverlay ( { discardPreview : true } ) ;
else updatePatchControls ( ) ;
2026-08-10 13:59:33 +09:00
if ( deferredRunRecord ) window . setTimeout ( deferredRunRecord , 0 ) ;
2026-08-08 17:41:30 +09:00
return true ;
}
2026-08-10 13:59:33 +09:00
async function generatePatchPreviewWorld ( baseWorld , rect , options , operation ) {
const assertCurrent = ( ) => {
if ( isPatchOperationCurrent ( operation ) ) return ;
const error = new Error ( "Patch generation superseded by a newer operation." ) ;
error . name = "AbortError" ;
throw error ;
} ;
assertCurrent ( ) ;
const requestedVariant = Number . isFinite ( options . variant ) ? options . variant >>> 0 : 0 ;
const requestedSeed = derivePatchSeed ( baseWorld , options . terrainType , requestedVariant ) ;
let job ;
let infrastructureRetries = 0 ;
let workerExecutionCount = 0 ;
let workerStartCount = 0 ;
let totalDispatchMs = 0 ;
while ( ! job ) {
assertCurrent ( ) ;
const executionRecord = {
executionAttempt : Math . max ( 1 , Number ( operation . executionAttempt || 1 ) ) ,
startedAt : performance . now ( ) ,
workerEpoch : null ,
variants : operation . candidatePlan . map ( ( candidate ) => candidate . variant >>> 0 ) ,
status : "dispatching" ,
code : null ,
reason : null ,
} ;
operation . executions || = [ ] ;
operation . executions . push ( executionRecord ) ;
const workerPromise = runPatchInWorker ( baseWorld , rect , {
... options ,
seed : requestedSeed ,
variant : requestedVariant ,
maxQualityRetries : 0 ,
} , operation ) ;
executionRecord . workerEpoch = operation . workerEpoch || null ;
if ( ! workerPromise ) {
const reason = state . diagnostics . lastWorkerFallbackReason || "Patch worker unavailable" ;
executionRecord . status = "worker-unavailable" ;
executionRecord . reason = reason ;
executionRecord . endedAt = performance . now ( ) ;
executionRecord . wallMs = executionRecord . endedAt - executionRecord . startedAt ;
throw new Error ( ` ${ reason } . Patch generation requires Web Worker support (serve the app over HTTP/HTTPS if local file workers are blocked). ` ) ;
}
workerExecutionCount ++ ;
2026-05-29 22:00:42 +09:00
try {
2026-08-10 13:59:33 +09:00
const execution = await workerPromise ;
executionRecord . endedAt = performance . now ( ) ;
executionRecord . wallMs = executionRecord . endedAt - executionRecord . startedAt ;
executionRecord . status = execution . result ? . searchStatus || ( execution . result ? . ok ? "succeeded" : "failed" ) ;
executionRecord . code = execution . result ? . code || null ;
executionRecord . reason = execution . result ? . reason || null ;
workerStartCount += Number ( execution . workerStartCount || 0 ) ;
totalDispatchMs += Number ( execution . dispatchMs || 0 ) ;
if ( execution . result ? . searchStatus === "infrastructure-error" && infrastructureRetries < 1 ) {
infrastructureRetries ++ ;
patchWorker ? . terminate ? . ( ) ;
patchWorker = null ;
operation . executionAttempt = infrastructureRetries + 1 ;
continue ;
}
2026-08-11 21:51:07 +09:00
// r9 quality search is batched: only three drafts are evaluated at a time.
// A batch that contains no publishable, fully finalized candidate advances
// to the next three variants without ever exposing a draft or a quality-
// rejected production result. Structural/invariant failures remain terminal.
const contentBatchExhausted = execution . result ? . searchStatus === "exhausted"
&& [ "patch-search-exhausted" , "patch-draft-search-exhausted" ] . includes ( String ( execution . result ? . code || "" ) ) ;
const allCandidatePlan = Array . isArray ( operation . allCandidatePlan ) ? operation . allCandidatePlan : operation . candidatePlan ;
const nextCandidateIndex = Math . max ( 0 , Number ( operation . nextCandidateIndex || 0 ) ) ;
if ( contentBatchExhausted && nextCandidateIndex < allCandidatePlan . length ) {
operation . completedAttemptSummaries || = [ ] ;
for ( const attempt of execution . result ? . searchAttempts || [ ] ) {
const identity = ` ${ attempt ? . candidateId || ` ${ attempt ? . variant } : ${ attempt ? . seed } ` } | ${ attempt ? . status || "unknown" } ` ;
if ( ! operation . completedAttemptSummaries . some ( ( entry ) => ` ${ entry ? . candidateId || ` ${ entry ? . variant } : ${ entry ? . seed } ` } | ${ entry ? . status || "unknown" } ` === identity ) ) {
operation . completedAttemptSummaries . push ( { ... attempt } ) ;
}
}
const batchSize = Math . max ( 1 , Number ( operation . candidateBatchSize || PATCH _SEARCH _BATCH _SIZE ) ) ;
const batchEnd = Math . min ( allCandidatePlan . length , nextCandidateIndex + batchSize ) ;
operation . candidatePlan = allCandidatePlan . slice ( nextCandidateIndex , batchEnd ) ;
operation . nextCandidateIndex = batchEnd ;
operation . candidateBatchOrdinal = Math . max ( 1 , Number ( operation . candidateBatchOrdinal || 1 ) ) + 1 ;
operation . executionAttempt = Math . max ( 1 , Number ( operation . executionAttempt || 1 ) ) + 1 ;
if ( progressStageEl ) {
const firstOrdinal = operation . candidatePlan [ 0 ] ? . candidateOrdinal || ( nextCandidateIndex + 1 ) ;
const lastOrdinal = operation . candidatePlan [ operation . candidatePlan . length - 1 ] ? . candidateOrdinal || batchEnd ;
progressStageEl . textContent = ` Quality batch ${ operation . candidateBatchOrdinal } : candidates ${ firstOrdinal } - ${ lastOrdinal } / ${ allCandidatePlan . length } ; previous batch did not meet the production quality floor... ` ;
}
recordDiagnosticLog ( "info" , "Patch quality search continuing" , ` No publishable candidate in quality batch ${ operation . candidateBatchOrdinal - 1 } ; evaluating the next batch. ` , {
completedCandidates : nextCandidateIndex ,
remainingCandidates : allCandidatePlan . length - nextCandidateIndex ,
nextVariants : operation . candidatePlan . map ( ( candidate ) => candidate . variant >>> 0 ) ,
} ) ;
continue ;
}
2026-08-10 13:59:33 +09:00
job = execution ;
2026-05-29 22:00:42 +09:00
} catch ( error ) {
2026-08-10 13:59:33 +09:00
executionRecord . endedAt = performance . now ( ) ;
executionRecord . wallMs = executionRecord . endedAt - executionRecord . startedAt ;
executionRecord . status = error ? . name === "AbortError" ? "cancelled" : "infrastructure-error" ;
executionRecord . code = error ? . code || error ? . name || "worker-error" ;
executionRecord . reason = error ? . message || String ( error || "worker error" ) ;
if ( error ? . name === "AbortError" ) throw error ;
if ( error ? . recoverable === true && infrastructureRetries < 1 ) {
infrastructureRetries ++ ;
const rejectedIds = new Set ( ( operation . completedAttemptSummaries || [ ] ) . map ( ( entry ) => entry . candidateId ) ) ;
const remainingPlan = operation . candidatePlan . filter ( ( candidate ) => ! rejectedIds . has ( candidate . candidateId ) ) ;
operation . candidatePlan = remainingPlan . length ? remainingPlan : operation . candidatePlan ;
operation . executionAttempt = infrastructureRetries + 1 ;
recordDiagnosticLog ( "warning" , "Patch worker restarted" , error . message || "Recoverable worker failure" , {
workerEpoch : operation ? . workerEpoch || 0 ,
executionAttempt : operation . executionAttempt ,
remainingVariants : operation . candidatePlan . map ( ( candidate ) => candidate . variant ) ,
} ) ;
continue ;
}
2026-05-29 23:49:02 +09:00
const reason = error ? . message || String ( error || "unknown worker failure" ) ;
state . diagnostics . lastWorkerFallbackReason = reason ;
2026-08-10 13:59:33 +09:00
recordDiagnosticLog ( "error" , "Patch worker failed" , reason , { workerEpoch : operation ? . workerEpoch || 0 } ) ;
2026-08-08 17:41:30 +09:00
throw error ;
2026-05-29 22:00:42 +09:00
}
}
2026-08-10 13:59:33 +09:00
assertCurrent ( ) ;
const mergedAttempts = [ ] ;
for ( const attempt of [ ... ( operation . completedAttemptSummaries || [ ] ) , ... ( job . result ? . searchAttempts || [ ] ) ] ) {
const identity = ` ${ attempt . candidateId || ` ${ attempt . variant } : ${ attempt . seed } ` } | ${ attempt . status } ` ;
if ( ! mergedAttempts . some ( ( entry ) => entry . _identity === identity ) ) mergedAttempts . push ( { ... attempt , _identity : identity } ) ;
}
if ( job . result ) {
job . result . searchAttempts = mergedAttempts . map ( ( { _identity , ... attempt } ) => attempt ) ;
job . result . candidateCount = operation . candidateLimit || job . result . candidateCount ;
job . result . executions = ( operation . executions || [ ] ) . map ( ( execution ) => ( { ... execution } ) ) ;
}
job . actualVariant = Number . isFinite ( job . result ? . actualVariant )
? job . result . actualVariant >>> 0
: Number . isFinite ( job . result ? . variant ) ? job . result . variant >>> 0 : requestedVariant ;
job . actualSeed = Number . isFinite ( job . result ? . actualSeed )
? job . result . actualSeed >>> 0
: Number . isFinite ( job . result ? . seed ) ? job . result . seed >>> 0 : requestedSeed ;
job . qualityWorkerRetryCount = 0 ;
job . workerRestartCount = infrastructureRetries ;
job . workerExecutionCount = workerExecutionCount ;
job . workerStartCount = workerStartCount ;
job . dispatchMs = totalDispatchMs ;
if ( job . result ) {
job . result . requestedVariant = requestedVariant ;
job . result . actualVariant = job . actualVariant ;
job . result . actualSeed = job . actualSeed ;
job . result . qualityWorkerRetryCount = 0 ;
job . result . workerRestartCount = infrastructureRetries ;
job . result . workerExecutionCount = workerExecutionCount ;
job . result . workerStartCount = job . workerStartCount ;
job . result . inputDispatchMs = Number ( job . dispatchMs || 0 ) ;
job . result . inputMirrorReused = job . mirrorReused === true ;
}
return job ;
2026-05-28 23:51:55 +09:00
}
2026-05-29 23:49:02 +09:00
async function generateSelectedPatch ( kind = "Patch preview" ) {
2026-08-10 13:59:33 +09:00
if ( state . patchBusy || state . fullGenerationBusy ) return ;
const patchStartedAt = performance . now ( ) ;
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-08-10 13:59:33 +09:00
const requestedTerrainType = patchTerrainTypeInput ? . value || generationTypeInput ? . value || "auto" ;
const inheritedTerrainType = baseWorld ? . sourceMap ? . terrainTemplate ? . terrainType
|| baseWorld ? . sourceMap ? . terrainDebug ? . terrainType
|| ( state . generationType !== "auto" ? state . generationType : null ) ;
// "Auto" on an expansion means continue the existing world's generator
// profile. It must not select a different template for the pasted area.
const terrainType = requestedTerrainType === "auto" && inheritedTerrainType
? inheritedTerrainType
: requestedTerrainType ;
2026-08-08 17:41:30 +09:00
const patchMode = patchModeInput ? . value || "auto" ;
2026-08-10 13:59:33 +09:00
const resolvedPatchRects = buildPatchRects ( validation . rect , baseWorld , { patchMode , _geometryOnly : true } ) ;
const resolvedPatchMode = resolvedPatchRects . patchMode || patchMode ;
const candidateWindowSignature = [
resolvedPatchRects . coreRect ? . x0 , resolvedPatchRects . coreRect ? . y0 ,
resolvedPatchRects . coreRect ? . x1 , resolvedPatchRects . coreRect ? . y1 ,
resolvedPatchRects . writeRect ? . x0 , resolvedPatchRects . writeRect ? . y0 ,
resolvedPatchRects . writeRect ? . x1 , resolvedPatchRects . writeRect ? . y1 ,
] . join ( "," ) ;
2026-05-28 23:51:55 +09:00
const variant = readPatchVariant ( ) ;
2026-08-10 13:59:33 +09:00
const searchPlan = buildPatchCandidatePlan (
baseWorld , validation . rect , terrainType , patchMode , resolvedPatchMode , candidateWindowSignature , variant ,
{ explicitFirst : kind !== "Patch alternative" }
) ;
const seed = searchPlan . plan [ 0 ] ? . seed ? ? derivePatchSeed ( baseWorld , terrainType , variant ) ;
const operation = {
requestId ,
operationId : ` patch-operation- ${ requestId } ` ,
searchId : ` ${ searchPlan . contextId } |search- ${ requestId } ` ,
searchContextId : searchPlan . contextId ,
committedRevision : state . committedRevision ,
baseWorld ,
selectionRevision : state . selectionRevision ,
selectionSignature : selectionSignature ( state . selectionRect ) ,
requestedTerrainType ,
terrainType ,
patchMode ,
resolvedPatchMode ,
candidateWindowSignature ,
2026-08-11 21:51:07 +09:00
generatorPolicyVersion : "quality-batched-production-search-v4" ,
qualityPolicyVersion : "initial-quality-oracle-transport-parity-v3" ,
selectBestCandidate : true ,
draftSelection : true ,
2026-08-10 13:59:33 +09:00
requestedVariant : variant ,
requestedSeed : seed ,
currentVariant : variant ,
currentSeed : seed ,
currentCandidateOrdinal : 0 ,
executionAttempt : 1 ,
completedAttemptSummaries : [ ] ,
executions : [ ] ,
candidateLimit : searchPlan . candidateLimit ,
2026-08-11 21:51:07 +09:00
candidateBatchSize : PATCH _SEARCH _BATCH _SIZE ,
allCandidatePlan : searchPlan . plan ,
candidatePlan : searchPlan . plan . slice ( 0 , PATCH _SEARCH _BATCH _SIZE ) ,
nextCandidateIndex : Math . min ( PATCH _SEARCH _BATCH _SIZE , searchPlan . plan . length ) ,
candidateBatchOrdinal : 1 ,
2026-08-10 13:59:33 +09:00
estimatedTileCount : searchPlan . estimatedTileCount ,
includeSeamVisualization : state . showSeamDiagnostics ,
selectionRect : validation . rect ,
startedAt : patchStartedAt ,
} ;
activePatchOperation = operation ;
2026-08-08 17:41:30 +09:00
state . patchBusy = true ;
state . patchBusyVariant = variant ;
state . patchStatusMessage = "" ;
updatePatchControls ( ) ;
2026-08-11 21:51:07 +09:00
setProgressVisible ( true , ` Generating quality batch 1 ( ${ operation . candidatePlan . length } lightweight drafts, up to ${ searchPlan . plan . length } candidates); only fully finalized candidates can be previewed... ` ) ;
2026-05-28 19:37:28 +09:00
await nextFrame ( ) ;
try {
2026-08-10 13:59:33 +09:00
if ( ! isPatchOperationCurrent ( operation ) ) return ;
2026-08-08 17:41:30 +09:00
const job = await generatePatchPreviewWorld ( baseWorld , validation . rect , {
terrainType ,
patchMode ,
seed ,
variant ,
2026-08-11 21:51:07 +09:00
// Three lightweight drafts are ranked first. Only the top draft normally
// runs administration, final transport, merge repair, seam audit, delta,
// and hash construction. Structural/invariant failures remain fatal.
2026-08-08 17:41:30 +09:00
maxQualityRetries : 0 ,
qualityTerrainAttempts : 1 ,
2026-08-10 13:59:33 +09:00
acceptBestAvailableQuality : false ,
includeSeamVisualization : operation . includeSeamVisualization ,
} , operation ) ;
if ( ! isPatchOperationCurrent ( operation ) ) return ;
2026-05-29 22:00:42 +09:00
const result = job . result ;
2026-08-10 13:59:33 +09:00
const searchAttempts = Array . isArray ( result ? . searchAttempts ) ? result . searchAttempts : [ ] ;
const lastAttempt = searchAttempts [ searchAttempts . length - 1 ] || null ;
const actualVariant = Number . isFinite ( job . actualVariant )
? job . actualVariant
: Number . isFinite ( lastAttempt ? . variant ) ? lastAttempt . variant : ( Number . isFinite ( result ? . variant ) ? result . variant : variant ) ;
const actualSeed = Number . isFinite ( job . actualSeed )
? job . actualSeed
: Number . isFinite ( lastAttempt ? . seed ) ? lastAttempt . seed : ( Number . isFinite ( result ? . seed ) ? result . seed : seed ) ;
consumePatchSearchAttempts ( operation . searchContextId , searchAttempts . filter ( ( attempt ) => attempt ? . status === "rejected" ) ) ;
if ( Number . isFinite ( result ? . nextVariant ) ) patchSearchSeries . nextVariant = result . nextVariant >>> 0 ;
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 ;
2026-08-10 13:59:33 +09:00
const rejectedVariants = searchAttempts . filter ( ( attempt ) => attempt . status === "rejected" ) . map ( ( attempt ) => attempt . variant ) ;
const attemptText = rejectedVariants . length ? ` Tried variants ${ rejectedVariants . join ( ", " ) } . ` : "" ;
2026-08-08 17:41:30 +09:00
state . patchStatusMessage = showing == null
2026-08-10 13:59:33 +09:00
? ` ${ reason } ; no preview was applied. ${ attemptText } `
: ` ${ reason } ; still showing preview variant ${ showing } . ${ attemptText } ` ;
const runStatus = result . searchStatus === "exhausted" ? "search-exhausted"
: result . searchStatus === "invariant-breach" ? "invariant-breach"
: result . searchStatus === "execution-error" ? "execution-error" : "rejected" ;
recordPatchRun ( result , terrainType , validation . rect , actualVariant , {
kind , worker : job . worker , wallMs : performance . now ( ) - patchStartedAt , status : runStatus , seed : actualSeed ,
requestedVariant : variant , workerStartCount : job . workerStartCount ,
candidateOrdinal : lastAttempt ? . candidateOrdinal || 0 , candidateCount : searchPlan . plan . length ,
workerEpoch : job . workerEpoch , committedRevision : operation . committedRevision ,
inputDispatchMs : job . dispatchMs , inputMirrorReused : job . mirrorReused , estimatedTileCount : operation . estimatedTileCount ,
} ) ;
recordDiagnosticLog ( "warning" , "Patch search did not publish a preview" , reason , {
kind , terrainType , variant : actualVariant , requestedVariant : variant , seed : actualSeed ,
searchStatus : result . searchStatus , attempts : searchAttempts ,
} ) ;
finishProgress ( state . patchStatusMessage , result . patchTimings || [ ] , 1800 ) ;
2026-05-28 19:37:28 +09:00
updatePatchControls ( ) ;
return ;
}
2026-08-10 13:59:33 +09:00
// The worker already owns both the immutable baseline and accepted world,
// so it performs the complete raster/feature comparison before transfer.
// Keep the local path only for backward-compatible worker results.
const previewDelta = result . previewDelta || previewPatchDelta ( baseWorld , job . world , result . rects || validation . rect ) || {
changedCells : 0 , terrainChangedCells : 0 , adminChangedCells : 0 , featureLayersChanged : 0 , identical : true ,
} ;
2026-08-08 17:41:30 +09:00
result . previewDelta = previewDelta ;
2026-08-10 13:59:33 +09:00
result . baseCommittedRevision = operation . committedRevision ;
result . publicationStatus = "staged" ;
const stagedPatch = {
world : job . world , result , rect : validation . rect , terrainType , seed : actualSeed , variant : actualVariant ,
requestedVariant : variant , worker : job . worker , previewDelta , operationId : requestId ,
baseCommittedRevision : operation . committedRevision ,
} ;
if ( progressStageEl ) progressStageEl . textContent = ` Rendering preview variant ${ actualVariant } ... ` ;
2026-08-08 17:41:30 +09:00
const renderStartedAt = performance . now ( ) ;
2026-08-10 13:59:33 +09:00
const stagedRenderBundle = await stagePreviewRenderBundle ( job . world , actualVariant , operation ) ;
if ( ! stagedRenderBundle || ! isPatchOperationCurrent ( operation ) ) return ;
publishPreviewRenderBundle ( stagedRenderBundle , stagedPatch ) ;
result . publicationStatus = "published" ;
operation . searchStatus = "published" ;
2026-08-08 17:41:30 +09:00
const renderMs = performance . now ( ) - renderStartedAt ;
2026-08-10 13:59:33 +09:00
consumePatchSearchAttempts ( operation . searchContextId , searchAttempts ) ;
patchSearchSeries . nextVariant = Number . isFinite ( result . nextVariant ) ? result . nextVariant >>> 0 : ( ( actualVariant + 1 ) >>> 0 ) ;
setPatchVariant ( actualVariant , { update : false } ) ;
recordPatchRun ( result , terrainType , validation . rect , actualVariant , {
kind , worker : job . worker , wallMs : performance . now ( ) - patchStartedAt , renderMs , status : "success" , seed : actualSeed ,
requestedVariant : variant , workerStartCount : job . workerStartCount ,
candidateOrdinal : result . candidateOrdinal , candidateCount : result . candidateCount ,
workerEpoch : job . workerEpoch , committedRevision : operation . committedRevision ,
inputDispatchMs : job . dispatchMs , inputMirrorReused : job . mirrorReused , estimatedTileCount : operation . estimatedTileCount ,
} ) ;
state . patchStatusMessage = ! previewDelta . identical
2026-08-11 21:51:07 +09:00
? ` Highest-quality candidate ${ result . candidateOrdinal || 1 } / ${ result . candidateCount || searchPlan . plan . length } , variant ${ actualVariant } , is displayed (render revision ${ job . world . renderRevision } ); ${ previewDelta . changedCells . toLocaleString ( ) } cells and ${ previewDelta . featureLayersChanged . toLocaleString ( ) } feature layers differ from the committed map. `
2026-08-10 13:59:33 +09:00
: ` Variant ${ actualVariant } completed but is identical to the committed map in the audited change scope. ` ;
2026-05-28 19:37:28 +09:00
updatePatchControls ( ) ;
2026-08-10 13:59:33 +09:00
const modeText = ` ${ result . patchMode || patchMode } / ${ result . patchGenerationMode } ` ;
const quality = result . candidateQuality ;
const qualityText = quality
2026-08-11 21:51:07 +09:00
? ` / quality ${ quality . hardPass ? "PASS" : "FAIL" } ${ Number ( quality . score || 0 ) . toFixed ( 3 ) } / selected terrain variant ${ Number ( quality . selectedVariant || 0 ) } `
2026-08-10 13:59:33 +09:00
: "" ;
const retryText = job . qualityWorkerRetryCount ? ` / quality retries ${ job . qualityWorkerRetryCount } ` : "" ;
2026-08-11 21:51:07 +09:00
const searchText = ` / best candidate ${ result . candidateOrdinal || 1 } / ${ result . candidateCount || searchPlan . plan . length } / evaluated ${ searchAttempts . length || 1 } ` ;
2026-08-10 13:59:33 +09:00
finishProgress ( ` Preview variant ${ actualVariant } displayed ${ job . worker ? " from worker" : "" } : ${ previewDelta . changedCells . toLocaleString ( ) } changed cells + ${ previewDelta . featureLayersChanged . toLocaleString ( ) } changed feature layers / end-to-end ${ formatMs ( performance . now ( ) - patchStartedAt ) } / dispatch ${ formatMs ( job . dispatchMs || 0 ) } / render ${ formatMs ( renderMs ) } ${ searchText } ${ retryText } ${ qualityText } / ${ modeText } . ` , result . patchTimings || [ ] , 1400 ) ;
2026-05-28 19:37:28 +09:00
} catch ( error ) {
2026-08-08 17:41:30 +09:00
if ( error ? . name === "AbortError" ) {
2026-08-10 13:59:33 +09:00
if ( requestId === patchRequestSeq ) {
state . patchStatusMessage = "Generation cancelled." ;
recordPatchRun ( { ok : false , reason : error . message || "cancelled" , patchMode } , terrainType , validation . rect , operation . currentVariant ? ? variant , {
kind , worker : true , wallMs : performance . now ( ) - patchStartedAt , status : "cancelled" , seed : operation . currentSeed ? ? seed ,
requestedVariant : variant ,
candidateOrdinal : operation . currentCandidateOrdinal , candidateCount : operation . candidatePlan . length ,
workerEpoch : operation . workerEpoch , committedRevision : operation . committedRevision ,
estimatedTileCount : operation . estimatedTileCount ,
} ) ;
updatePatchControls ( ) ;
finishProgress ( state . patchStatusMessage , null , 1200 ) ;
}
2026-08-08 17:41:30 +09:00
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-08-10 13:59:33 +09:00
recordPatchRun ( { ok : false , reason , patchMode } , terrainType , validation . rect , operation . currentVariant ? ? variant , {
kind , worker : true , wallMs : performance . now ( ) - patchStartedAt , status : "error" , seed : operation . currentSeed ? ? seed ,
requestedVariant : variant ,
candidateOrdinal : operation . currentCandidateOrdinal , candidateCount : operation . candidatePlan . length ,
workerEpoch : operation . workerEpoch , committedRevision : operation . committedRevision ,
estimatedTileCount : operation . estimatedTileCount ,
} ) ;
2026-05-29 23:49:02 +09:00
recordDiagnosticLog ( "error" , "Patch exception" , reason , { kind , terrainType , variant } ) ;
2026-08-10 13:59:33 +09:00
finishProgress ( state . patchStatusMessage , null , 1800 ) ;
2026-08-08 17:41:30 +09:00
console . error ( "Patch generation failed" , error ) ;
} finally {
if ( requestId === patchRequestSeq ) {
state . patchBusy = false ;
state . patchBusyVariant = null ;
2026-08-10 13:59:33 +09:00
if ( activePatchOperation === operation ) activePatchOperation = null ;
2026-08-08 17:41:30 +09:00
updatePatchControls ( ) ;
}
2026-05-28 19:37:28 +09:00
}
}
2026-05-28 23:51:55 +09:00
async function generateAlternativePatch ( ) {
2026-08-10 13:59:33 +09:00
if ( state . patchBusy || state . fullGenerationBusy ) return ;
2026-05-28 23:51:55 +09:00
const validation = validatePatchRect ( state . selectionRect , state . world ) ;
if ( ! validation . ok ) {
updatePatchControls ( ) ;
return ;
}
2026-08-10 13:59:33 +09:00
const nextVariant = Number . isFinite ( patchSearchSeries . nextVariant )
? patchSearchSeries . nextVariant >>> 0
: ( readPatchVariant ( ) + 1 ) >>> 0 ;
setPatchVariant ( nextVariant , { update : false } ) ;
2026-05-29 23:49:02 +09:00
await generateSelectedPatch ( "Patch alternative" ) ;
2026-05-28 23:51:55 +09:00
}
2026-08-10 13:59:33 +09:00
function previewRenderOptions ( ) {
return {
mode : state . mode ,
showFeatures : state . showFeatures ,
showLabels : state . showLabels ,
showSeamDiagnostics : state . showSeamDiagnostics ,
continuousTerrain : true ,
fastTerrain : false ,
zoom : state . zoom || 1 ,
} ;
}
async function stagePreviewRenderBundle ( world , variant , operation ) {
const startedAt = performance . now ( ) ;
const viewSize = viewportSizeForZoom ( state . zoom ) ;
const camera = clampCameraForView ( state . camera , viewSize , world ) ;
await nextFrame ( ) ;
if ( ! isPatchOperationCurrent ( operation ) ) return null ;
const viewportStartedAt = performance . now ( ) ;
const viewportMap = getViewportMap ( world , camera , viewSize . width , viewSize . height , { light : false } ) ;
const viewportMs = performance . now ( ) - viewportStartedAt ;
await nextFrame ( ) ;
if ( ! isPatchOperationCurrent ( operation ) ) return null ;
const hoverStartedAt = performance . now ( ) ;
const hoverEntities = buildHoverEntities ( viewportMap ) ;
const hoverMs = performance . now ( ) - hoverStartedAt ;
const frame = document . createElement ( "canvas" ) ;
const drawStartedAt = performance . now ( ) ;
const renderBreakdown = await drawMapCooperative ( frame , viewportMap , previewRenderOptions ( ) , {
yieldControl : yieldPatchMainThread ,
shouldCancel : ( ) => ! isPatchOperationCurrent ( operation ) ,
} ) ;
const drawMs = performance . now ( ) - drawStartedAt ;
await nextFrame ( ) ;
if ( ! isPatchOperationCurrent ( operation ) ) return null ;
return {
world ,
variant ,
revision : state . renderRevision + 1 ,
viewSize ,
camera ,
viewportMap ,
hoverEntities ,
frame ,
timings : {
viewportMs ,
hoverMs ,
drawMs ,
totalRenderMs : performance . now ( ) - startedAt ,
renderBreakdown ,
} ,
} ;
}
function publishPreviewRenderBundle ( bundle , stagedPatch ) {
const previous = {
pendingPatch : state . pendingPatch ,
viewportMap : state . viewportMap ,
hoverEntities : state . hoverEntities ,
renderRevision : state . renderRevision ,
camera : state . camera ,
viewWidth : state . viewWidth ,
viewHeight : state . viewHeight ,
patchStatusMessage : state . patchStatusMessage ,
diagnostics : state . diagnostics . lastViewport ,
featureCounts : state . diagnostics . lastFeatureCounts ,
} ;
try {
bundle . world . renderRevision = bundle . revision ;
bundle . world . previewVariant = bundle . variant ;
state . pendingPatch = stagedPatch ;
state . viewportMap = bundle . viewportMap ;
state . hoverEntities = bundle . hoverEntities ;
state . renderRevision = bundle . revision ;
state . camera = bundle . camera ;
state . viewWidth = bundle . viewSize . width ;
state . viewHeight = bundle . viewSize . height ;
const visibleContext = canvas . getContext ( "2d" ) ;
if ( ! visibleContext ) throw new Error ( "Visible canvas context is unavailable." ) ;
canvas . width = bundle . frame . width ;
canvas . height = bundle . frame . height ;
visibleContext . drawImage ( bundle . frame , 0 , 0 ) ;
applyCanvasZoom ( ) ;
updateRenderDiagnostics ( { fastTerrain : false } , bundle . timings ) ;
renderStats ( displaySourceMap ( ) ) ;
renderAdvancedData ( ) ;
updatePatchControls ( ) ;
return previous ;
} catch ( error ) {
state . pendingPatch = previous . pendingPatch ;
state . viewportMap = previous . viewportMap ;
state . hoverEntities = previous . hoverEntities ;
state . renderRevision = previous . renderRevision ;
state . camera = previous . camera ;
state . viewWidth = previous . viewWidth ;
state . viewHeight = previous . viewHeight ;
state . patchStatusMessage = previous . patchStatusMessage ;
state . diagnostics . lastViewport = previous . diagnostics ;
state . diagnostics . lastFeatureCounts = previous . featureCounts ;
// Rebuild the old frame from the restored state only on the exceptional
// path. Keeping a full-size rollback canvas during every successful preview
// doubled peak raster memory for no user-visible benefit.
try {
redraw ( { allowWorldExpand : false } ) ;
updatePatchControls ( ) ;
} catch {
// Preserve the original publication error; state has already rolled back.
}
throw error ;
}
}
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.
2026-08-10 13:59:33 +09:00
const mayExpand = ! state . pendingPatch && ! state . patchBusy && ! state . fullGenerationBusy && 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-08-10 13:59:33 +09:00
advanceCommittedRevision ( ) ;
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 ) {
2026-08-10 13:59:33 +09:00
replaceSelectionRect ( {
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-08-10 13:59:33 +09:00
} ) ;
2026-05-28 23:51:55 +09:00
}
2026-08-10 13:59:33 +09:00
state . lastPatchResult = state . world ? . lastPatchResult || null ;
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
}
2026-08-10 13:59:33 +09:00
function canvasDigestForE2E ( ) {
const context = canvas ? . getContext ? . ( "2d" , { willReadFrequently : true } ) ;
if ( ! context || ! canvas . width || ! canvas . height ) return null ;
const bytes = context . getImageData ( 0 , 0 , canvas . width , canvas . height ) . data ;
let hash = 2166136261 >>> 0 ;
// This path exists only behind ?additionalGenerationE2E=1. Hash every pixel
// byte so atomic-publication tests cannot miss a localized stale/partial
// render because of sampling stride aliasing.
for ( let index = 0 ; index < bytes . length ; index ++ ) {
hash ^= bytes [ index ] ;
hash = Math . imul ( hash , 16777619 ) >>> 0 ;
}
return ` ${ canvas . width } x ${ canvas . height } : ${ hash . toString ( 16 ) . padStart ( 8 , "0" ) } ` ;
}
function installAdditionalGenerationE2EHarness ( ) {
if ( new URLSearchParams ( location . search ) . get ( "additionalGenerationE2E" ) !== "1" ) return ;
window . _ _additionalGenerationE2EProgress = [ ] ;
window . _ _additionalGenerationE2ECancelTiming = null ;
const snapshot = ( ) => ( {
ready : ! ! state . world && ! state . fullGenerationBusy ,
worldWidth : state . world ? . width || 0 ,
worldHeight : state . world ? . height || 0 ,
originX : state . world ? . originX || 0 ,
originY : state . world ? . originY || 0 ,
fullGenerationBusy : state . fullGenerationBusy ,
patchBusy : state . patchBusy ,
pending : ! ! state . pendingPatch ,
pendingVariant : state . pendingPatch ? . variant ? ? null ,
pendingSeed : state . pendingPatch ? . seed ? ? null ,
publicationStatus : state . pendingPatch ? . result ? . publicationStatus || null ,
searchStatus : state . pendingPatch ? . result ? . searchStatus || null ,
acceptedWorldHash : state . pendingPatch ? . result ? . acceptedWorldHash || null ,
committedRevision : state . committedRevision ,
workerMirrorRevision : patchWorker ? . _ _mirrorCommittedRevision ? ? null ,
patchStatus : state . patchStatusMessage ,
progressText : progressStageEl ? . textContent || "" ,
statsText : statsEl ? . textContent || "" ,
diagnosticsText : advancedPatchDiagnosticsEl ? . textContent || "" ,
seamText : advancedSeamDiagnosticsEl ? . textContent || "" ,
canvasDigest : canvasDigestForE2E ( ) ,
lastRun : state . patchRuns [ state . patchRuns . length - 1 ] || null ,
progressEvents : [ ... window . _ _additionalGenerationE2EProgress ] ,
} ) ;
window . _ _additionalGenerationE2E = {
snapshot ,
configure ( { rect , patchMode = "regeneration" , terrainType = "auto" , variant = 0 , seamDiagnostics = false } = { } ) {
if ( ! state . world || state . fullGenerationBusy || state . patchBusy ) throw new Error ( "Application is not ready for patch configuration." ) ;
discardPendingPatch ( { redrawAfter : false } ) ;
window . _ _additionalGenerationE2EProgress . length = 0 ;
window . _ _additionalGenerationE2ECancelTiming = null ;
patchModeInput . value = patchMode ;
patchTerrainTypeInput . value = terrainType ;
showSeamDiagnosticsInput . checked = seamDiagnostics ;
state . showSeamDiagnostics = seamDiagnostics ;
setPatchVariant ( variant , { update : false } ) ;
replaceSelectionRect ( rect ) ;
updatePatchControls ( ) ;
return snapshot ( ) ;
} ,
async generate ( ) {
await generateSelectedPatch ( "Browser E2E preview" ) ;
return snapshot ( ) ;
} ,
async alternative ( ) {
await generateAlternativePatch ( ) ;
return snapshot ( ) ;
} ,
cancel ( ) {
cancelPatchGeneration ( { reason : "Browser E2E cancellation." } ) ;
return snapshot ( ) ;
} ,
async apply ( ) {
if ( ! commitPendingPatch ( { redrawAfter : true } ) ) throw new Error ( "No preview is available to Apply." ) ;
const deadline = performance . now ( ) + 30_000 ;
while ( patchWorker && patchWorker . _ _mirrorCommittedRevision !== state . committedRevision && performance . now ( ) < deadline ) {
await new Promise ( ( resolve ) => window . setTimeout ( resolve , 25 ) ) ;
}
return snapshot ( ) ;
} ,
discard ( ) {
discardPendingPatch ( { redrawAfter : true } ) ;
return snapshot ( ) ;
} ,
} ;
}
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-08-10 13:59:33 +09:00
if ( state . patchBusy ) cancelPatchGeneration ( { reason : "Patch generation cancelled because Terrain changed." } ) ;
if ( state . fullGenerationBusy ) return ;
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" , ( ) => {
2026-08-10 13:59:33 +09:00
if ( state . patchBusy ) cancelPatchGeneration ( { reason : "Patch generation cancelled because Mode changed." } ) ;
if ( state . fullGenerationBusy ) return ;
2026-08-08 17:41:30 +09:00
discardPendingPatch ( { redrawAfter : true } ) ;
state . lastPatchResult = null ;
state . patchStatusMessage = "" ;
updatePatchControls ( ) ;
renderAdvancedData ( ) ;
} ) ;
patchVariantInput ? . addEventListener ( "change" , ( ) => {
2026-08-10 13:59:33 +09:00
if ( state . patchBusy ) cancelPatchGeneration ( { reason : "Patch generation cancelled because Variant changed." } ) ;
if ( state . fullGenerationBusy ) return ;
discardPendingPatch ( { redrawAfter : true } ) ;
2026-08-08 17:41:30 +09:00
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 } ) ) ;
2026-08-10 13:59:33 +09:00
cancelPatchButton ? . addEventListener ( "click" , ( event ) => {
const handlerStartedAt = performance . now ( ) ;
const inputTimestamp = Number . isFinite ( Number ( event ? . timeStamp ) ) ? Number ( event . timeStamp ) : handlerStartedAt ;
const cancelled = cancelPatchGeneration ( ) ;
const cancelledAt = performance . now ( ) ;
if ( Object . prototype . hasOwnProperty . call ( window , "__additionalGenerationE2ECancelTiming" ) ) {
window . _ _additionalGenerationE2ECancelTiming = {
isTrusted : event ? . isTrusted === true ,
inputTimestamp ,
handlerStartedAt ,
cancelledAt ,
inputToHandlerMs : Math . max ( 0 , handlerStartedAt - inputTimestamp ) ,
handlerToCancelledMs : Math . max ( 0 , cancelledAt - handlerStartedAt ) ,
inputToCancelledMs : Math . max ( 0 , cancelledAt - inputTimestamp ) ,
cancelled ,
patchBusyAfter : state . patchBusy ,
} ;
}
} ) ;
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" , ( ) => {
2026-08-10 13:59:33 +09:00
if ( state . patchBusy ) cancelPatchGeneration ( { reason : "Patch generation cancelled because seam visualization changed." } ) ;
2026-08-08 17:41:30 +09:00
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-08-10 13:59:33 +09:00
installAdditionalGenerationE2EHarness ( ) ;
2026-05-20 13:50:56 +09:00
regenerate ( ) ;
}
init ( ) ;