// Application State const state = { currentMap: null, mapData: null, mapWidth: 0, mapHeight: 0, startPoint: null, endPoint: null, hpaPath: null, hpaResult: null, // Store full HPA* result including timing comparisons: [], // Array of comparison results visibleComparisons: new Set(), // Which comparison paths are visible adapters: [], // Available comparison adapters (loaded from backend) graphDebug: null, // Static graph data (allNodes, edges, clusterSize) - loaded once per map debugInfo: null, // Per-path debug data (timings, nodePath, initialPath) isMapLoading: false, // Loading state for map switching isHpaLoading: false, // Separate loading state for HPA* activeRefreshButton: null, // Track which refresh button is spinning // Transport Ship mode mode: "pathfinding", // "pathfinding" | "transport" paintedTiles: new Set(), // Set of tile indices (y * width + x) brushSize: 5, transportResult: null, // Result from spatial query }; // Colors for comparison paths const COMPARISON_COLORS = { "hpa.cached": "#00ffff", // cyan hpa: "#ff8800", // orange "a.baseline": "#ff00ff", // magenta "a.generic": "#88ff00", // lime "a.full": "#ffff00", // yellow }; // Canvas state let zoomLevel = 1.0; let panX = 0; let panY = 0; let isDragging = false; let dragStartX = 0; let dragStartY = 0; let dragStartPanX = 0; let dragStartPanY = 0; let isPainting = false; let isErasing = false; let mapCanvas, overlayCanvas, interactiveCanvas; let mapCtx, overlayCtx, interactiveCtx; let mapRendered = false; let hoveredNode = null; let hoveredPoint = null; // 'start', 'end', or null let draggingPoint = null; // 'start', 'end', or null let draggingPointPosition = null; // [x, y] canvas position while dragging let lastPathRecalcTime = 0; let renderRequested = false; // Save current state to URL query string function updateURLState() { const params = new URLSearchParams(); if (state.currentMap) { params.set("map", state.currentMap); } if (state.startPoint) { params.set("start", `${state.startPoint[0]},${state.startPoint[1]}`); } if (state.endPoint) { params.set("end", `${state.endPoint[0]},${state.endPoint[1]}`); } const newURL = `${window.location.pathname}?${params.toString()}`; window.history.replaceState({}, "", newURL); } // Restore state from URL query string function restoreFromURL() { const params = new URLSearchParams(window.location.search); const mapName = params.get("map"); const startStr = params.get("start"); const endStr = params.get("end"); const result = { map: mapName, start: null, end: null, }; if (startStr) { const [x, y] = startStr.split(",").map(Number); if (!isNaN(x) && !isNaN(y)) { result.start = [x, y]; } } if (endStr) { const [x, y] = endStr.split(",").map(Number); if (!isNaN(x) && !isNaN(y)) { result.end = [x, y]; } } return result; } // Initialize on DOM load window.addEventListener("DOMContentLoaded", () => { initializeCanvases(); initializeControls(); initializeDragControls(); initializeTimingsPanel(); loadMaps(); }); // Initialize canvas elements function initializeCanvases() { mapCanvas = document.getElementById("mapCanvas"); mapCtx = mapCanvas.getContext("2d"); overlayCanvas = document.getElementById("overlayCanvas"); overlayCtx = overlayCanvas.getContext("2d"); // Create interactive canvas OUTSIDE the CSS transform wrapper // This canvas is viewport-sized and renders paths/points at screen coordinates const canvasContainer = document.querySelector(".canvas-container"); interactiveCanvas = document.createElement("canvas"); interactiveCanvas.id = "interactiveCanvas"; interactiveCanvas.style.position = "absolute"; interactiveCanvas.style.top = "0"; interactiveCanvas.style.left = "0"; interactiveCanvas.style.width = "100%"; interactiveCanvas.style.height = "100%"; interactiveCanvas.style.zIndex = "3"; interactiveCanvas.style.pointerEvents = "none"; canvasContainer.appendChild(interactiveCanvas); interactiveCtx = interactiveCanvas.getContext("2d"); // Size interactive canvas to viewport const resizeInteractiveCanvas = () => { const rect = canvasContainer.getBoundingClientRect(); interactiveCanvas.width = rect.width; interactiveCanvas.height = rect.height; }; resizeInteractiveCanvas(); window.addEventListener("resize", resizeInteractiveCanvas); } // Initialize control event listeners function initializeControls() { // Map selector (top panel) document.getElementById("scenarioSelect").addEventListener("change", (e) => { switchMap(e.target.value); }); // Map selector (welcome screen) document .getElementById("welcomeMapSelect") .addEventListener("change", (e) => { const mapName = e.target.value; if (mapName) { switchMap(mapName); } }); // Refresh HPA* button document.getElementById("refreshHpa").addEventListener("click", (e) => { if (state.startPoint && state.endPoint) { const btn = e.currentTarget; btn.classList.add("spinning"); state.activeRefreshButton = btn; requestPathfinding(state.startPoint, state.endPoint); } }); // Visualization toggles - all buttons [ "showInitialPath", "showUsedNodes", "showColoredMap", "showNodes", "showSectorGrid", "showEdges", ].forEach((id) => { const button = document.getElementById(id); button.addEventListener("click", () => { const isActive = button.dataset.active === "true"; button.dataset.active = !isActive; // Map coloring affects map canvas if (id === "showColoredMap") { renderMapBackground(2); } // Static overlays (sectors, edges, all nodes) go on overlay canvas if (["showNodes", "showSectorGrid", "showEdges"].includes(id)) { renderOverlay(2); } // Dynamic elements (paths, highlighted nodes) go on interactive canvas renderInteractive(); }); }); // Zoom control document.getElementById("zoom").addEventListener("input", (e) => { zoomLevel = parseFloat(e.target.value); document.getElementById("zoomValue").textContent = zoomLevel.toFixed(1) + "x"; updateTransform(); }); // Clear points button document.getElementById("clearPoints").addEventListener("click", () => { clearPoints(); }); // Mode switch buttons document.querySelectorAll(".mode-button").forEach((btn) => { btn.addEventListener("click", () => { const newMode = btn.dataset.mode; if (newMode !== state.mode) { setMode(newMode); } }); }); // Transport controls const brushSizeInput = document.getElementById("brushSize"); const brushSizeValue = document.getElementById("brushSizeValue"); brushSizeInput.addEventListener("input", (e) => { state.brushSize = parseInt(e.target.value); brushSizeValue.textContent = state.brushSize; }); document.getElementById("clearTerritory").addEventListener("click", () => { state.paintedTiles.clear(); state.transportResult = null; updateTransportInfo(); renderInteractive(); }); } // Set application mode function setMode(newMode) { state.mode = newMode; // Update UI document.querySelectorAll(".mode-button").forEach((btn) => { btn.classList.toggle("active", btn.dataset.mode === newMode); }); const transportControls = document.getElementById("transportControls"); const timingsPanel = document.getElementById("timingsPanel"); const debugPanel = document.querySelector(".debug-panel"); if (newMode === "transport") { transportControls.style.display = "block"; timingsPanel.style.top = "280px"; debugPanel.style.display = "none"; setStatus("Paint territory, then click water target"); } else { transportControls.style.display = "none"; timingsPanel.style.top = "280px"; debugPanel.style.display = "flex"; if (state.startPoint && state.endPoint) { setStatus("Path computed successfully"); } else if (state.startPoint) { setStatus("Click on map to set end point"); } else { setStatus("Click on map to set start point"); } } renderInteractive(); } // Update transport info display function updateTransportInfo() { const paintedCount = document.getElementById("paintedCount"); const shoreCount = document.getElementById("shoreCount"); paintedCount.textContent = state.paintedTiles.size; // Count shore tiles let shores = 0; if (state.mapData) { for (const idx of state.paintedTiles) { if (isLandShore(idx)) { shores++; } } } shoreCount.textContent = shores; } // Check if tile is a land shore (land adjacent to water) function isLandShore(tileIdx) { const x = tileIdx % state.mapWidth; const y = Math.floor(tileIdx / state.mapWidth); // Must be land if (state.mapData[tileIdx] !== 0) return false; // Check 4 neighbors for water const neighbors = [ [x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1], ]; for (const [nx, ny] of neighbors) { if (nx < 0 || nx >= state.mapWidth || ny < 0 || ny >= state.mapHeight) continue; const nIdx = ny * state.mapWidth + nx; if (state.mapData[nIdx] === 1) return true; } return false; } // Helper function to check if mouse is over a start/end point function getPointAtPosition(canvasX, canvasY) { const scale = zoomLevel; const zoomFactor = 3 / zoomLevel; const hitRadius = Math.max(4, scale * 3 * zoomFactor) + 3; // Add 3px tolerance // Check end point first (render on top) if (state.endPoint) { const dx = canvasX - (state.endPoint[0] + 0.5); const dy = canvasY - (state.endPoint[1] + 0.5); const distance = Math.sqrt(dx * dx + dy * dy); if (distance <= hitRadius / scale) { return "end"; } } // Check start point if (state.startPoint) { const dx = canvasX - (state.startPoint[0] + 0.5); const dy = canvasY - (state.startPoint[1] + 0.5); const distance = Math.sqrt(dx * dx + dy * dy); if (distance <= hitRadius / scale) { return "start"; } } return null; } // Throttled path recalculation (max once per 16ms ~60fps) function schedulePathRecalc() { const now = Date.now(); const timeSinceLastCall = now - lastPathRecalcTime; if (timeSinceLastCall >= 16) { // Enough time has passed, request immediately lastPathRecalcTime = now; if (state.startPoint && state.endPoint) { // Skip comparisons during drag for snappy feel requestPathfinding(state.startPoint, state.endPoint, true); } } // If not enough time has passed, skip this call (throttle) } // Throttled spatial query recalculation (max once per 50ms for heavier computation) let lastSpatialQueryTime = 0; function scheduleSpatialQueryRecalc() { const now = Date.now(); const timeSinceLastCall = now - lastSpatialQueryTime; if (timeSinceLastCall >= 50) { lastSpatialQueryTime = now; if (state.endPoint && state.paintedTiles.size > 0) { requestSpatialQuery(state.endPoint); } } } // Initialize drag and click controls function initializeDragControls() { const wrapper = document.getElementById("canvasWrapper"); const tooltip = document.getElementById("tooltip"); wrapper.addEventListener("mousedown", (e) => { const rect = wrapper.getBoundingClientRect(); const canvasX = (e.clientX - rect.left - panX) / zoomLevel; const canvasY = (e.clientY - rect.top - panY) / zoomLevel; // Transport mode: check for dragging end point first, then painting if (state.mode === "transport") { // Check if clicking on end point to drag it const pointAtMouse = getPointAtPosition(canvasX, canvasY); if (pointAtMouse === "end") { draggingPoint = "end"; wrapper.style.cursor = "move"; dragStartX = e.clientX; dragStartY = e.clientY; return; } const tileX = Math.floor(canvasX); const tileY = Math.floor(canvasY); if ( tileX >= 0 && tileX < state.mapWidth && tileY >= 0 && tileY < state.mapHeight ) { const tileIdx = tileY * state.mapWidth + tileX; const isLand = state.mapData[tileIdx] === 0; if (isLand) { // Start painting (or erasing with ctrl/right-click) isErasing = e.ctrlKey || e.button === 2; isPainting = true; paintAtPosition(tileX, tileY, isErasing); wrapper.style.cursor = isErasing ? "crosshair" : "pointer"; return; } } // Fall through to panning if not on land } // Pathfinding mode: check if clicking on a point const pointAtMouse = getPointAtPosition(canvasX, canvasY); if (pointAtMouse && state.mode === "pathfinding") { // Start dragging the point draggingPoint = pointAtMouse; wrapper.style.cursor = "move"; } else { // Start panning the map isDragging = true; wrapper.style.cursor = "grabbing"; } dragStartX = e.clientX; dragStartY = e.clientY; dragStartPanX = panX; dragStartPanY = panY; }); wrapper.addEventListener("mousemove", (e) => { const rect = wrapper.getBoundingClientRect(); const canvasX = (e.clientX - rect.left - panX) / zoomLevel; const canvasY = (e.clientY - rect.top - panY) / zoomLevel; // Transport mode: continue painting if (isPainting && state.mode === "transport") { const tileX = Math.floor(canvasX); const tileY = Math.floor(canvasY); paintAtPosition(tileX, tileY, isErasing); return; } // Transport mode: dragging end point if (draggingPoint === "end" && state.mode === "transport") { const tileX = Math.floor(canvasX); const tileY = Math.floor(canvasY); if ( tileX >= 0 && tileX < state.mapWidth && tileY >= 0 && tileY < state.mapHeight ) { const tileIndex = tileY * state.mapWidth + tileX; const isWater = state.mapData[tileIndex] === 1; if (isWater) { draggingPointPosition = [tileX, tileY]; state.endPoint = [tileX, tileY]; renderInteractive(); // Throttled spatial query recomputation if (state.paintedTiles.size > 0) { scheduleSpatialQueryRecalc(); } } } return; } // Transport mode: check hover over end point if (state.mode === "transport" && !isDragging) { const pointAtMouse = getPointAtPosition(canvasX, canvasY); if (pointAtMouse !== hoveredPoint) { hoveredPoint = pointAtMouse; renderInteractive(); wrapper.style.cursor = hoveredPoint ? "move" : "grab"; } return; } if (draggingPoint) { // Dragging a start/end point - snap to water tile const tileX = Math.floor(canvasX); const tileY = Math.floor(canvasY); // Validate tile is within bounds and is water if ( tileX >= 0 && tileX < state.mapWidth && tileY >= 0 && tileY < state.mapHeight ) { const tileIndex = tileY * state.mapWidth + tileX; const isWater = state.mapData[tileIndex] === 1; if (isWater) { // Snap to water tile center draggingPointPosition = [tileX, tileY]; // Update the actual point position and trigger throttled path recalculation if (draggingPoint === "start") { state.startPoint = [tileX, tileY]; } else { state.endPoint = [tileX, tileY]; } // Trigger throttled path recalculation (16ms) if (state.startPoint && state.endPoint) { schedulePathRecalc(); } } // If not water, keep previous valid position (don't update) } renderInteractive(); } else if (isDragging) { // Panning the map const dx = e.clientX - dragStartX; const dy = e.clientY - dragStartY; panX = dragStartPanX + dx; panY = dragStartPanY + dy; updateTransform(); // Updates interactive layer at screen coordinates } else { // Check for point hover const pointAtMouse = getPointAtPosition(canvasX, canvasY); if (pointAtMouse !== hoveredPoint) { hoveredPoint = pointAtMouse; renderInteractive(); // Fast - only redraws points // Update cursor wrapper.style.cursor = hoveredPoint ? "move" : "grab"; } // Check for node hover (only if node visualization is enabled) const showNodes = document.getElementById("showNodes").dataset.active === "true"; const showUsedNodes = document.getElementById("showUsedNodes").dataset.active === "true"; if ( (showNodes || showUsedNodes) && state.graphDebug && state.graphDebug.allNodes ) { // Filter nodes based on what's visible let nodesToCheck = state.graphDebug.allNodes; if ( showUsedNodes && !showNodes && state.debugInfo && state.debugInfo.nodePath ) { // Only show tooltips for used nodes // nodePath are coordinates [x, y] matching the map format const usedNodeCoords = new Set( state.debugInfo.nodePath.map(([x, y]) => `${x},${y}`), ); nodesToCheck = state.graphDebug.allNodes.filter((node) => usedNodeCoords.has(`${node.x * 2},${node.y * 2}`), ); } const foundNode = findNodeAtPosition(canvasX, canvasY, nodesToCheck); if (foundNode !== hoveredNode) { hoveredNode = foundNode; if (hoveredNode) { showNodeTooltip(hoveredNode, e.clientX, e.clientY); } else { tooltip.classList.remove("visible"); } renderInteractive(); } else if (hoveredNode) { tooltip.style.left = e.clientX + 15 + "px"; tooltip.style.top = e.clientY + 15 + "px"; } } else { // No node visualization enabled, clear any existing tooltip if (hoveredNode) { hoveredNode = null; tooltip.classList.remove("visible"); renderInteractive(); } } } }); wrapper.addEventListener("mouseup", (e) => { // Only treat as click if mouse didn't move much const dx = Math.abs(e.clientX - dragStartX); const dy = Math.abs(e.clientY - dragStartY); // Transport mode: finish painting if (isPainting) { isPainting = false; isErasing = false; wrapper.style.cursor = "grab"; return; } // Transport mode: finish dragging end point if (draggingPoint === "end" && state.mode === "transport") { if (state.endPoint && state.paintedTiles.size > 0) { requestSpatialQuery(state.endPoint); } draggingPoint = null; draggingPointPosition = null; renderInteractive(); wrapper.style.cursor = "grab"; return; } if (draggingPoint) { // Finished dragging a point // Request final path update to ensure we have the path for the final position // (in case throttling skipped the last update during fast dragging) if (state.startPoint && state.endPoint) { requestPathfinding(state.startPoint, state.endPoint); } draggingPoint = null; draggingPointPosition = null; renderInteractive(); updateURLState(); } else if (isDragging && dx < 5 && dy < 5) { // Was panning but didn't move much - treat as click if (state.mode === "transport") { handleTransportClick(e); } else { handleMapClick(e); } } isDragging = false; // Reset cursor based on current hover state const rect = wrapper.getBoundingClientRect(); const canvasX = (e.clientX - rect.left - panX) / zoomLevel; const canvasY = (e.clientY - rect.top - panY) / zoomLevel; const pointAtMouse = getPointAtPosition(canvasX, canvasY); wrapper.style.cursor = pointAtMouse && state.mode === "pathfinding" ? "move" : "grab"; }); wrapper.addEventListener("mouseleave", () => { isDragging = false; draggingPoint = null; draggingPointPosition = null; isPainting = false; isErasing = false; tooltip.classList.remove("visible"); wrapper.style.cursor = "grab"; const needsRender = hoveredNode || hoveredPoint; hoveredNode = null; hoveredPoint = null; if (needsRender) { renderInteractive(); } }); // Prevent context menu on right-click (for erasing) wrapper.addEventListener("contextmenu", (e) => { if (state.mode === "transport") { e.preventDefault(); } }); wrapper.addEventListener("wheel", (e) => { e.preventDefault(); const rect = wrapper.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const oldZoom = zoomLevel; const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1; zoomLevel = Math.max(0.1, Math.min(10, zoomLevel * zoomDelta)); panX = mouseX - (mouseX - panX) * (zoomLevel / oldZoom); panY = mouseY - (mouseY - panY) * (zoomLevel / oldZoom); document.getElementById("zoom").value = zoomLevel; document.getElementById("zoomValue").textContent = zoomLevel.toFixed(1); updateTransform(); renderInteractive(); }); } // Initialize timings panel to default state function initializeTimingsPanel() { // Set initial state to match "no path" state updateTimingsPanel({ primary: null, comparisons: [] }); } // Handle map clicks for point selection function handleMapClick(e) { if (!state.currentMap || state.isMapLoading || state.isHpaLoading) return; const wrapper = document.getElementById("canvasWrapper"); const rect = wrapper.getBoundingClientRect(); // Convert screen coordinates to tile coordinates const canvasX = (e.clientX - rect.left - panX) / zoomLevel; const canvasY = (e.clientY - rect.top - panY) / zoomLevel; const tileX = Math.floor(canvasX); const tileY = Math.floor(canvasY); // Validate coordinates if ( tileX < 0 || tileX >= state.mapWidth || tileY < 0 || tileY >= state.mapHeight ) { return; } // Check if tile is water const index = tileY * state.mapWidth + tileX; const isWater = state.mapData[index] === 1; if (!isWater) { showError("Selected point must be on water"); return; } // Point selection state machine if (!state.startPoint) { // Set start point state.startPoint = [tileX, tileY]; updatePointDisplay(); renderInteractive(); updateURLState(); } else if (!state.endPoint) { // Set end point and trigger pathfinding state.endPoint = [tileX, tileY]; updatePointDisplay(); renderInteractive(); updateURLState(); requestPathfinding(state.startPoint, state.endPoint); } else { // Reset and set new start point clearPoints(); state.startPoint = [tileX, tileY]; updatePointDisplay(); renderInteractive(); updateURLState(); } } // Clear selected points function clearPoints() { state.startPoint = null; state.endPoint = null; state.hpaPath = null; state.hpaResult = null; state.comparisons = []; state.debugInfo = null; updatePointDisplay(); hidePathInfo(); updateURLState(); // Remove points from URL renderInteractive(); } // Paint tiles in a brush area function paintAtPosition(centerX, centerY, erase = false) { const radius = Math.floor(state.brushSize / 2); let changed = false; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const x = centerX + dx; const y = centerY + dy; if (x < 0 || x >= state.mapWidth || y < 0 || y >= state.mapHeight) continue; const idx = y * state.mapWidth + x; const isLand = state.mapData[idx] === 0; if (!isLand) continue; if (erase) { if (state.paintedTiles.has(idx)) { state.paintedTiles.delete(idx); changed = true; } } else { if (!state.paintedTiles.has(idx)) { state.paintedTiles.add(idx); changed = true; } } } } if (changed) { updateTransportInfo(); renderInteractive(); } } // Handle clicks in transport mode function handleTransportClick(e) { if (!state.currentMap || state.isMapLoading) return; const wrapper = document.getElementById("canvasWrapper"); const rect = wrapper.getBoundingClientRect(); const canvasX = (e.clientX - rect.left - panX) / zoomLevel; const canvasY = (e.clientY - rect.top - panY) / zoomLevel; const tileX = Math.floor(canvasX); const tileY = Math.floor(canvasY); if ( tileX < 0 || tileX >= state.mapWidth || tileY < 0 || tileY >= state.mapHeight ) { return; } const idx = tileY * state.mapWidth + tileX; const isWater = state.mapData[idx] === 1; if (!isWater) { return; } // Clicked on water - run spatial query if (state.paintedTiles.size === 0) { showError("Paint some territory first"); return; } requestSpatialQuery([tileX, tileY]); } // Request spatial query computation async function requestSpatialQuery(target) { setStatus("Computing spatial query...", true); try { // Only send shore tiles (land adjacent to water) - much smaller payload const ownedTiles = Array.from(state.paintedTiles).filter((idx) => isLandShore(idx), ); const response = await fetch("/api/spatial-query", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ map: state.currentMap, ownedTiles, target, }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "Spatial query failed"); } const result = await response.json(); state.transportResult = result; state.endPoint = target; renderInteractive(); updateTransportTimings(result); if (result.selectedShore) { setStatus( `Shore selected: (${result.selectedShore[0]}, ${result.selectedShore[1]})`, ); } else { setStatus("No valid shore found"); } } catch (error) { showError(`Spatial query failed: ${error.message}`); } } // Update timings panel for transport mode function updateTransportTimings(result) { const hpaTimeEl = document.getElementById("hpaTime"); const hpaTilesEl = document.getElementById("hpaTiles"); if (result.path) { hpaTilesEl.textContent = `- ${result.path.length} tiles`; } else { hpaTilesEl.textContent = ""; } const totalTime = result.debug?.timings?.["SpatialQuery.closestShoreByWater"] ?? 0; if (totalTime > 0) { hpaTimeEl.textContent = `${totalTime.toFixed(2)}ms`; hpaTimeEl.classList.remove("faded"); } else { hpaTimeEl.textContent = "0.00ms"; hpaTimeEl.classList.add("faded"); } // Hide pathfinding-specific timing breakdown in transport mode document.getElementById("timingEarlyExit").style.display = "none"; document.getElementById("timingFindNodes").style.display = "none"; document.getElementById("timingAbstractPath").style.display = "none"; document.getElementById("timingInitialPath").style.display = "none"; document.getElementById("timingSmoothPath").style.display = "none"; document.getElementById("comparisonsSection").style.display = "none"; } // Update transform for pan/zoom function updateTransform() { const transform = `translate(${panX}px, ${panY}px) scale(${zoomLevel})`; mapCanvas.style.transform = transform; overlayCanvas.style.transform = transform; // Interactive canvas is outside the transform - update it separately renderInteractive(); } // Load available maps async function loadMaps() { setStatus("Loading maps...", true); try { const response = await fetch("/api/maps"); if (!response.ok) throw new Error("Failed to load maps"); const data = await response.json(); // Featured maps to show in grid (in order) const featuredMapNames = [ "giantworldmap", "northamerica", "southamerica", "europe", "asia", "straitofgibraltar", "manicouagan", "mars", ]; // Get featured maps in the specified order const gridMaps = featuredMapNames .map((name) => data.maps.find((m) => m.name === name)) .filter((map) => map !== undefined); // Populate map grid with featured maps - update placeholders gridMaps.forEach((map, index) => { const card = document.querySelector(`[data-map-index="${index}"]`); if (!card) return; // Update click handler card.onclick = () => switchMap(map.name); // Update image const img = card.querySelector("img"); if (img) { img.src = `/api/maps/${encodeURIComponent(map.name)}/thumbnail`; img.alt = map.displayName; } // Update name const nameEl = card.querySelector(".map-card-name"); if (nameEl) { nameEl.textContent = map.displayName; nameEl.style.opacity = "1"; } }); // Populate both selectors (all maps) const topSelect = document.getElementById("scenarioSelect"); const welcomeSelect = document.getElementById("welcomeMapSelect"); topSelect.innerHTML = ''; welcomeSelect.innerHTML = ''; data.maps.forEach((map) => { // Top panel selector const topOption = document.createElement("option"); topOption.value = map.name; topOption.textContent = map.displayName; topSelect.appendChild(topOption); // Welcome screen selector const welcomeOption = document.createElement("option"); welcomeOption.value = map.name; welcomeOption.textContent = map.displayName; welcomeSelect.appendChild(welcomeOption); }); setStatus("Select a map to begin"); // Restore state from URL if present const urlState = restoreFromURL(); if (urlState.map) { // Load the map from URL await switchMap(urlState.map, true); // Restore points from URL // Points will be restored in switchMap after the map loads } } catch (error) { showError(`Failed to load maps: ${error.message}`); } } // Switch to a different map async function switchMap(mapName, restorePointsFromURL = false) { if (!mapName) return; setStatus("Loading map...", true); state.isMapLoading = true; try { const response = await fetch(`/api/maps/${encodeURIComponent(mapName)}`); if (!response.ok) throw new Error("Failed to load map"); const data = await response.json(); // Update state state.currentMap = mapName; state.mapWidth = data.width; state.mapHeight = data.height; state.mapData = data.mapData; state.graphDebug = data.graphDebug; // Store static graph debug data state.adapters = data.adapters || []; // Store available comparison adapters // Clear paths (but don't update URL yet if we're restoring from URL) state.startPoint = null; state.endPoint = null; state.hpaPath = null; state.hpaResult = null; state.comparisons = []; state.debugInfo = null; updatePointDisplay(); hidePathInfo(); // Size canvases mapCanvas.width = state.mapWidth * 2; mapCanvas.height = state.mapHeight * 2; mapCanvas.style.width = `${state.mapWidth}px`; mapCanvas.style.height = `${state.mapHeight}px`; overlayCanvas.width = state.mapWidth * 2; overlayCanvas.height = state.mapHeight * 2; overlayCanvas.style.width = `${state.mapWidth}px`; overlayCanvas.style.height = `${state.mapHeight}px`; // Render map and overlays renderMapBackground(2); renderOverlay(2); renderInteractive(); // Reset view zoomLevel = 1.0; panX = 0; panY = 0; document.getElementById("zoom").value = 1.0; document.getElementById("zoomValue").textContent = "1.0"; updateTransform(); // Hide welcome screen hideWelcomeScreen(); // Sync both selectors document.getElementById("scenarioSelect").value = mapName; document.getElementById("welcomeMapSelect").value = mapName; setStatus("Click on map to set start point"); mapRendered = true; // Restore start/end points from URL if requested (initial page load) if (restorePointsFromURL) { const urlState = restoreFromURL(); if (urlState.start) { const [x, y] = urlState.start; if (x >= 0 && x < state.mapWidth && y >= 0 && y < state.mapHeight) { const tileIndex = y * state.mapWidth + x; const isWater = state.mapData[tileIndex] === 1; if (isWater) { state.startPoint = [x, y]; } } } if (urlState.end) { const [x, y] = urlState.end; if (x >= 0 && x < state.mapWidth && y >= 0 && y < state.mapHeight) { const tileIndex = y * state.mapWidth + x; const isWater = state.mapData[tileIndex] === 1; if (isWater) { state.endPoint = [x, y]; } } } // If both points are set, request pathfinding if (state.startPoint && state.endPoint) { renderInteractive(); requestPathfinding(state.startPoint, state.endPoint); } } else { // User manually switched maps - update URL to clear points updateURLState(); } } catch (error) { showError(`Failed to load map: ${error.message}`); } finally { state.isMapLoading = false; } } // Show/hide welcome screen function showWelcomeScreen() { document.getElementById("welcomeScreen").classList.remove("hidden"); } function hideWelcomeScreen() { document.getElementById("welcomeScreen").classList.add("hidden"); } // Request pathfinding computation (HPA* primary + comparisons) async function requestPathfinding(from, to, skipComparisons = false) { setStatus("Computing path...", true); state.isHpaLoading = true; try { const body = { map: state.currentMap, from, to, }; // Skip comparisons during drag for snappy feel if (skipComparisons) { body.adapters = []; } const response = await fetch("/api/pathfind", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "Pathfinding failed"); } const result = await response.json(); // Update state with new API format state.hpaPath = result.primary.path; state.hpaResult = result.primary; state.comparisons = result.comparisons; state.debugInfo = { initialPath: result.primary.debug.initialPath, nodePath: result.primary.debug.nodePath, timings: result.primary.debug.timings, }; // Update UI updatePathInfo(result); renderInteractive(); setStatus("Path computed successfully"); } catch (error) { showError(`Pathfinding failed: ${error.message}`); } finally { state.isHpaLoading = false; // Stop refresh button spinning if (state.activeRefreshButton) { state.activeRefreshButton.classList.remove("spinning"); state.activeRefreshButton = null; } } } // Update point display function updatePointDisplay() { // No-op now, kept for compatibility } // Update path info in UI function updatePathInfo(result) { // Update timings panel updateTimingsPanel(result); } // Update the dedicated timings panel function updateTimingsPanel(result) { const primary = result.primary; const timings = primary && primary.debug ? primary.debug.timings : {}; const hpaTilesEl = document.getElementById("hpaTiles"); if (primary && primary.length > 0) { hpaTilesEl.textContent = `- ${primary.length} tiles`; } else { hpaTilesEl.textContent = ""; } // Show timing breakdown - always visible with gray dashes when no data // Early Exit const earlyExitEl = document.getElementById("timingEarlyExit"); const earlyExitValueEl = document.getElementById("timingEarlyExitValue"); earlyExitEl.style.display = "flex"; const earlyExitTime = timings["earlyExit"]; if (earlyExitTime !== undefined) { earlyExitValueEl.textContent = `${earlyExitTime.toFixed(2)}ms`; earlyExitValueEl.style.color = "#f5f5f5"; } else { earlyExitValueEl.textContent = "—"; earlyExitValueEl.style.color = "#666"; } // Find Nodes const findNodesEl = document.getElementById("timingFindNodes"); const findNodesValueEl = document.getElementById("timingFindNodesValue"); findNodesEl.style.display = "flex"; const nodeLookupTime = timings["nodeLookup"]; if (nodeLookupTime !== undefined) { findNodesValueEl.textContent = `${nodeLookupTime.toFixed(2)}ms`; findNodesValueEl.style.color = "#f5f5f5"; } else { findNodesValueEl.textContent = "—"; findNodesValueEl.style.color = "#666"; } // Abstract Path const abstractPathEl = document.getElementById("timingAbstractPath"); const abstractPathValueEl = document.getElementById( "timingAbstractPathValue", ); abstractPathEl.style.display = "flex"; const abstractPathTime = timings["abstractPath"]; if (abstractPathTime !== undefined) { abstractPathValueEl.textContent = `${abstractPathTime.toFixed(2)}ms`; abstractPathValueEl.style.color = "#f5f5f5"; } else { abstractPathValueEl.textContent = "—"; abstractPathValueEl.style.color = "#666"; } // Initial Path const initialPathEl = document.getElementById("timingInitialPath"); const initialPathValueEl = document.getElementById("timingInitialPathValue"); initialPathEl.style.display = "flex"; const initialPathTime = timings["initialPath"]; if (initialPathTime !== undefined) { initialPathValueEl.textContent = `${initialPathTime.toFixed(2)}ms`; initialPathValueEl.style.color = "#f5f5f5"; } else { initialPathValueEl.textContent = "—"; initialPathValueEl.style.color = "#666"; } // Smooth Path const smoothPathEl = document.getElementById("timingSmoothPath"); const smoothPathValueEl = document.getElementById("timingSmoothPathValue"); smoothPathEl.style.display = "flex"; const smoothPathTime = timings["smoothingTransformer"]; if (smoothPathTime !== undefined) { smoothPathValueEl.textContent = `${smoothPathTime.toFixed(2)}ms`; smoothPathValueEl.style.color = "#f5f5f5"; } else { smoothPathValueEl.textContent = "—"; smoothPathValueEl.style.color = "#666"; } // Show comparisons section const comparisonsSection = document.getElementById("comparisonsSection"); const comparisonsContainer = document.getElementById("comparisonsContainer"); // Only show comparisons section if we have adapters loaded if (!state.adapters || state.adapters.length === 0) { comparisonsSection.style.display = "none"; return; } comparisonsSection.style.display = "block"; // Build lookup map for comparison data const compMap = {}; if (result.comparisons) { for (const comp of result.comparisons) { compMap[comp.adapter] = comp; } } // Use total span time from DebugSpan let hpaTime = timings["findPath"] || 0; if (compMap["hpa.cached"]) { hpaTime = compMap["hpa.cached"].time; } // Show HPA* time and path length (or 0.00 in light gray if no data) const hpaTimeEl = document.getElementById("hpaTime"); if (hpaTime > 0) { hpaTimeEl.textContent = `${hpaTime.toFixed(2)}ms`; hpaTimeEl.classList.remove("faded"); } else { hpaTimeEl.textContent = "0.00ms"; hpaTimeEl.classList.add("faded"); } // Find fastest time overall (including HPA*) when we have data const compTimes = result.comparisons ? result.comparisons.map((c) => c.time).filter((t) => t > 0) : []; const fastestCompTime = compTimes.length > 0 ? Math.min(...compTimes) : Infinity; // Update HPA* time color - green if fastest, red if slower than any comparison const hpaIsFastest = hpaTime > 0 && hpaTime <= fastestCompTime; const hpaSlower = hpaTime > 0 && fastestCompTime < hpaTime; const fastestTime = Math.min(hpaTime || Infinity, fastestCompTime); if (hpaIsFastest) { hpaTimeEl.style.color = "#00ff88"; } else if (hpaSlower) { hpaTimeEl.style.color = "#ff6666"; } else { hpaTimeEl.style.color = "#f5f5f5"; } // Build comparison rows for all known adapters let html = ""; for (const adapter of state.adapters) { const comp = compMap[adapter]; const pathColor = COMPARISON_COLORS[adapter] || "#ffffff"; const isActive = state.visibleComparisons.has(adapter); // Show actual values or placeholders const hasData = comp && comp.time > 0; const isFastest = hasData && comp.time === fastestTime; const timeColor = isFastest ? "#00ff88" : hasData ? "#f5f5f5" : "#666"; const tilesText = hasData ? comp.length : "—"; const timeText = hasData ? `${comp.time.toFixed(2)}ms` : "—"; html += `