From dc950c58f803346ebdd72da36278390e0e6cbd37 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Mon, 11 May 2026 23:30:55 +0200 Subject: [PATCH 1/9] revert to f2db639 --- app/javascript/channels/map_channel.js | 42 ++-- .../maplibre/controls/buttons/select.js | 17 +- app/javascript/maplibre/map.js | 187 +++++++++++++++--- 3 files changed, 202 insertions(+), 44 deletions(-) diff --git a/app/javascript/channels/map_channel.js b/app/javascript/channels/map_channel.js index 437a3c1c..8c6765e6 100644 --- a/app/javascript/channels/map_channel.js +++ b/app/javascript/channels/map_channel.js @@ -1,4 +1,5 @@ import consumer from 'channels/consumer' +import { status } from 'helpers/status' import { createLayerInstance } from 'maplibre/layers/factory' import { initializeLayerSources, initializeLayerStyles, layers, loadLayerDefinitions } from 'maplibre/layers/layers' import { @@ -16,7 +17,11 @@ import { export let mapChannel let channelStatus let connectionUUID -let remoteCursors = new Set(); +let remoteCursors = new Set() +let wasHiddenSinceLastConnect = false +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') wasHiddenSinceLastConnect = true +}); ['turbo:before-visit'].forEach(function (e) { window.addEventListener(e, function () { @@ -44,20 +49,31 @@ export function initializeSocket () { window.mapChannel = mapChannel // only reload data when there has been a connection before, to avoid double load if (channelStatus === 'off') { - reloadMapProperties().then(() => { - initializeMaplibreProperties() - loadLayerDefinitions().then(() => { - // If basemap actually changed, setBackgroundMapLayer() will trigger - // initializeStyles() via style.load (which re-initializes layer sources/styles). - // If not, we re-initialize them directly to catch up on any missed updates. - if (!setBackgroundMapLayer()) { - initializeLayerSources() - initializeLayerStyles() - } - map.fire('load', { detail: { message: 'Map re-loaded by map_channel' } }) + // Skip the heavy rebuild when the page was hidden at any point since + // the last connect — MapLibre is already reloading evicted tiles, and + // piling a layer/style re-init on top contributes to the post-resume + // freeze. Real network reconnects (no backgrounding) still rebuild. + if (wasHiddenSinceLastConnect) { + status('WS reconnect: skip rebuild (visibility)', 'info', 'medium', 1500) + } else { + status('WS reconnect: rebuilding', 'info', 'medium', 1500) + reloadMapProperties().then(() => { + initializeMaplibreProperties() + loadLayerDefinitions().then(() => { + // If basemap actually changed, setBackgroundMapLayer() will trigger + // initializeStyles() via style.load (which re-initializes layer sources/styles). + // If not, we re-initialize them directly to catch up on any missed updates. + if (!setBackgroundMapLayer()) { + initializeLayerSources() + initializeLayerStyles() + } + status('WS reconnect: rebuild done', 'info', 'medium', 1500) + map.fire('load', { detail: { message: 'Map re-loaded by map_channel' } }) + }) }) - }) + } } + wasHiddenSinceLastConnect = false consumer.connection.webSocket.onerror = function (_event) { map.fire('offline', { detail: { message: 'Websocket error' } }) channelStatus = 'off' diff --git a/app/javascript/maplibre/controls/buttons/select.js b/app/javascript/maplibre/controls/buttons/select.js index d43c5497..ca505ebf 100644 --- a/app/javascript/maplibre/controls/buttons/select.js +++ b/app/javascript/maplibre/controls/buttons/select.js @@ -1,13 +1,26 @@ +import { status } from 'helpers/status'; import { resetEditControls } from 'maplibre/controls/edit'; import { resetControls } from 'maplibre/controls/shared'; -import { recoverHandlers } from 'maplibre/map'; export class MapSelectControl { constructor (_options) { this._container = document.createElement('div') this._container.innerHTML = '' this._container.onclick = function (e) { - recoverHandlers() + + // Debugging map freeze + status("Select Mode") + map.stop() + map.resize() + map.triggerRepaint() + map.scrollZoom.enable() + map.doubleClickZoom.enable() + map.keyboard.enable() + map.boxZoom.enable() + map.dragRotate.enable() + map.touchZoomRotate.enable() + map.touchPitch.enable() + resetControls() resetEditControls() e.target.closest('button').classList.add('active') diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index ef6a966a..607c4b75 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -26,28 +26,6 @@ let backgroundHillshade let backgroundGlobe let backgroundContours -// Cycle handler enable state and dispatch synthetic pointer-up events to clear -// any wedged interaction state. Idempotent — safe to call any time. Triggered -// automatically on first idle after visibility resume, and manually via the -// select-mode button click. -export function recoverHandlers () { - status("Recovering handlers") - const opts = { bubbles: true, cancelable: true } - window.dispatchEvent(new MouseEvent('mouseup', opts)) - window.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerType: 'mouse' })) - const cycle = (h) => { h.disable(); h.enable() } - cycle(map.scrollZoom) - cycle(map.doubleClickZoom) - cycle(map.keyboard) - cycle(map.boxZoom) - cycle(map.touchPitch) - if (!isGeolocateCompassModeActive()) { - cycle(map.dragRotate) - cycle(map.touchZoomRotate) - if (!draw || draw.getMode() !== 'draw_paint_mode') cycle(map.dragPan) - } -} - // Workflow of map loading: // // initializeMap() @@ -167,16 +145,167 @@ export async function initializeMap (divId = 'maplibre-map') { map.on('online', (_e) => { functions.e('#maplibre-map', e => { e.setAttribute('data-online', true) }) }) map.on('offline', (_e) => { functions.e('#maplibre-map', e => { e.setAttribute('data-online', false) }) }) - // After long backgrounding, the browser evicts MapLibre's tile cache. On - // resume MapLibre fires a storm of source/data events that can wedge the - // interaction handlers' internal state — clicks still work, drag/zoom don't. - // recoverHandlers (module-level, exported) cycles handler enable state and - // dispatches synthetic pointer-up events to clear any in-flight gesture - // state. Runs on first idle (storm settled). + // Render heartbeat — used by the visibilitychange watchdog to detect freezes. + let lastRenderAt = performance.now() + map.on('render', () => { lastRenderAt = performance.now() }) + + // Canvas-level WebGL context handlers. Calling preventDefault() on the lost + // event signals the browser we want context restoration; without it the + // canvas stays dead silently while DOM events keep firing (matches the + // "clicks work, drag dead" symptom). + const mapCanvas = map.getCanvas() + mapCanvas.addEventListener('webglcontextlost', (e) => { + e.preventDefault() + console.warn('WebGL context lost') + status('Map context lost', 'warning') + }, false) + mapCanvas.addEventListener('webglcontextrestored', () => { + console.log('WebGL context restored') + status('Map context restored', 'info') + map.triggerRepaint() + }, false) + + // Diagnostic state shared with the input watchdog so freeze toasts can carry + // the post-resume map activity summary. Tracked for 10s after each resume. + let postResumeT0 = 0 + let postResumeRenderCount = 0 + let postResumeMaxGap = 0 + let postResumeLastRender = 0 + let postResumeCounts = {} + const trackedEvents = ['load', 'idle', 'dataloading', 'data', 'sourcedataloading', 'sourcedata', + 'styledataloading', 'styledata', 'error', 'movestart', 'moveend', 'zoomstart', 'zoomend', + 'dragstart', 'dragend'] + + const diagnosticSummary = () => { + const elapsed = Math.round(performance.now() - postResumeT0) + return `t+${elapsed}ms R:${postResumeRenderCount} gap:${Math.round(postResumeMaxGap)}ms ` + + Object.entries(postResumeCounts).filter(([_, n]) => n > 0) + .map(([k, n]) => `${k.replace('source', 'src').replace('style', 'sty').replace('loading', 'L')}×${n}`).join(' ') + } + + // Heaviest recovery: force a WebGL context loss/restore cycle. MapLibre's + // webglcontextrestored handler rebuilds buffers, reuploads textures, and + // restarts the render loop — this is what fixes a fully dead rAF chain that + // triggerRepaint can't kick. Idempotent within a 30s window. + let lastGLResetAt = 0 + const forceGLReset = () => { + if (performance.now() - lastGLResetAt < 30000) return + lastGLResetAt = performance.now() + const gl = mapCanvas.getContext('webgl2') || mapCanvas.getContext('webgl') + const ext = gl?.getExtension('WEBGL_lose_context') + if (!ext) { + status('Recovery: WEBGL_lose_context unavailable', 'warning', 'medium', 3000) + return + } + status('Recovery: forcing GL reset', 'info', 'medium', 2000) + ext.loseContext() + setTimeout(() => ext.restoreContext(), 100) + } + + // Visibility watchdog — silently tracks map activity for 10s after resume so + // the input/render freeze toasts can include the diagnostic summary inline + // (no separate toast that would be clobbered by the freeze toast). document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return - // map.once('idle', recoverHandlers) + status('Visibility: visible', 'info', 'medium', 1000) + + postResumeT0 = performance.now() + postResumeRenderCount = 0 + postResumeMaxGap = 0 + postResumeLastRender = postResumeT0 + postResumeCounts = {} + + const handlers = {} + trackedEvents.forEach(name => { + handlers[name] = () => { postResumeCounts[name] = (postResumeCounts[name] || 0) + 1 } + map.on(name, handlers[name]) + }) + handlers.render = () => { + const now = performance.now() + const gap = now - postResumeLastRender + if (gap > postResumeMaxGap) postResumeMaxGap = gap + postResumeLastRender = now + postResumeRenderCount++ + } + map.on('render', handlers.render) + + const beforeRepaint = performance.now() + map.triggerRepaint() + setTimeout(() => { + const renderedSinceWatchdog = lastRenderAt > beforeRepaint + const glLost = map.painter?.context?.gl?.isContextLost?.() + if (!renderedSinceWatchdog || glLost) { + status(`Map render frozen (gl_lost=${glLost}) | ${diagnosticSummary()}`, 'warning', 'medium', 10000) + forceGLReset() + } + }, 500) + + setTimeout(() => { + trackedEvents.forEach(name => map.off(name, handlers[name])) + map.off('render', handlers.render) + }, 10000) + + // Recovery: after the post-resume tile/data storm settles (first 'idle'), + // clear any stuck gesture state with synthetic pointer-up events and cycle + // handler enable state. .enable() alone doesn't reset internal handler + // state — disable+enable does. Fallback timeout covers the case where idle + // never fires. + let recoveryDone = false + const doRecovery = () => { + if (recoveryDone) return + recoveryDone = true + const opts = { bubbles: true, cancelable: true } + window.dispatchEvent(new MouseEvent('mouseup', opts)) + window.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerType: 'mouse' })) + mapCanvas.dispatchEvent(new MouseEvent('mouseup', opts)) + mapCanvas.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerType: 'mouse' })) + const cycle = (h) => { h.disable(); h.enable() } + cycle(map.scrollZoom) + cycle(map.doubleClickZoom) + cycle(map.keyboard) + cycle(map.boxZoom) + cycle(map.touchPitch) + if (!isGeolocateCompassModeActive()) { + cycle(map.dragRotate) + cycle(map.touchZoomRotate) + if (!draw || draw.getMode() !== 'draw_paint_mode') cycle(map.dragPan) + } + status('Recovery: handlers reset', 'info', 'medium', 2000) + } + map.once('idle', doRecovery) + setTimeout(doRecovery, 8000) + }) + + // Input watchdog — detects handler-level freezes where render still works but + // pointer drag doesn't translate to map movement (clicks work, drag doesn't). + let pointerDownAt = null + let pointerDownX = 0 + let pointerDownY = 0 + let mapMovedSincePointerDown = false + let inputFreezeReported = false + map.on('movestart', () => { if (pointerDownAt) mapMovedSincePointerDown = true }) + mapCanvas.addEventListener('pointerdown', (e) => { + pointerDownAt = performance.now() + pointerDownX = e.clientX + pointerDownY = e.clientY + mapMovedSincePointerDown = false + inputFreezeReported = false + }) + mapCanvas.addEventListener('pointermove', (e) => { + if (!pointerDownAt || inputFreezeReported) return + const dx = e.clientX - pointerDownX + const dy = e.clientY - pointerDownY + if (Math.sqrt(dx * dx + dy * dy) < 15) return + if (mapMovedSincePointerDown) return + if (performance.now() - pointerDownAt < 150) return + inputFreezeReported = true + const glLost = map.painter?.context?.gl?.isContextLost?.() + console.warn('Map drag input frozen', { glLost }) + status(`Map drag frozen (gl_lost=${glLost}) | ${diagnosticSummary()}`, 'warning', 'medium', 10000) + forceGLReset() }) + mapCanvas.addEventListener('pointerup', () => { pointerDownAt = null }) + mapCanvas.addEventListener('pointercancel', () => { pointerDownAt = null }) map.on('contextmenu', (e) => { e.preventDefault() From 8d5b41f271ff513f3790c28e08d597d886a406e7 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Tue, 12 May 2026 00:02:34 +0200 Subject: [PATCH 2/9] tag production images 'production', to not rely on branch name --- .github/workflows/docker-base.yml | 4 ++-- .github/workflows/docker-publish.yml | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-base.yml b/.github/workflows/docker-base.yml index f4be7511..b909c213 100644 --- a/.github/workflows/docker-base.yml +++ b/.github/workflows/docker-base.yml @@ -1,4 +1,4 @@ -name: Docker base image +name: Build base container image on: # rebuild image with updated patches each night: @@ -50,4 +50,4 @@ jobs: tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest push: true cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 3f66e0a1..bf83a10d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,4 +1,4 @@ -name: Docker production image +name: Build production container image # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by @@ -90,6 +90,11 @@ jobs: uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha + type=raw,value=production # Build and push Docker image with Buildx (don't push on PR) # https://github.com/docker/build-push-action From 222a0af7cd3b59dde5e136dcc9be15afdf3973b5 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Tue, 12 May 2026 00:24:36 +0200 Subject: [PATCH 3/9] merge main --- app/javascript/channels/map_channel.js | 44 +++++++------------------- app/javascript/maplibre/map.js | 3 -- 2 files changed, 12 insertions(+), 35 deletions(-) diff --git a/app/javascript/channels/map_channel.js b/app/javascript/channels/map_channel.js index 898d01d8..6141afa2 100644 --- a/app/javascript/channels/map_channel.js +++ b/app/javascript/channels/map_channel.js @@ -1,5 +1,4 @@ import consumer from 'channels/consumer' -import { status } from 'helpers/status' import { createLayerInstance } from 'maplibre/layers/factory' import { initializeLayerSources, initializeLayerStyles, layers, loadLayerDefinitions } from 'maplibre/layers/layers' import { @@ -17,15 +16,7 @@ import { export let mapChannel let channelStatus let connectionUUID -<<<<<<< fix_freeze -let remoteCursors = new Set() -let wasHiddenSinceLastConnect = false -document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') wasHiddenSinceLastConnect = true -}); -======= let remoteCursors = {}; ->>>>>>> main ['turbo:before-visit'].forEach(function (e) { window.addEventListener(e, function () { @@ -53,31 +44,20 @@ export function initializeSocket () { window.mapChannel = mapChannel // only reload data when there has been a connection before, to avoid double load if (channelStatus === 'off') { - // Skip the heavy rebuild when the page was hidden at any point since - // the last connect — MapLibre is already reloading evicted tiles, and - // piling a layer/style re-init on top contributes to the post-resume - // freeze. Real network reconnects (no backgrounding) still rebuild. - if (wasHiddenSinceLastConnect) { - status('WS reconnect: skip rebuild (visibility)', 'info', 'medium', 1500) - } else { - status('WS reconnect: rebuilding', 'info', 'medium', 1500) - reloadMapProperties().then(() => { - initializeMaplibreProperties() - loadLayerDefinitions().then(() => { - // If basemap actually changed, setBackgroundMapLayer() will trigger - // initializeStyles() via style.load (which re-initializes layer sources/styles). - // If not, we re-initialize them directly to catch up on any missed updates. - if (!setBackgroundMapLayer()) { - initializeLayerSources() - initializeLayerStyles() - } - status('WS reconnect: rebuild done', 'info', 'medium', 1500) - map.fire('load', { detail: { message: 'Map re-loaded by map_channel' } }) - }) + reloadMapProperties().then(() => { + initializeMaplibreProperties() + loadLayerDefinitions().then(() => { + // If basemap actually changed, setBackgroundMapLayer() will trigger + // initializeStyles() via style.load (which re-initializes layer sources/styles). + // If not, we re-initialize them directly to catch up on any missed updates. + if (!setBackgroundMapLayer()) { + initializeLayerSources() + initializeLayerStyles() + } + map.fire('load', { detail: { message: 'Map re-loaded by map_channel' } }) }) - } + }) } - wasHiddenSinceLastConnect = false consumer.connection.webSocket.onerror = function (_event) { map.fire('offline', { detail: { message: 'Websocket error' } }) channelStatus = 'off' diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index 0ea63e15..c85acccb 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -26,8 +26,6 @@ let backgroundHillshade let backgroundGlobe let backgroundContours -<<<<<<< fix_freeze -======= // Cycle handler enable state and dispatch synthetic pointer-up events to clear // any wedged interaction state. Idempotent — safe to call any time. Triggered // automatically on first idle after visibility resume, and manually via the @@ -60,7 +58,6 @@ document.addEventListener('visibilitychange', () => { // map.once('idle', recoverHandlers) }) ->>>>>>> main // Workflow of map loading: // // initializeMap() From 3516291339a5acbbbe5ce771b743567871036404 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Tue, 12 May 2026 10:45:57 +0200 Subject: [PATCH 4/9] collect errors --- .../maplibre/controls/buttons/select.js | 11 ++------ app/javascript/maplibre/map.js | 28 +++++++++++++++++-- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/app/javascript/maplibre/controls/buttons/select.js b/app/javascript/maplibre/controls/buttons/select.js index ca505ebf..89d34006 100644 --- a/app/javascript/maplibre/controls/buttons/select.js +++ b/app/javascript/maplibre/controls/buttons/select.js @@ -1,25 +1,18 @@ import { status } from 'helpers/status'; import { resetEditControls } from 'maplibre/controls/edit'; import { resetControls } from 'maplibre/controls/shared'; +import { recoverHandlers } from 'maplibre/map'; export class MapSelectControl { constructor (_options) { this._container = document.createElement('div') this._container.innerHTML = '' this._container.onclick = function (e) { - - // Debugging map freeze status("Select Mode") map.stop() map.resize() map.triggerRepaint() - map.scrollZoom.enable() - map.doubleClickZoom.enable() - map.keyboard.enable() - map.boxZoom.enable() - map.dragRotate.enable() - map.touchZoomRotate.enable() - map.touchPitch.enable() + recoverHandlers() resetControls() resetEditControls() diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index c85acccb..ecc54a36 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -204,15 +204,21 @@ export async function initializeMap (divId = 'maplibre-map') { let postResumeMaxGap = 0 let postResumeLastRender = 0 let postResumeCounts = {} + let postResumeErrors = [] + const POST_RESUME_MAX_ERRORS = 5 const trackedEvents = ['load', 'idle', 'dataloading', 'data', 'sourcedataloading', 'sourcedata', 'styledataloading', 'styledata', 'error', 'movestart', 'moveend', 'zoomstart', 'zoomend', 'dragstart', 'dragend'] const diagnosticSummary = () => { const elapsed = Math.round(performance.now() - postResumeT0) - return `t+${elapsed}ms R:${postResumeRenderCount} gap:${Math.round(postResumeMaxGap)}ms ` + + let summary = `t+${elapsed}ms R:${postResumeRenderCount} gap:${Math.round(postResumeMaxGap)}ms ` + Object.entries(postResumeCounts).filter(([_, n]) => n > 0) .map(([k, n]) => `${k.replace('source', 'src').replace('style', 'sty').replace('loading', 'L')}×${n}`).join(' ') + if (postResumeErrors.length > 0) { + summary += ' | err: ' + postResumeErrors.join('; ') + } + return summary } // Heaviest recovery: force a WebGL context loss/restore cycle. MapLibre's @@ -246,10 +252,22 @@ export async function initializeMap (divId = 'maplibre-map') { postResumeMaxGap = 0 postResumeLastRender = postResumeT0 postResumeCounts = {} + postResumeErrors = [] const handlers = {} trackedEvents.forEach(name => { - handlers[name] = () => { postResumeCounts[name] = (postResumeCounts[name] || 0) + 1 } + if (name === 'error') { + handlers[name] = (e) => { + postResumeCounts[name] = (postResumeCounts[name] || 0) + 1 + const msg = e?.error?.message || 'unknown' + const src = e?.sourceId ? `[${e.sourceId}]` : '' + const label = (src + msg).slice(0, 60) + if (postResumeErrors.length >= POST_RESUME_MAX_ERRORS) postResumeErrors.shift() + postResumeErrors.push(label) + } + } else { + handlers[name] = () => { postResumeCounts[name] = (postResumeCounts[name] || 0) + 1 } + } map.on(name, handlers[name]) }) handlers.render = () => { @@ -334,7 +352,11 @@ export async function initializeMap (divId = 'maplibre-map') { const glLost = map.painter?.context?.gl?.isContextLost?.() console.warn('Map drag input frozen', { glLost }) status(`Map drag frozen (gl_lost=${glLost}) | ${diagnosticSummary()}`, 'warning', 'medium', 10000) - forceGLReset() + if (glLost) { + forceGLReset() + } else { + recoverHandlers() + } }) mapCanvas.addEventListener('pointerup', () => { pointerDownAt = null }) mapCanvas.addEventListener('pointercancel', () => { pointerDownAt = null }) From 8f4a8ff76279693a285a589172516f2cf5495c35 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Tue, 12 May 2026 12:06:25 +0200 Subject: [PATCH 5/9] force gl reset on select button --- .../maplibre/controls/buttons/select.js | 3 +- app/javascript/maplibre/map.js | 40 +++++++++---------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/app/javascript/maplibre/controls/buttons/select.js b/app/javascript/maplibre/controls/buttons/select.js index 89d34006..e4ce0fdb 100644 --- a/app/javascript/maplibre/controls/buttons/select.js +++ b/app/javascript/maplibre/controls/buttons/select.js @@ -1,7 +1,7 @@ import { status } from 'helpers/status'; import { resetEditControls } from 'maplibre/controls/edit'; import { resetControls } from 'maplibre/controls/shared'; -import { recoverHandlers } from 'maplibre/map'; +import { recoverHandlers, forceGLReset } from 'maplibre/map'; export class MapSelectControl { constructor (_options) { @@ -13,6 +13,7 @@ export class MapSelectControl { map.resize() map.triggerRepaint() recoverHandlers() + forceGLReset() resetControls() resetEditControls() diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index ecc54a36..659dec18 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -25,13 +25,13 @@ let backgroundTerrain let backgroundHillshade let backgroundGlobe let backgroundContours +let lastGLResetAt = 0 // Cycle handler enable state and dispatch synthetic pointer-up events to clear // any wedged interaction state. Idempotent — safe to call any time. Triggered // automatically on first idle after visibility resume, and manually via the // select-mode button click. export function recoverHandlers () { - status("Recovering handlers") const opts = { bubbles: true, cancelable: true } window.dispatchEvent(new MouseEvent('mouseup', opts)) if (window.PointerEvent) { @@ -50,6 +50,25 @@ export function recoverHandlers () { } } +// Heaviest recovery: force a WebGL context loss/restore cycle. MapLibre's +// webglcontextrestored handler rebuilds buffers, reuploads textures, and +// restarts the render loop — this is what fixes a fully dead rAF chain that +// triggerRepaint can't kick. Idempotent within a 30s window. +export function forceGLReset () { + if (performance.now() - lastGLResetAt < 30000) return + lastGLResetAt = performance.now() + const mapCanvas = map.getCanvas() + const gl = mapCanvas?.getContext('webgl2') || mapCanvas?.getContext('webgl') + const ext = gl?.getExtension('WEBGL_lose_context') + if (!ext) { + status('Recovery: WEBGL_lose_context unavailable', 'warning', 'medium', 3000) + return + } + status('Recovery: forcing GL reset', 'info', 'medium', 2000) + ext.loseContext() + setTimeout(() => ext.restoreContext(), 100) +} + // Module-scope visibilitychange listener — registered once. Inside initializeMap // would re-register on every Stimulus reconnect (Turbo navigation), accumulating // stale closures. Guard with `!map` since this can fire before initializeMap runs. @@ -221,25 +240,6 @@ export async function initializeMap (divId = 'maplibre-map') { return summary } - // Heaviest recovery: force a WebGL context loss/restore cycle. MapLibre's - // webglcontextrestored handler rebuilds buffers, reuploads textures, and - // restarts the render loop — this is what fixes a fully dead rAF chain that - // triggerRepaint can't kick. Idempotent within a 30s window. - let lastGLResetAt = 0 - const forceGLReset = () => { - if (performance.now() - lastGLResetAt < 30000) return - lastGLResetAt = performance.now() - const gl = mapCanvas.getContext('webgl2') || mapCanvas.getContext('webgl') - const ext = gl?.getExtension('WEBGL_lose_context') - if (!ext) { - status('Recovery: WEBGL_lose_context unavailable', 'warning', 'medium', 3000) - return - } - status('Recovery: forcing GL reset', 'info', 'medium', 2000) - ext.loseContext() - setTimeout(() => ext.restoreContext(), 100) - } - // Visibility watchdog — silently tracks map activity for 10s after resume so // the input/render freeze toasts can include the diagnostic summary inline // (no separate toast that would be clobbered by the freeze toast). From 53989ce9fe616acf2c62630c7daf0e33408297f1 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Tue, 12 May 2026 14:30:03 +0200 Subject: [PATCH 6/9] recover after websocket reconnect --- app/javascript/channels/map_channel.js | 4 ++++ app/javascript/maplibre/map.js | 32 +++++++++++++------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/app/javascript/channels/map_channel.js b/app/javascript/channels/map_channel.js index 6141afa2..4be5d30a 100644 --- a/app/javascript/channels/map_channel.js +++ b/app/javascript/channels/map_channel.js @@ -6,6 +6,7 @@ import { initializeMaplibreProperties, map, mapProperties, reloadMapProperties, + recoverHandlers, setBackgroundMapLayer, setLayerVisibility, updateMapName, @@ -55,6 +56,9 @@ export function initializeSocket () { initializeLayerStyles() } map.fire('load', { detail: { message: 'Map re-loaded by map_channel' } }) + // Recover handlers after the heavy data reload completes, in case the + // reload left handlers in a wedged state (drag frozen but render working). + recoverHandlers() }) }) } diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index 659dec18..a3e9a869 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -33,9 +33,18 @@ let lastGLResetAt = 0 // select-mode button click. export function recoverHandlers () { const opts = { bubbles: true, cancelable: true } + const mapCanvas = map.getCanvas() + // Dispatch on both window and canvas — MapLibre's HandlerManager + // listens on canvas for some events, window for others. window.dispatchEvent(new MouseEvent('mouseup', opts)) + mapCanvas.dispatchEvent(new MouseEvent('mouseup', opts)) if (window.PointerEvent) { + // Dispatch both mouse and touch pointer types to clear state for both. + // On phones, real events use pointerType:'touch', so mouse-type events alone don't clear touch state. window.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerType: 'mouse' })) + window.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerType: 'touch' })) + mapCanvas.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerType: 'mouse' })) + mapCanvas.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerType: 'touch' })) } const cycle = (h) => { h.disable(); h.enable() } cycle(map.scrollZoom) @@ -237,6 +246,12 @@ export async function initializeMap (divId = 'maplibre-map') { if (postResumeErrors.length > 0) { summary += ' | err: ' + postResumeErrors.join('; ') } + // Add map/handler state for freeze diagnosis + const mov = map.isMoving() ? 'T' : 'F' + const eas = map.isEasing() ? 'T' : 'F' + const dpE = map.dragPan.isEnabled() ? 'E' : 'D' + const dpA = map.dragPan.isActive() ? 'A' : 'I' + summary += ` | mov:${mov} eas:${eas} dp:${dpE}/${dpA}` return summary } @@ -304,22 +319,7 @@ export async function initializeMap (divId = 'maplibre-map') { const doRecovery = () => { if (recoveryDone) return recoveryDone = true - const opts = { bubbles: true, cancelable: true } - window.dispatchEvent(new MouseEvent('mouseup', opts)) - window.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerType: 'mouse' })) - mapCanvas.dispatchEvent(new MouseEvent('mouseup', opts)) - mapCanvas.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerType: 'mouse' })) - const cycle = (h) => { h.disable(); h.enable() } - cycle(map.scrollZoom) - cycle(map.doubleClickZoom) - cycle(map.keyboard) - cycle(map.boxZoom) - cycle(map.touchPitch) - if (!isGeolocateCompassModeActive()) { - cycle(map.dragRotate) - cycle(map.touchZoomRotate) - if (!draw || draw.getMode() !== 'draw_paint_mode') cycle(map.dragPan) - } + recoverHandlers() status('Recovery: handlers reset', 'info', 'medium', 2000) } map.once('idle', doRecovery) From a76039cf1fd8032c24a92f891ca02b598177863d Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Tue, 12 May 2026 16:37:03 +0200 Subject: [PATCH 7/9] additional debug --- app/javascript/channels/map_channel.js | 8 ++++---- app/javascript/maplibre/map.js | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/javascript/channels/map_channel.js b/app/javascript/channels/map_channel.js index 4be5d30a..4e6951e2 100644 --- a/app/javascript/channels/map_channel.js +++ b/app/javascript/channels/map_channel.js @@ -47,17 +47,17 @@ export function initializeSocket () { if (channelStatus === 'off') { reloadMapProperties().then(() => { initializeMaplibreProperties() - loadLayerDefinitions().then(() => { + loadLayerDefinitions().then(async () => { // If basemap actually changed, setBackgroundMapLayer() will trigger // initializeStyles() via style.load (which re-initializes layer sources/styles). // If not, we re-initialize them directly to catch up on any missed updates. if (!setBackgroundMapLayer()) { initializeLayerSources() - initializeLayerStyles() + await initializeLayerStyles() } map.fire('load', { detail: { message: 'Map re-loaded by map_channel' } }) - // Recover handlers after the heavy data reload completes, in case the - // reload left handlers in a wedged state (drag frozen but render working). + // Recover handlers after the heavy async work (layer initialization, + // sortLayers) completes, so the handler reset doesn't get re-wedged. recoverHandlers() }) }) diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index a3e9a869..faea18b4 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -57,6 +57,15 @@ export function recoverHandlers () { cycle(map.touchZoomRotate) if (!draw || draw.getMode() !== 'draw_paint_mode') cycle(map.dragPan) } + + // Deferred re-check: queued browser events (real pointerdown from user's + // finger still on screen) fire AFTER our synchronous code. Catch re-activation. + setTimeout(() => { + if (map.dragPan?.isActive() && !map.isMoving()) { + map.dragPan.disable() + map.dragPan.enable() + } + }, 100) } // Heaviest recovery: force a WebGL context loss/restore cycle. MapLibre's @@ -709,11 +718,14 @@ export function setBackgroundMapLayer (mapName = mapProperties.base_map, force = basemap = basemaps()['osmRasterTiles'] } if (basemap) { - map.once('style.load', () => { + map.once('style.load', async () => { status('Loaded base map ' + mapName) // on map style change, all sources and layers are removed, so we need to re-initialize them - initializeStyles() + await initializeStyles() limitZoom() + // Recover handlers after the async layer initialization completes. + // During reconnection, this runs AFTER the heavy sortLayers/setData work. + recoverHandlers() }) backgroundMapLayer = mapName backgroundTerrain = mapProperties.terrain From d6deeaa63ba46e3dd9bfab6fc70e104220222617 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Tue, 12 May 2026 17:25:45 +0200 Subject: [PATCH 8/9] unfreeze when map is moving --- app/javascript/maplibre/map.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index faea18b4..759f5527 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -61,7 +61,7 @@ export function recoverHandlers () { // Deferred re-check: queued browser events (real pointerdown from user's // finger still on screen) fire AFTER our synchronous code. Catch re-activation. setTimeout(() => { - if (map.dragPan?.isActive() && !map.isMoving()) { + if (map.dragPan?.isActive() && !map.isEasing()) { map.dragPan.disable() map.dragPan.enable() } From 3210e2105fd07bd1d0c168ae12c60273914867d8 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Tue, 12 May 2026 21:12:12 +0200 Subject: [PATCH 9/9] Real fix (i hope) --- app/javascript/application.js | 5 +- app/javascript/channels/map_channel.js | 5 +- .../maplibre/controls/buttons/select.js | 9 - app/javascript/maplibre/map.js | 234 +----------------- 4 files changed, 6 insertions(+), 247 deletions(-) diff --git a/app/javascript/application.js b/app/javascript/application.js index 94b36764..77c12e58 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,9 +1,9 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import '@hotwired/turbo-rails' -import 'stimulus-controllers-index' -import * as functions from 'helpers/functions' import AOS from 'aos' +import * as functions from 'helpers/functions' +import 'stimulus-controllers-index' // Note: Don't import map js here for faster frontpage load times @@ -21,7 +21,6 @@ window.addEventListener('turbo:load', function () { } }) - if ('serviceWorker' in navigator) { // Register the service worker window.addEventListener('load', () => { diff --git a/app/javascript/channels/map_channel.js b/app/javascript/channels/map_channel.js index 4e6951e2..b0880678 100644 --- a/app/javascript/channels/map_channel.js +++ b/app/javascript/channels/map_channel.js @@ -6,7 +6,6 @@ import { initializeMaplibreProperties, map, mapProperties, reloadMapProperties, - recoverHandlers, setBackgroundMapLayer, setLayerVisibility, updateMapName, @@ -56,10 +55,8 @@ export function initializeSocket () { await initializeLayerStyles() } map.fire('load', { detail: { message: 'Map re-loaded by map_channel' } }) - // Recover handlers after the heavy async work (layer initialization, - // sortLayers) completes, so the handler reset doesn't get re-wedged. - recoverHandlers() }) + // status('Connection to server re-established') }) } consumer.connection.webSocket.onerror = function (_event) { diff --git a/app/javascript/maplibre/controls/buttons/select.js b/app/javascript/maplibre/controls/buttons/select.js index e4ce0fdb..eca7c3ed 100644 --- a/app/javascript/maplibre/controls/buttons/select.js +++ b/app/javascript/maplibre/controls/buttons/select.js @@ -1,20 +1,11 @@ -import { status } from 'helpers/status'; import { resetEditControls } from 'maplibre/controls/edit'; import { resetControls } from 'maplibre/controls/shared'; -import { recoverHandlers, forceGLReset } from 'maplibre/map'; export class MapSelectControl { constructor (_options) { this._container = document.createElement('div') this._container.innerHTML = '' this._container.onclick = function (e) { - status("Select Mode") - map.stop() - map.resize() - map.triggerRepaint() - recoverHandlers() - forceGLReset() - resetControls() resetEditControls() e.target.closest('button').classList.add('active') diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index 759f5527..ae26d20f 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -6,10 +6,10 @@ import * as functions from 'helpers/functions'; import { status } from 'helpers/status'; import { AnimateLineAnimation, AnimatePointAnimation, AnimatePolygonAnimation, animateViewFromProperties } from 'maplibre/animations'; import { hideContextMenu, initContextMenu } from 'maplibre/controls/context_menu'; -import { isGeolocateCompassModeActive, isGeolocateFollowModeActive } from 'maplibre/controls/geolocate'; +import { isGeolocateFollowModeActive } from 'maplibre/controls/geolocate'; import { initCtrlTooltips, initializeDefaultControls, initSettingsModal, resetControls } from 'maplibre/controls/shared'; import { initializeViewControls } from 'maplibre/controls/view'; -import { draw, resetEditMode } from 'maplibre/edit'; +import { resetEditMode } from 'maplibre/edit'; import { highlightFeature, resetHighlightedFeature } from 'maplibre/feature'; import { getFeature, initializeLayers, initializeLayerSources, initializeLayerStyles, layers, renderLayers } from 'maplibre/layers/layers'; import { basemaps, defaultFont, demSource, elevationSource } from 'maplibre/styles/basemaps'; @@ -25,75 +25,6 @@ let backgroundTerrain let backgroundHillshade let backgroundGlobe let backgroundContours -let lastGLResetAt = 0 - -// Cycle handler enable state and dispatch synthetic pointer-up events to clear -// any wedged interaction state. Idempotent — safe to call any time. Triggered -// automatically on first idle after visibility resume, and manually via the -// select-mode button click. -export function recoverHandlers () { - const opts = { bubbles: true, cancelable: true } - const mapCanvas = map.getCanvas() - // Dispatch on both window and canvas — MapLibre's HandlerManager - // listens on canvas for some events, window for others. - window.dispatchEvent(new MouseEvent('mouseup', opts)) - mapCanvas.dispatchEvent(new MouseEvent('mouseup', opts)) - if (window.PointerEvent) { - // Dispatch both mouse and touch pointer types to clear state for both. - // On phones, real events use pointerType:'touch', so mouse-type events alone don't clear touch state. - window.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerType: 'mouse' })) - window.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerType: 'touch' })) - mapCanvas.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerType: 'mouse' })) - mapCanvas.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerType: 'touch' })) - } - const cycle = (h) => { h.disable(); h.enable() } - cycle(map.scrollZoom) - cycle(map.doubleClickZoom) - cycle(map.keyboard) - cycle(map.boxZoom) - cycle(map.touchPitch) - if (!isGeolocateCompassModeActive()) { - cycle(map.dragRotate) - cycle(map.touchZoomRotate) - if (!draw || draw.getMode() !== 'draw_paint_mode') cycle(map.dragPan) - } - - // Deferred re-check: queued browser events (real pointerdown from user's - // finger still on screen) fire AFTER our synchronous code. Catch re-activation. - setTimeout(() => { - if (map.dragPan?.isActive() && !map.isEasing()) { - map.dragPan.disable() - map.dragPan.enable() - } - }, 100) -} - -// Heaviest recovery: force a WebGL context loss/restore cycle. MapLibre's -// webglcontextrestored handler rebuilds buffers, reuploads textures, and -// restarts the render loop — this is what fixes a fully dead rAF chain that -// triggerRepaint can't kick. Idempotent within a 30s window. -export function forceGLReset () { - if (performance.now() - lastGLResetAt < 30000) return - lastGLResetAt = performance.now() - const mapCanvas = map.getCanvas() - const gl = mapCanvas?.getContext('webgl2') || mapCanvas?.getContext('webgl') - const ext = gl?.getExtension('WEBGL_lose_context') - if (!ext) { - status('Recovery: WEBGL_lose_context unavailable', 'warning', 'medium', 3000) - return - } - status('Recovery: forcing GL reset', 'info', 'medium', 2000) - ext.loseContext() - setTimeout(() => ext.restoreContext(), 100) -} - -// Module-scope visibilitychange listener — registered once. Inside initializeMap -// would re-register on every Stimulus reconnect (Turbo navigation), accumulating -// stale closures. Guard with `!map` since this can fire before initializeMap runs. -document.addEventListener('visibilitychange', () => { - if (document.visibilityState !== 'visible' || !map) return - // map.once('idle', recoverHandlers) -}) // Workflow of map loading: // @@ -208,168 +139,12 @@ export async function initializeMap (divId = 'maplibre-map') { map.on('touchend', (e) => { updateCursorPosition(e) }) map.on('drag', () => { mapInteracted = true - if (layers.filter(l => (l.type === 'overpass' || l.type === 'wikipedia') && l.show !== false).length) { dom.animateElement('#layer-reload', 'fade-in') } + if (layers && layers.filter(l => (l.type === 'overpass' || l.type === 'wikipedia') && l.show !== false).length) { dom.animateElement('#layer-reload', 'fade-in') } }) map.on('zoom', (_e) => { limitZoom() }) map.on('online', (_e) => { functions.e('#maplibre-map', e => { e.setAttribute('data-online', true) }) }) map.on('offline', (_e) => { functions.e('#maplibre-map', e => { e.setAttribute('data-online', false) }) }) - // Render heartbeat — used by the visibilitychange watchdog to detect freezes. - let lastRenderAt = performance.now() - map.on('render', () => { lastRenderAt = performance.now() }) - - // Canvas-level WebGL context handlers. Calling preventDefault() on the lost - // event signals the browser we want context restoration; without it the - // canvas stays dead silently while DOM events keep firing (matches the - // "clicks work, drag dead" symptom). - const mapCanvas = map.getCanvas() - mapCanvas.addEventListener('webglcontextlost', (e) => { - e.preventDefault() - console.warn('WebGL context lost') - status('Map context lost', 'warning') - }, false) - mapCanvas.addEventListener('webglcontextrestored', () => { - console.log('WebGL context restored') - status('Map context restored', 'info') - map.triggerRepaint() - }, false) - - // Diagnostic state shared with the input watchdog so freeze toasts can carry - // the post-resume map activity summary. Tracked for 10s after each resume. - let postResumeT0 = 0 - let postResumeRenderCount = 0 - let postResumeMaxGap = 0 - let postResumeLastRender = 0 - let postResumeCounts = {} - let postResumeErrors = [] - const POST_RESUME_MAX_ERRORS = 5 - const trackedEvents = ['load', 'idle', 'dataloading', 'data', 'sourcedataloading', 'sourcedata', - 'styledataloading', 'styledata', 'error', 'movestart', 'moveend', 'zoomstart', 'zoomend', - 'dragstart', 'dragend'] - - const diagnosticSummary = () => { - const elapsed = Math.round(performance.now() - postResumeT0) - let summary = `t+${elapsed}ms R:${postResumeRenderCount} gap:${Math.round(postResumeMaxGap)}ms ` + - Object.entries(postResumeCounts).filter(([_, n]) => n > 0) - .map(([k, n]) => `${k.replace('source', 'src').replace('style', 'sty').replace('loading', 'L')}×${n}`).join(' ') - if (postResumeErrors.length > 0) { - summary += ' | err: ' + postResumeErrors.join('; ') - } - // Add map/handler state for freeze diagnosis - const mov = map.isMoving() ? 'T' : 'F' - const eas = map.isEasing() ? 'T' : 'F' - const dpE = map.dragPan.isEnabled() ? 'E' : 'D' - const dpA = map.dragPan.isActive() ? 'A' : 'I' - summary += ` | mov:${mov} eas:${eas} dp:${dpE}/${dpA}` - return summary - } - - // Visibility watchdog — silently tracks map activity for 10s after resume so - // the input/render freeze toasts can include the diagnostic summary inline - // (no separate toast that would be clobbered by the freeze toast). - document.addEventListener('visibilitychange', () => { - if (document.visibilityState !== 'visible') return - status('Visibility: visible', 'info', 'medium', 1000) - - postResumeT0 = performance.now() - postResumeRenderCount = 0 - postResumeMaxGap = 0 - postResumeLastRender = postResumeT0 - postResumeCounts = {} - postResumeErrors = [] - - const handlers = {} - trackedEvents.forEach(name => { - if (name === 'error') { - handlers[name] = (e) => { - postResumeCounts[name] = (postResumeCounts[name] || 0) + 1 - const msg = e?.error?.message || 'unknown' - const src = e?.sourceId ? `[${e.sourceId}]` : '' - const label = (src + msg).slice(0, 60) - if (postResumeErrors.length >= POST_RESUME_MAX_ERRORS) postResumeErrors.shift() - postResumeErrors.push(label) - } - } else { - handlers[name] = () => { postResumeCounts[name] = (postResumeCounts[name] || 0) + 1 } - } - map.on(name, handlers[name]) - }) - handlers.render = () => { - const now = performance.now() - const gap = now - postResumeLastRender - if (gap > postResumeMaxGap) postResumeMaxGap = gap - postResumeLastRender = now - postResumeRenderCount++ - } - map.on('render', handlers.render) - - const beforeRepaint = performance.now() - map.triggerRepaint() - setTimeout(() => { - const renderedSinceWatchdog = lastRenderAt > beforeRepaint - const glLost = map.painter?.context?.gl?.isContextLost?.() - if (!renderedSinceWatchdog || glLost) { - status(`Map render frozen (gl_lost=${glLost}) | ${diagnosticSummary()}`, 'warning', 'medium', 10000) - forceGLReset() - } - }, 500) - - setTimeout(() => { - trackedEvents.forEach(name => map.off(name, handlers[name])) - map.off('render', handlers.render) - }, 10000) - - // Recovery: after the post-resume tile/data storm settles (first 'idle'), - // clear any stuck gesture state with synthetic pointer-up events and cycle - // handler enable state. .enable() alone doesn't reset internal handler - // state — disable+enable does. Fallback timeout covers the case where idle - // never fires. - let recoveryDone = false - const doRecovery = () => { - if (recoveryDone) return - recoveryDone = true - recoverHandlers() - status('Recovery: handlers reset', 'info', 'medium', 2000) - } - map.once('idle', doRecovery) - setTimeout(doRecovery, 8000) - }) - - // Input watchdog — detects handler-level freezes where render still works but - // pointer drag doesn't translate to map movement (clicks work, drag doesn't). - let pointerDownAt = null - let pointerDownX = 0 - let pointerDownY = 0 - let mapMovedSincePointerDown = false - let inputFreezeReported = false - map.on('movestart', () => { if (pointerDownAt) mapMovedSincePointerDown = true }) - mapCanvas.addEventListener('pointerdown', (e) => { - pointerDownAt = performance.now() - pointerDownX = e.clientX - pointerDownY = e.clientY - mapMovedSincePointerDown = false - inputFreezeReported = false - }) - mapCanvas.addEventListener('pointermove', (e) => { - if (!pointerDownAt || inputFreezeReported) return - const dx = e.clientX - pointerDownX - const dy = e.clientY - pointerDownY - if (Math.sqrt(dx * dx + dy * dy) < 15) return - if (mapMovedSincePointerDown) return - if (performance.now() - pointerDownAt < 150) return - inputFreezeReported = true - const glLost = map.painter?.context?.gl?.isContextLost?.() - console.warn('Map drag input frozen', { glLost }) - status(`Map drag frozen (gl_lost=${glLost}) | ${diagnosticSummary()}`, 'warning', 'medium', 10000) - if (glLost) { - forceGLReset() - } else { - recoverHandlers() - } - }) - mapCanvas.addEventListener('pointerup', () => { pointerDownAt = null }) - mapCanvas.addEventListener('pointercancel', () => { pointerDownAt = null }) - map.on('contextmenu', (e) => { e.preventDefault() // menu gets unhidden only when there are buttons @@ -723,9 +498,6 @@ export function setBackgroundMapLayer (mapName = mapProperties.base_map, force = // on map style change, all sources and layers are removed, so we need to re-initialize them await initializeStyles() limitZoom() - // Recover handlers after the async layer initialization completes. - // During reconnection, this runs AFTER the heavy sortLayers/setData work. - recoverHandlers() }) backgroundMapLayer = mapName backgroundTerrain = mapProperties.terrain