2026-05-24 22:52:22 +09:00
import { generateMapAsync } from "./mapGenerator.js" ;
2026-05-20 13:50:56 +09:00
import { drawMap } from "./renderer.js" ;
2026-05-24 17:38:51 +09:00
import { landuseLabel } from "./landuseCodes.js" ;
2026-05-28 19:37:28 +09:00
import { CELL _SIZE , MAP _H , MAP _W } 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" ;
import { PATCH _MIN _AREA , PATCH _MIN _HEIGHT , PATCH _MIN _WIDTH , buildPatchRects , generatePatch , validatePatchRect } from "./mapPatch.js" ;
2026-05-20 13:50:56 +09:00
const modes = [
[ "all" , "All" ] ,
[ "terrain" , "Terrain" ] ,
[ "history" , "Premodern" ] ,
[ "modern" , "Modern" ] ,
[ "development" , "Development" ] ,
[ "landuse" , "Land Use" ] ,
[ "admin" , "Municipal Borders" ] ,
2026-05-21 13:20:19 +09:00
[ "borders-debug" , "Borders Debug" ] ,
2026-05-26 21:14:37 +09:00
[ "transport-debug" , "Transport Debug" ] ,
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" ,
showFeatures : true ,
showLabels : true ,
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-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-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-20 13:50:56 +09:00
const randomSeedButton = document . getElementById ( "randomSeed" ) ;
const showFeaturesInput = document . getElementById ( "showFeatures" ) ;
const showLabelsInput = document . getElementById ( "showLabels" ) ;
const modeGrid = document . getElementById ( "modeGrid" ) ;
const statsEl = document . getElementById ( "stats" ) ;
const tooltipEl = document . getElementById ( "mapTooltip" ) ;
2026-05-29 14:31:42 +09:00
const selectionSvgEl = document . getElementById ( "mapSelectionSvg" ) ;
2026-05-28 19:37:28 +09:00
const selectionEl = document . getElementById ( "mapSelection" ) ;
2026-05-24 19:33:09 +09:00
const progressEl = document . getElementById ( "generationProgress" ) ;
const progressStageEl = document . getElementById ( "generationProgressStage" ) ;
const progressTimingsEl = document . getElementById ( "generationProgressTimings" ) ;
2026-05-24 22:52:22 +09:00
let generationStartedAt = 0 ;
let generationCurrentStage = "" ;
let generationTimer = null ;
2026-05-29 14:31:42 +09:00
let zoomRedrawRaf = null ;
let zoomSettledTimer = null ;
2026-05-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 ,
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 ,
} ;
function activeMap ( ) {
return state . viewportMap || state . map ;
}
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 14:31:42 +09:00
function clampCameraForView ( camera , size = viewportSizeForZoom ( state . zoom ) ) {
return clampCameraToWorld ( camera , state . world , size ? . width || MAP _W , size ? . height || MAP _H ) ;
}
function syncViewportSize ( ) {
const size = viewportSizeForZoom ( state . zoom ) ;
state . viewWidth = size . width ;
state . viewHeight = size . height ;
return size ;
}
function mapCellScreenSize ( map = activeMap ( ) ) {
const rect = canvas . getBoundingClientRect ( ) ;
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 ) ;
// Keep the canvas element at a stable size. Zoom is applied inside the
// renderer transform, not by resizing the scrollable shell.
canvas . style . width = ` ${ MAP _W * CELL _SIZE } px ` ;
canvas . style . height = ` ${ MAP _H * CELL _SIZE } px ` ;
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-28 23:51:55 +09:00
const rect = canvas . getBoundingClientRect ( ) ;
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 ) {
const rect = canvas . getBoundingClientRect ( ) ;
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 ) {
const rect = canvas . getBoundingClientRect ( ) ;
return {
x : Math . min ( Math . max ( event . clientX - rect . left , 0 ) , rect . width ) ,
y : Math . min ( Math . max ( event . clientY - rect . top , 0 ) , rect . height ) ,
} ;
}
2026-05-29 14:31:42 +09:00
function screenPointToWorldCell ( point ) {
const map = activeMap ( ) ;
if ( ! map || ! point ) return null ;
const rect = canvas . getBoundingClientRect ( ) ;
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 = [ ] ;
for ( const p of points || [ ] ) {
if ( ! out . length || Math . hypot ( out [ out . length - 1 ] . x - p . x , out [ out . length - 1 ] . y - p . y ) >= 6 ) out . push ( p ) ;
}
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 ;
const polygon = simplified . map ( screenPointToWorldCell ) . filter ( Boolean ) ;
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 ) ) ) ,
} ;
}
function drawSelectionSvg ( points , invalid = false ) {
if ( ! selectionSvgEl ) return ;
if ( ! points || points . length < 3 ) {
selectionSvgEl . style . display = "none" ;
selectionSvgEl . innerHTML = "" ;
return ;
}
const pts = points . map ( ( p ) => ` ${ p . x } , ${ p . y } ` ) . join ( " " ) ;
selectionSvgEl . setAttribute ( "viewBox" , ` 0 0 ${ canvas . clientWidth || canvas . width || 1 } ${ canvas . clientHeight || canvas . height || 1 } ` ) ;
selectionSvgEl . innerHTML = ` <polygon points=" ${ pts } " /> ` ;
selectionSvgEl . style . display = "block" ;
selectionSvgEl . classList . toggle ( "invalid" , ! ! invalid ) ;
}
function hideSelectionSvg ( ) {
if ( ! selectionSvgEl ) return ;
selectionSvgEl . style . display = "none" ;
selectionSvgEl . innerHTML = "" ;
selectionSvgEl . classList . remove ( "invalid" ) ;
}
2026-05-28 19:37:28 +09:00
function updateSelectionOverlay ( ) {
2026-05-29 14:31:42 +09:00
if ( ! dragState . selectPath ? . length ) return ;
const liveShape = selectionPathToShape ( dragState . selectPath ) ;
const validation = validatePatchRect ( liveShape , state . world ) ;
drawSelectionSvg ( dragState . selectPath , ! validation . ok ) ;
if ( selectionEl ) selectionEl . style . display = "none" ;
2026-05-28 19:37:28 +09:00
if ( generatePatchButton ) generatePatchButton . disabled = true ;
2026-05-28 23:51:55 +09:00
if ( alternativePatchButton ) alternativePatchButton . disabled = true ;
2026-05-28 19:37:28 +09:00
if ( patchStatusEl ) {
2026-05-29 14:31:42 +09:00
const current = validation . rect || liveShape ;
2026-05-28 19:37:28 +09:00
patchStatusEl . textContent = validation . ok
? ` Selected: ${ formatRectSize ( validation . rect ) } . Release to confirm patch selection. `
: ` ${ validation . reason } Current: ${ formatRectSize ( current ) } . ` ;
patchStatusEl . classList . toggle ( "invalid" , ! validation . ok ) ;
}
}
function updateSelectionOverlayFromWorldRect ( ) {
2026-05-29 14:31:42 +09:00
if ( ! state . selectionRect || ! state . camera || ! activeMap ( ) ) return ;
2026-05-28 19:37:28 +09:00
const rect = canvas . getBoundingClientRect ( ) ;
if ( ! rect . width || ! rect . height ) return ;
2026-05-29 14:31:42 +09:00
if ( Array . isArray ( state . selectionRect . polygon ) && state . selectionRect . polygon . length >= 3 ) {
const points = state . selectionRect . polygon . map ( worldCellToOverlayPoint )
. map ( ( p ) => ( { x : Math . min ( Math . max ( p . x , 0 ) , rect . width ) , y : Math . min ( Math . max ( p . y , 0 ) , rect . height ) } ) ) ;
drawSelectionSvg ( points , ! validatePatchRect ( state . selectionRect , state . world ) . ok ) ;
if ( selectionEl ) selectionEl . style . display = "none" ;
return ;
}
2026-05-28 19:37:28 +09:00
const cameraX = Math . round ( state . camera . x || 0 ) ;
const cameraY = Math . round ( state . camera . y || 0 ) ;
2026-05-28 23:51:55 +09:00
const p0 = mapPixelToScreenPoint ( ( state . selectionRect . x0 - cameraX ) * CELL _SIZE , ( state . selectionRect . y0 - cameraY ) * CELL _SIZE ) ;
const p1 = mapPixelToScreenPoint ( ( state . selectionRect . x1 - cameraX ) * CELL _SIZE , ( state . selectionRect . y1 - cameraY ) * CELL _SIZE ) ;
const vx0 = p0 . x - canvas . offsetLeft ;
const vy0 = p0 . y - canvas . offsetTop ;
const vx1 = p1 . x - canvas . offsetLeft ;
const vy1 = p1 . y - canvas . offsetTop ;
2026-05-28 19:37:28 +09:00
const x0 = Math . min ( Math . max ( Math . min ( vx0 , vx1 ) , 0 ) , rect . width ) ;
const y0 = Math . min ( Math . max ( Math . min ( vy0 , vy1 ) , 0 ) , rect . height ) ;
const x1 = Math . min ( Math . max ( Math . max ( vx0 , vx1 ) , 0 ) , rect . width ) ;
const y1 = Math . min ( Math . max ( Math . max ( vy0 , vy1 ) , 0 ) , rect . height ) ;
if ( x1 - x0 < 1 || y1 - y0 < 1 ) {
selectionEl . style . display = "none" ;
2026-05-28 00:30:09 +09:00
return ;
}
2026-05-29 14:31:42 +09:00
hideSelectionSvg ( ) ;
2026-05-28 19:37:28 +09:00
selectionEl . style . display = "block" ;
selectionEl . style . left = ` ${ canvas . offsetLeft + x0 } px ` ;
selectionEl . style . top = ` ${ canvas . offsetTop + y0 } px ` ;
selectionEl . style . width = ` ${ Math . max ( 1 , x1 - x0 ) } px ` ;
selectionEl . style . height = ` ${ Math . max ( 1 , y1 - y0 ) } px ` ;
const validation = validatePatchRect ( state . selectionRect , state . world ) ;
selectionEl . classList . toggle ( "invalid" , ! validation . ok ) ;
}
function formatRectSize ( rect ) {
if ( ! rect ) return "-" ;
const w = Math . max ( 0 , rect . x1 - rect . x0 ) ;
const h = Math . max ( 0 , rect . y1 - rect . y0 ) ;
2026-05-29 14:31:42 +09:00
const area = Math . max ( 0 , rect . areaCells || ( w * h ) ) ;
return ` ${ w } x ${ h } cells / ${ area . toLocaleString ( ) } cells ` ;
2026-05-28 19:37:28 +09:00
}
2026-05-28 23:51:55 +09:00
function normalizePatchVariant ( value ) {
const parsed = Number . parseInt ( value , 10 ) ;
return Number . isFinite ( parsed ) ? Math . max ( 0 , parsed ) >>> 0 : 0 ;
}
function setPatchVariant ( value , { update = true } = { } ) {
state . patchVariant = normalizePatchVariant ( value ) ;
if ( patchVariantInput && patchVariantInput . value !== String ( state . patchVariant ) ) {
patchVariantInput . value = String ( state . patchVariant ) ;
}
if ( update ) updatePatchControls ( ) ;
return state . patchVariant ;
}
function readPatchVariant ( ) {
return setPatchVariant ( patchVariantInput ? . value ? ? state . patchVariant , { update : false } ) ;
}
function resetPatchVariant ( { update = true } = { } ) {
return setPatchVariant ( 0 , { update } ) ;
}
2026-05-28 19:37:28 +09:00
function updatePatchControls ( ) {
if ( ! patchStatusEl && ! generatePatchButton ) return ;
const validation = validatePatchRect ( state . selectionRect , state . world ) ;
2026-05-28 23:51:55 +09:00
const variant = readPatchVariant ( ) ;
2026-05-28 19:37:28 +09:00
if ( generatePatchButton ) generatePatchButton . disabled = ! validation . ok ;
2026-05-28 23:51:55 +09:00
if ( alternativePatchButton ) alternativePatchButton . disabled = ! validation . ok ;
2026-05-28 19:37:28 +09:00
if ( ! patchStatusEl ) return ;
if ( ! state . selectionRect ) {
2026-05-29 14:31:42 +09:00
patchStatusEl . textContent = ` Right-drag a freeform area. Minimum: ${ PATCH _MIN _WIDTH } x ${ PATCH _MIN _HEIGHT } cells and ${ PATCH _MIN _AREA . toLocaleString ( ) } cells. Current variant: ${ variant } . ` ;
2026-05-28 19:37:28 +09:00
patchStatusEl . classList . toggle ( "invalid" , false ) ;
return ;
}
if ( ! validation . ok ) {
patchStatusEl . textContent = ` ${ validation . reason } Current: ${ formatRectSize ( validation . rect || state . selectionRect ) } . ` ;
patchStatusEl . classList . toggle ( "invalid" , true ) ;
return ;
2026-05-28 00:30:09 +09:00
}
2026-05-28 19:37:28 +09:00
const rects = buildPatchRects ( validation . rect , state . world ) ;
const patchText = state . lastPatchResult
2026-05-29 14:31:42 +09:00
? ` Last patch: ${ state . lastPatchResult . label } , variant ${ state . lastPatchResult . variant ? ? "-" } , mode ${ state . lastPatchResult . patchGenerationMode || "legacy-full-pipeline" } , terrain ${ state . lastPatchResult . updatedCells . toLocaleString ( ) } cells, coast ${ state . lastPatchResult . coastCellsChanged || 0 } , natural ${ state . lastPatchResult . naturalRegionsUpdated || 0 } ${ state . lastPatchResult . humanGeography ? . ok ? ` , connectors ${ ( state . lastPatchResult . humanGeography . roadConnectorsCreated || 0 ) + ( state . lastPatchResult . humanGeography . railwayConnectorsCreated || 0 ) } , admin ${ state . lastPatchResult . humanGeography . adminCellsReassigned || 0 } , invalid ports ${ state . lastPatchResult . humanGeography . invalidPortsRemoved || 0 } ` : "" } . `
2026-05-28 19:37:28 +09:00
: "" ;
2026-05-28 23:51:55 +09:00
patchStatusEl . textContent = ` Variant: ${ variant } . Core: ${ formatRectSize ( rects . coreRect ) } . Write: ${ formatRectSize ( rects . writeRect ) } . Context: ${ formatRectSize ( rects . contextRect ) } . Repair: ${ formatRectSize ( rects . repairRect ) } . ${ patchText } ` ;
2026-05-28 19:37:28 +09:00
patchStatusEl . classList . toggle ( "invalid" , false ) ;
}
function clearDragMode ( ) {
dragState . mode = null ;
dragState . pointerId = null ;
dragState . pendingCamera = null ;
if ( dragState . panRaf != null ) {
cancelAnimationFrame ( dragState . panRaf ) ;
dragState . panRaf = null ;
}
canvasShell ? . classList . remove ( "panning" , "selecting" ) ;
}
function schedulePanRedraw ( camera ) {
dragState . pendingCamera = camera ;
if ( dragState . panRaf != null ) return ;
dragState . panRaf = requestAnimationFrame ( ( ) => {
dragState . panRaf = null ;
if ( ! dragState . pendingCamera ) return ;
const next = dragState . pendingCamera ;
dragState . pendingCamera = null ;
if ( next . x === state . camera . x && next . y === state . camera . y ) return ;
state . camera = next ;
redraw ( { fastTerrain : true } ) ;
} ) ;
}
function hideSelectionOverlay ( ) {
dragState . selectStart = null ;
dragState . selectEnd = null ;
2026-05-29 14:31:42 +09:00
dragState . selectPath = null ;
2026-05-28 19:37:28 +09:00
state . selectionRect = null ;
2026-05-28 23:51:55 +09:00
resetPatchVariant ( { update : false } ) ;
2026-05-29 14:31:42 +09:00
hideSelectionSvg ( ) ;
2026-05-28 19:37:28 +09:00
if ( selectionEl ) selectionEl . style . display = "none" ;
updatePatchControls ( ) ;
2026-05-28 00:30:09 +09:00
}
2026-05-28 19:37:28 +09:00
function selectionPixelsToCells ( start , end ) {
2026-05-29 14:31:42 +09:00
const a = screenPointToWorldCell ( start ) ;
const b = screenPointToWorldCell ( end ) ;
if ( ! a || ! b ) return null ;
2026-05-28 19:37:28 +09:00
return {
2026-05-29 14:31:42 +09:00
x0 : Math . min ( a . x , b . x ) ,
y0 : Math . min ( a . y , b . y ) ,
x1 : Math . max ( a . x , b . x ) + 1 ,
y1 : Math . max ( a . y , b . y ) + 1 ,
2026-05-28 19:37:28 +09:00
} ;
2026-05-28 00:30:09 +09:00
}
2026-05-29 14:31:42 +09:00
function selectionPixelsToShape ( start , end , path = null ) {
if ( Array . isArray ( path ) && path . length >= 3 ) return selectionPathToShape ( path ) ;
return selectionPixelsToCells ( start , end ) ;
}
2026-05-28 19:37:28 +09:00
function handleMapPointerDown ( event ) {
if ( ! state . world || ! canvasShell ) return ;
if ( event . button !== 0 && event . button !== 2 ) return ;
dragState . pointerId = event . pointerId ;
dragState . startClientX = event . clientX ;
dragState . startClientY = event . clientY ;
dragState . startCameraX = state . camera . x ;
dragState . startCameraY = state . camera . y ;
tooltipEl ? . classList . remove ( "visible" ) ;
if ( event . button === 0 ) {
dragState . mode = "pan" ;
canvasShell . classList . add ( "panning" ) ;
} else {
dragState . mode = "select" ;
dragState . selectStart = clampCanvasPoint ( event ) ;
dragState . selectEnd = dragState . selectStart ;
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-28 23:51:55 +09:00
const cellSize = Math . max ( 1 , displayedCellSize ( ) ) ;
const dxCells = Math . round ( ( event . clientX - dragState . startClientX ) / cellSize ) ;
const dyCells = Math . round ( ( event . clientY - dragState . startClientY ) / cellSize ) ;
2026-05-29 14:31:42 +09:00
const nextCamera = clampCameraForView ( {
2026-05-28 19:37:28 +09:00
x : dragState . startCameraX - dxCells ,
y : dragState . startCameraY - dyCells ,
2026-05-29 14:31:42 +09:00
} , viewportSizeForZoom ( state . zoom ) ) ;
2026-05-28 19:37:28 +09:00
schedulePanRedraw ( nextCamera ) ;
} else if ( dragState . mode === "select" ) {
dragState . selectEnd = clampCanvasPoint ( event ) ;
2026-05-29 14:31:42 +09:00
if ( ! dragState . selectPath || Math . hypot ( dragState . selectEnd . x - dragState . selectPath [ dragState . selectPath . length - 1 ] . x , dragState . selectEnd . y - dragState . selectPath [ dragState . selectPath . length - 1 ] . y ) >= 3 ) {
dragState . selectPath = [ ... ( dragState . selectPath || [ ] ) , dragState . selectEnd ] ;
}
2026-05-28 19:37:28 +09:00
updateSelectionOverlay ( ) ;
}
event . preventDefault ( ) ;
}
function handleMapPointerUp ( event ) {
if ( dragState . pointerId !== event . pointerId ) return ;
const wasPanning = dragState . mode === "pan" ;
if ( dragState . mode === "select" ) {
dragState . selectEnd = clampCanvasPoint ( event ) ;
2026-05-29 14:31:42 +09:00
if ( ! dragState . selectPath || dragState . selectPath . length < 2 ) dragState . selectPath = [ dragState . selectStart , dragState . selectEnd ] ;
else dragState . selectPath = [ ... dragState . selectPath , dragState . selectEnd ] ;
const shape = selectionPixelsToShape ( dragState . selectStart , dragState . selectEnd , dragState . selectPath ) ;
2026-05-28 19:37:28 +09:00
const width = Math . abs ( dragState . selectEnd . x - dragState . selectStart . x ) ;
const height = Math . abs ( dragState . selectEnd . y - dragState . selectStart . y ) ;
2026-05-29 14:31:42 +09:00
if ( shape && ( dragState . selectPath . length >= 3 || ( width >= 4 && height >= 4 ) ) ) {
state . selectionRect = shape ;
2026-05-28 23:51:55 +09:00
state . lastPatchResult = null ;
resetPatchVariant ( { update : false } ) ;
2026-05-28 19:37:28 +09:00
updateSelectionOverlayFromWorldRect ( ) ;
updatePatchControls ( ) ;
} else {
hideSelectionOverlay ( ) ;
}
}
canvas . releasePointerCapture ? . ( event . pointerId ) ;
if ( wasPanning && dragState . pendingCamera ) {
state . camera = dragState . pendingCamera ;
dragState . pendingCamera = null ;
}
clearDragMode ( ) ;
if ( wasPanning ) redraw ( { fastTerrain : 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 ;
}
function insideCount ( items ) {
return items . filter ( ( item ) => item . insidePrefecture ) . length ;
}
function outsideCount ( items ) {
return items . length - insideCount ( items ) ;
}
function countText ( items ) {
return ` ${ insideCount ( items ) } / outside ${ outsideCount ( items ) } ` ;
}
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 ` ;
}
function renderTimingRows ( timings = [ ] ) {
if ( ! progressTimingsEl ) return ;
progressTimingsEl . innerHTML = "" ;
for ( const row of timings ) {
const item = document . createElement ( "div" ) ;
item . className = "progress-timing-row" ;
const label = document . createElement ( "span" ) ;
label . textContent = row . label ;
const value = document . createElement ( "strong" ) ;
value . textContent = formatMs ( row . ms ) ;
item . append ( label , value ) ;
progressTimingsEl . append ( item ) ;
}
}
function updateGenerationProgress ( event ) {
if ( ! progressEl ) return ;
progressEl . classList . remove ( "hidden" ) ;
2026-05-24 22:52:22 +09:00
if ( event ? . status === "start" ) generationCurrentStage = event . label || "Preparing" ;
2026-05-24 19:33:09 +09:00
if ( progressStageEl ) {
2026-05-24 22:52:22 +09:00
const elapsed = generationStartedAt ? ` / elapsed ${ formatMs ( performance . now ( ) - generationStartedAt ) } ` : "" ;
2026-05-24 19:33:09 +09:00
progressStageEl . textContent = event ? . status === "done"
2026-05-24 22:52:22 +09:00
? ` Completed: ${ event . label } / ${ formatMs ( event . ms ) } ${ elapsed } `
: ` Running: ${ event ? . label || generationCurrentStage || "Preparing" } ${ elapsed } ` ;
2026-05-24 19:33:09 +09:00
}
renderTimingRows ( event ? . timings || [ ] ) ;
}
function setProgressVisible ( visible , message = "Preparing" ) {
if ( ! progressEl ) return ;
progressEl . classList . toggle ( "hidden" , ! visible ) ;
2026-05-24 22:52:22 +09:00
if ( visible ) {
generationStartedAt = performance . now ( ) ;
generationCurrentStage = message ;
if ( generationTimer ) window . clearInterval ( generationTimer ) ;
generationTimer = window . setInterval ( ( ) => {
if ( progressStageEl && ! progressEl . classList . contains ( "hidden" ) ) {
progressStageEl . textContent = ` Running: ${ generationCurrentStage || "Preparing" } / elapsed ${ formatMs ( performance . now ( ) - generationStartedAt ) } ` ;
}
} , 100 ) ;
} else if ( generationTimer ) {
window . clearInterval ( generationTimer ) ;
generationTimer = null ;
}
2026-05-24 19:33:09 +09:00
if ( progressStageEl ) progressStageEl . textContent = message ;
if ( visible ) renderTimingRows ( [ ] ) ;
}
function nextFrame ( ) {
return new Promise ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
}
2026-05-20 13:50:56 +09:00
function getStats ( map ) {
return [
2026-05-24 17:38:51 +09:00
[ "Map Type" , map . terrainTemplate ? . terrainTypeLabel || map . terrainDebug ? . terrainTypeLabel || "-" ] ,
[ "Terrain ID" , map . terrainTemplate ? . terrainType || map . terrainDebug ? . terrainType || "-" ] ,
2026-05-24 22:52:22 +09:00
[ "Generation Total" , map . generationTotalMs ? formatMs ( map . generationTotalMs ) : "-" ] ,
2026-05-28 00:30:09 +09:00
[ "Geography Basis" , map . geographyDebug ? . version ? ` ${ map . geographyDebug . version } / ${ map . geographyDebug . stage || "-" } ` : "-" ] ,
2026-05-24 22:52:22 +09:00
... ( map . generationTimings || [ ] ) . map ( ( row ) => [ ` Time: ${ row . label } ` , formatMs ( row . ms ) ] ) ,
2026-05-20 13:50:56 +09:00
[ "Villages" , countText ( map . villages ) ] ,
[ "Market Towns" , countText ( map . markets ) ] ,
[ "Castles" , countText ( map . castles ) ] ,
[ "Premodern Roads" , map . premodernRoads . length ] ,
[ "Minor Roads" , map . minorRoads . length ] ,
2026-05-22 19:28:39 +09:00
[ "Prefecture" , map . prefectureName || "-" ] ,
[ "Neighbor Prefectures" , ( map . neighborPrefectures || [ ] ) . map ( ( p ) => p . name ) . join ( " / " ) || "-" ] ,
[ "Neighbor Features" , map . neighborPrefectureDetails ? ` ${ map . neighborPrefectureDetails . cities ? . length || 0 } cities / ${ map . neighborPrefectureDetails . adminCenters ? . length || 0 } municipalities / ${ map . neighborPrefectureDetails . roads ? . length || 0 } roads ` : "-" ] ,
2026-05-20 13:50:56 +09:00
[ "Prefectural Capital" , map . prefecturalCapital ? . name || "-" ] ,
2026-05-24 17:38:51 +09:00
[ "Regional Capitals" , ( map . modernCities || [ ] ) . filter ( ( p ) => p . isRegionalCapital ) . length ] ,
2026-05-20 13:50:56 +09:00
[ "Modern Cities" , countText ( map . modernCities ) ] ,
[ "Ports" , ` ${ map . ports . filter ( ( p ) => p . portClass === "major" ) . length } major / ${ map . ports . filter ( ( p ) => p . portClass === "regional" ) . length } regional / ${ map . ports . filter ( ( p ) => p . portClass === "fishing" ) . length } fishing / ${ map . ports . filter ( ( p ) => p . portClass === "lake" ) . length } lake ` ] ,
[ "Satellite Cities" , countText ( map . satelliteCities || [ ] ) ] ,
[ "Population" , ( map . totalPopulation || 0 ) . toLocaleString ( ) ] ,
[ "Rivers" , ` ${ map . mainRivers . length } main / ${ ( map . tributaryRivers || [ ] ) . length } tributary / ${ ( map . smallStreams || [ ] ) . length } hidden streams ` ] ,
[ "Neighbor Prefecture Borders" , ( map . regionalPrefectureBorders || [ ] ) . length ] ,
[ "Rail Lines" , map . railways . length + map . branchRailways . length + ( map . ringRailways || [ ] ) . length + map . externalRailways . length ] ,
[ "Industrial Zones" , countText ( map . industrialZones ) ] ,
2026-05-22 19:28:39 +09:00
[ "National Roads" , ` ${ map . nationalRoads . length } / pop cover ${ Math . round ( ( map . transportDebug ? . nationalRoadPopulationCoverage || 0 ) * 100 ) } % / uncovered ${ ( map . transportDebug ? . nationalRoadUncoveredPopulation || 0 ) . toLocaleString ( ) } ` ] ,
[ "General Ring Roads" , ( map . ringRoads || [ ] ) . length ] ,
2026-05-20 15:02:37 +09:00
[ "Expressways" , map . expressways . length + map . externalExpressways . length ] ,
2026-05-20 13:50:56 +09:00
[ "External Gateways" , map . externalGateways . length ] ,
[ "Interchanges" , countText ( map . interchanges ) ] ,
[ "Logistics Parks" , countText ( map . logisticsParks ) ] ,
[ "New Towns" , countText ( map . newTowns ) ] ,
[ "Municipalities" , map . adminCenters . length ] ,
2026-05-26 15:32:27 +09:00
[ "Prefecture source" , map . regionalDebug ? . prefectureSource ? ? "-" ] ,
2026-05-20 13:50:56 +09:00
] ;
}
function renderStats ( map ) {
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-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 ) ) ;
}
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 14:31:42 +09:00
return ! 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 ; }
}
2026-05-29 14:31:42 +09:00
if ( ! best && adminId != null && adminId >= 0 ) return nearestNamedAdminCenter ( map , cellIndex , maxDistance , null ) ;
2026-05-28 23:51:55 +09:00
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-26 15:32:27 +09:00
function prefectureNameForCell ( map , i ) {
const id = map . prefectureRegionId ? . [ i ] ? ? - 1 ;
2026-05-29 14:31:42 +09:00
const region = ( map . prefectureRegions || [ ] ) . find ( ( p ) => hasNumericId ( p , id , PREFECTURE _ID _KEYS ) ) ;
const regionName = firstUsableText ( region , PREFECTURE _NAME _KEYS ) ;
if ( regionName ) return regionName ;
const center = nearestNamedAdminCenter ( map , i , 90 ) ;
const fromCenter = firstUsableText ( center , [ "prefectureName" , "prefectureRegionName" , "regionName" ] ) ;
return fromCenter || ( id >= 0 ? "Unnamed prefecture" : "-" ) ;
2026-05-26 15:32:27 +09:00
}
2026-05-20 13:50:56 +09:00
function updateTooltip ( event ) {
2026-05-28 19:37:28 +09:00
const map = activeMap ( ) ;
if ( ! map || ! tooltipEl || dragState . mode ) return ;
2026-05-20 13:50:56 +09:00
const rect = canvas . getBoundingClientRect ( ) ;
2026-05-28 00:30:09 +09:00
const cell = mapClientToCell ( event ) ;
if ( ! cell ) return ;
const { x , y } = cell ;
2026-05-28 19:37:28 +09:00
if ( x < 0 || y < 0 || x >= map . width || y >= map . height ) {
2026-05-20 13:50:56 +09:00
tooltipEl . classList . remove ( "visible" ) ;
return ;
}
2026-05-28 19:37:28 +09:00
const i = y * map . width + x ;
const worldCell = viewportCellToWorldCell ( { x , y } ) ;
2026-05-26 16:56:18 +09:00
const entity = nearestEntity ( state . hoverEntities , x , y ) ;
2026-05-28 19:37:28 +09:00
const elevation = map . elevation ? . [ i ] ? ? 0 ;
2026-05-28 23:51:55 +09:00
const density = map . populationDensity ? . [ i ] ? ? map . settlementScore ? . [ i ] ? ? 0 ;
2026-05-28 19:37:28 +09:00
const hoveredAdminId = map . adminId ? . [ i ] ? ? - 1 ;
2026-05-28 23:51:55 +09:00
const hoveredAdminPopulation = adminPopulation ( map , hoveredAdminId , i ) ;
2026-05-28 19:37:28 +09:00
const coordinateText = worldCell ? ` World ${ worldCell . x } , ${ worldCell . y } / View ${ x } , ${ y } ` : ` ${ x } , ${ y } ` ;
2026-05-29 14:31:42 +09:00
const entityName = firstUsableText ( entity , [ "name" , "facilityLabel" , "municipalityName" , "canonicalSettlementName" , "kind" ] ) ;
2026-05-28 19:37:28 +09:00
const entityTitle = entity
2026-05-29 14:31:42 +09:00
? ` ${ entityName || adminName ( map , hoveredAdminId , i ) || entity . kind || "Feature" } / ${ entity . kind || "Feature" } `
2026-05-28 19:37:28 +09:00
: coordinateText ;
2026-05-20 13:50:56 +09:00
const lines = [
2026-05-28 19:37:28 +09:00
` <strong> ${ entityTitle } </strong> ` ,
` Prefecture: ${ prefectureNameForCell ( map , i ) } ` ,
2026-05-28 23:51:55 +09:00
` Admin: ${ adminName ( map , hoveredAdminId , i ) } ` ,
2026-05-26 16:56:18 +09:00
` Admin Pop: ${ hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation . toLocaleString ( ) } ` ,
2026-05-28 19:37:28 +09:00
` Land: ${ map . sea ? . [ i ] ? "Sea" : landuseName ( map . landuse ? . [ i ] ) } ` ,
` Elevation: ${ elevation . toFixed ( 3 ) } / Slope: ${ ( map . slope ? . [ i ] ? ? 0 ) . toFixed ( 3 ) } ` ,
` River: ${ ( map . river ? . [ i ] ? ? 0 ) . toFixed ( 2 ) } / Density: ${ density . toFixed ( 2 ) } ` ,
2026-05-20 13:50:56 +09:00
] ;
if ( entity ? . population ) lines . splice ( 1 , 0 , ` Population: ${ entity . population . toLocaleString ( ) } ` ) ;
tooltipEl . innerHTML = lines . join ( "<br>" ) ;
2026-05-26 15:32:27 +09:00
const margin = 8 ;
const offset = 14 ;
const maxLeft = Math . max ( margin , rect . width - tooltipEl . offsetWidth - margin ) ;
const maxTop = Math . max ( margin , rect . height - tooltipEl . offsetHeight - margin ) ;
const desiredLeft = event . clientX - rect . left + offset ;
const desiredTop = event . clientY - rect . top + offset ;
tooltipEl . style . left = ` ${ Math . min ( Math . max ( margin , desiredLeft ) , maxLeft ) } px ` ;
tooltipEl . style . top = ` ${ Math . min ( Math . max ( margin , desiredTop ) , maxTop ) } px ` ;
2026-05-20 13:50:56 +09:00
tooltipEl . classList . add ( "visible" ) ;
}
2026-05-26 16:56:18 +09:00
function renderModeButtons ( ) {
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 ( ) ;
redraw ( ) ;
} ) ;
modeGrid . append ( button ) ;
}
}
2026-05-24 19:33:09 +09:00
async function regenerate ( ) {
2026-05-20 13:50:56 +09:00
state . seedText = seedInput . value ;
2026-05-28 16:45:00 +09:00
state . generationType = generationTypeInput ? . value || "auto" ;
2026-05-24 19:33:09 +09:00
setProgressVisible ( true , "Preparing generation..." ) ;
await nextFrame ( ) ;
try {
2026-05-28 16:45:00 +09:00
state . map = await generateMapAsync ( parseSeed ( state . seedText ) , { onProgress : updateGenerationProgress , terrainType : state . generationType } ) ;
2026-05-28 19:37:28 +09:00
state . world = createWorldMap ( state . map ) ;
state . camera = createInitialCamera ( state . world ) ;
state . lastPatchResult = null ;
2026-05-28 23:51:55 +09:00
resetPatchVariant ( { update : false } ) ;
2026-05-28 19:37:28 +09:00
hideSelectionOverlay ( ) ;
2026-05-24 19:33:09 +09:00
renderStats ( state . map ) ;
redraw ( ) ;
if ( progressStageEl ) progressStageEl . textContent = ` Done in ${ formatMs ( state . map . generationTotalMs || 0 ) } ` ;
renderTimingRows ( state . map . generationTimings || [ ] ) ;
window . setTimeout ( ( ) => setProgressVisible ( false ) , 900 ) ;
} catch ( error ) {
if ( progressStageEl ) progressStageEl . textContent = ` Generation failed: ${ error ? . message || error } ` ;
throw error ;
}
2026-05-20 13:50:56 +09:00
}
2026-05-28 19:37:28 +09:00
2026-05-28 23:51:55 +09:00
function derivePatchSeed ( rect , terrainType , variant = 0 ) {
2026-05-28 19:37:28 +09:00
let h = parseSeed ( state . seedText ) ^ 0x9e3779b9 ;
h = Math . imul ( h ^ ( rect . x0 | 0 ) , 1664525 ) >>> 0 ;
h = Math . imul ( h ^ ( rect . y0 | 0 ) , 1013904223 ) >>> 0 ;
h = Math . imul ( h ^ ( rect . x1 | 0 ) , 2246822519 ) >>> 0 ;
h = Math . imul ( h ^ ( rect . y1 | 0 ) , 3266489917 ) >>> 0 ;
2026-05-28 23:51:55 +09:00
h = Math . imul ( h ^ normalizePatchVariant ( variant ) , 668265263 ) >>> 0 ;
2026-05-28 19:37:28 +09:00
for ( const ch of String ( terrainType || "auto" ) ) h = Math . imul ( h ^ ch . charCodeAt ( 0 ) , 16777619 ) >>> 0 ;
return h >>> 0 ;
}
2026-05-28 23:51:55 +09:00
function handleCanvasWheel ( event ) {
if ( ! state . world || ! activeMap ( ) ) return ;
event . preventDefault ( ) ;
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
// Wheel events can fire dozens of times per second. Do one lightweight redraw
// per frame, then a full labeled/continuous redraw once zooming settles.
if ( zoomRedrawRaf == null ) {
zoomRedrawRaf = requestAnimationFrame ( ( ) => {
zoomRedrawRaf = null ;
redraw ( { fastTerrain : true , allowWorldExpand : false } ) ;
} ) ;
}
if ( zoomSettledTimer != null ) clearTimeout ( zoomSettledTimer ) ;
zoomSettledTimer = window . setTimeout ( ( ) => {
zoomSettledTimer = null ;
redraw ( { fastTerrain : false , allowWorldExpand : false } ) ;
} , 140 ) ;
2026-05-28 23:51:55 +09:00
}
2026-05-28 19:37:28 +09:00
async function generateSelectedPatch ( ) {
const validation = validatePatchRect ( state . selectionRect , state . world ) ;
if ( ! validation . ok ) {
updatePatchControls ( ) ;
return ;
}
const terrainType = patchTerrainTypeInput ? . value || generationTypeInput ? . value || "auto" ;
2026-05-28 23:51:55 +09:00
const variant = readPatchVariant ( ) ;
const seed = derivePatchSeed ( validation . rect , terrainType , variant ) ;
2026-05-28 19:37:28 +09:00
setProgressVisible ( true , "Generating selected patch..." ) ;
await nextFrame ( ) ;
try {
2026-05-28 23:51:55 +09:00
const result = generatePatch ( state . world , validation . rect , { terrainType , seed , variant } ) ;
2026-05-28 19:37:28 +09:00
if ( ! result . ok ) {
if ( progressStageEl ) progressStageEl . textContent = ` Patch failed: ${ result . reason || "invalid selection" } ` ;
updatePatchControls ( ) ;
window . setTimeout ( ( ) => setProgressVisible ( false ) , 1200 ) ;
return ;
}
state . lastPatchResult = result ;
redraw ( ) ;
renderStats ( state . map ) ;
updatePatchControls ( ) ;
const human = result . humanGeography ;
const humanText = human ? . ok ? ` / human: ${ human . modernCities || 0 } cities, ${ human . ports || 0 } ports, ${ human . villages || 0 } villages, ${ ( human . roadConnectorsCreated || 0 ) + ( human . railwayConnectorsCreated || 0 ) } connectors, admin ${ human . adminCellsReassigned || 0 } , invalid ports ${ human . invalidPortsRemoved || 0 } ` : "" ;
2026-05-28 23:51:55 +09:00
if ( progressStageEl ) progressStageEl . textContent = ` Patch generated: ${ result . label } / variant ${ result . variant ? ? variant } / mode ${ result . patchGenerationMode || "legacy-full-pipeline" } / core ${ formatRectSize ( result . rects . coreRect ) } / write ${ formatRectSize ( result . rects . writeRect ) } / terrain ${ result . updatedCells . toLocaleString ( ) } cells / coast ${ result . coastCellsChanged || 0 } / natural ${ result . naturalRegionsUpdated || 0 } ${ humanText } ` ;
2026-05-28 19:37:28 +09:00
renderTimingRows ( [ ] ) ;
window . setTimeout ( ( ) => setProgressVisible ( false ) , 900 ) ;
} catch ( error ) {
if ( progressStageEl ) progressStageEl . textContent = ` Patch failed: ${ error ? . message || error } ` ;
throw error ;
}
}
2026-05-28 23:51:55 +09:00
async function generateAlternativePatch ( ) {
const validation = validatePatchRect ( state . selectionRect , state . world ) ;
if ( ! validation . ok ) {
updatePatchControls ( ) ;
return ;
}
setPatchVariant ( readPatchVariant ( ) + 1 , { update : false } ) ;
await generateSelectedPatch ( ) ;
}
2026-05-28 19:37:28 +09:00
function redraw ( options = { } ) {
if ( ! state . world ) return ;
2026-05-29 14:31:42 +09:00
const viewSize = syncViewportSize ( ) ;
const expansion = options . allowWorldExpand === false ? null : ensureWorldPaddingForCamera ( state . world , state . camera , viewSize . width , viewSize . height ) ;
2026-05-28 23:51:55 +09:00
if ( expansion ? . expanded ) {
state . camera = { x : ( state . camera ? . x || 0 ) + ( expansion . dx || 0 ) , y : ( state . camera ? . y || 0 ) + ( expansion . dy || 0 ) } ;
if ( state . selectionRect ) {
2026-05-29 14:31:42 +09:00
const dx = expansion . dx || 0 ;
const dy = expansion . dy || 0 ;
2026-05-28 23:51:55 +09:00
state . selectionRect = {
2026-05-29 14:31:42 +09:00
... state . selectionRect ,
x0 : state . selectionRect . x0 + dx ,
y0 : state . selectionRect . y0 + dy ,
x1 : state . selectionRect . x1 + dx ,
y1 : state . selectionRect . y1 + dy ,
polygon : Array . isArray ( state . selectionRect . polygon ) ? state . selectionRect . polygon . map ( ( p ) => ( { x : p . x + dx , y : p . y + dy } ) ) : state . selectionRect . polygon ,
2026-05-28 23:51:55 +09:00
} ;
}
}
2026-05-29 14:31:42 +09:00
state . camera = clampCameraForView ( state . camera , viewSize ) ;
state . viewportMap = getViewportMap ( state . world , state . camera , viewSize . width , viewSize . height , { light : ! ! options . fastTerrain } ) ;
state . hoverEntities = options . fastTerrain ? [ ] : buildHoverEntities ( state . viewportMap ) ;
2026-05-28 19:37:28 +09:00
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 ,
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-28 23:51:55 +09:00
applyCanvasZoom ( ) ;
2026-05-28 19:37:28 +09:00
if ( state . selectionRect && dragState . mode !== "select" ) updateSelectionOverlayFromWorldRect ( ) ;
2026-05-20 13:50:56 +09:00
}
function init ( ) {
renderModeButtons ( ) ;
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" , ( ) => {
state . lastPatchResult = null ;
resetPatchVariant ( { update : false } ) ;
updatePatchControls ( ) ;
} ) ;
patchVariantInput ? . addEventListener ( "change" , ( ) => setPatchVariant ( patchVariantInput . value ) ) ;
patchVariantInput ? . addEventListener ( "keydown" , ( event ) => {
if ( event . key === "Enter" ) {
setPatchVariant ( patchVariantInput . value ) ;
generateSelectedPatch ( ) ;
}
} ) ;
2026-05-28 19:37:28 +09:00
generatePatchButton ? . addEventListener ( "click" , generateSelectedPatch ) ;
2026-05-28 23:51:55 +09:00
alternativePatchButton ? . addEventListener ( "click" , generateAlternativePatch ) ;
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-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-05-20 13:50:56 +09:00
2026-05-28 19:37:28 +09:00
updatePatchControls ( ) ;
2026-05-20 13:50:56 +09:00
regenerate ( ) ;
}
init ( ) ;