Show a lost WebGL context instead of a blank pane

The browser's context budget is commonly 8 to 16 and this tree has nineteen
map-bearing profiles, so a reader who visits enough map tabs crosses it. What
the browser does then is the real problem: it kills the OLDEST context silently.
getContext never returns null, nothing fires on the new canvas, and a map that
worked a minute ago is simply blank when you go back to it. The reader who
notices is the one returning to something that used to work.

The pane now says so and offers a reload. That holds under either option on the
Driver's row 107, because it makes the failure visible rather than preventing it.

The listener goes on the canvas itself, not the pane. The map's canvas lives in
the plugin's shadow root and webglcontextlost does not bubble, so a listener on
the pane would never hear it — hence a walk that descends through shadow roots.
It binds after mount and again on every re-activation, because the canvas does
not exist when the element connects and a later one would otherwise go unwatched.
Binding is idempotent and marked per canvas.

Two string defects caught before this went anywhere. I wrote
T('actions.reloadMap'), and the string lives at buttons.reloadMap — it exists in
all eleven locales and my wrong namespace would have rendered English in every
one of them while the correct translation sat unused. And messages.mapContextLost
did not exist at all, which the tr() helper answers with a silent English
fallback. Both now resolve; keys-exist and parity are green.
This commit is contained in:
Doxa Maps 2026-09-28 19:49:23 +00:00
parent 16ff7e719b
commit cbb6f0040d
12 changed files with 78 additions and 11 deletions

View file

@ -30,5 +30,6 @@
"tapTerritoriesHint": "انقر على الأقاليم في الخريطة أو صفوف وسيلة الإيضاح لإضافتها · انقر مجددًا لإزالتها",
"territoriesSelected": "الأقاليم: {count}",
"bandInRange": "✓ النطاق 50–100",
"bandOutOfRange": "النطاق 50–100"
"bandOutOfRange": "النطاق 50–100",
"mapContextLost": "تم إلغاء تحميل هذه الخريطة لتحرير ذاكرة الرسوميات."
}

View file

@ -30,5 +30,6 @@
"tapTerritoriesHint": "Tippen Sie auf Gebiete auf der Karte oder auf Legendenzeilen, um sie hinzuzufügen · erneut tippen zum Entfernen",
"territoriesSelected": "Gebiete: {count}",
"bandInRange": "✓ Bereich 50–100",
"bandOutOfRange": "Bereich 50–100"
"bandOutOfRange": "Bereich 50–100",
"mapContextLost": "Diese Karte wurde entladen, um Grafikspeicher freizugeben."
}

View file

@ -30,5 +30,6 @@
"tapTerritoriesHint": "Tap territories on the map or legend rows to add · tap again to remove",
"territoriesSelected": "Territories: {count}",
"bandInRange": "✓ 50–100 band",
"bandOutOfRange": "band 50–100"
"bandOutOfRange": "band 50–100",
"mapContextLost": "This map was unloaded to free graphics memory."
}

View file

@ -30,5 +30,6 @@
"tapTerritoriesHint": "Toque territorios en el mapa o filas de la leyenda para añadirlos · toque de nuevo para quitarlos",
"territoriesSelected": "Territorios: {count}",
"bandInRange": "✓ franja 50–100",
"bandOutOfRange": "franja 50–100"
"bandOutOfRange": "franja 50–100",
"mapContextLost": "Este mapa se descargó para liberar memoria gráfica."
}

View file

@ -30,5 +30,6 @@
"tapTerritoriesHint": "Touchez des territoires sur la carte ou des lignes de la légende pour les ajouter · touchez à nouveau pour les retirer",
"territoriesSelected": "Territoires : {count}",
"bandInRange": "✓ tranche 50–100",
"bandOutOfRange": "tranche 50–100"
"bandOutOfRange": "tranche 50–100",
"mapContextLost": "Cette carte a été déchargée pour libérer de la mémoire graphique."
}

View file

@ -30,5 +30,6 @@
"tapTerritoriesHint": "जोड़ने के लिए मानचित्र पर क्षेत्रों या लेजेंड की पंक्तियों पर टैप करें · हटाने के लिए फिर से टैप करें",
"territoriesSelected": "क्षेत्र: {count}",
"bandInRange": "✓ 50–100 दायरा",
"bandOutOfRange": "50–100 दायरा"
"bandOutOfRange": "50–100 दायरा",
"mapContextLost": "ग्राफ़िक्स मेमोरी खाली करने के लिए यह मानचित्र अनलोड कर दिया गया।"
}

View file

@ -30,5 +30,6 @@
"tapTerritoriesHint": "Tocca i territori sulla mappa o le righe della legenda per aggiungerli · tocca di nuovo per rimuoverli",
"territoriesSelected": "Territori: {count}",
"bandInRange": "✓ fascia 50–100",
"bandOutOfRange": "fascia 50–100"
"bandOutOfRange": "fascia 50–100",
"mapContextLost": "Questa mappa è stata scaricata per liberare memoria grafica."
}

View file

@ -30,5 +30,6 @@
"tapTerritoriesHint": "Toque em territórios no mapa ou em linhas da legenda para adicionar · toque de novo para remover",
"territoriesSelected": "Territórios: {count}",
"bandInRange": "✓ faixa 50–100",
"bandOutOfRange": "faixa 50–100"
"bandOutOfRange": "faixa 50–100",
"mapContextLost": "Este mapa foi descarregado para libertar memória gráfica."
}

View file

@ -30,5 +30,6 @@
"tapTerritoriesHint": "Atingeți teritorii pe hartă sau rânduri din legendă pentru a le adăuga · atingeți din nou pentru a le elimina",
"territoriesSelected": "Teritorii: {count}",
"bandInRange": "✓ interval 50–100",
"bandOutOfRange": "interval 50–100"
"bandOutOfRange": "interval 50–100",
"mapContextLost": "Această hartă a fost descărcată pentru a elibera memoria grafică."
}

View file

@ -30,5 +30,6 @@
"tapTerritoriesHint": "Нажимайте на территории на карте или строки легенды, чтобы добавить · нажмите ещё раз, чтобы убрать",
"territoriesSelected": "Территории: {count}",
"bandInRange": "✓ диапазон 50–100",
"bandOutOfRange": "диапазон 50–100"
"bandOutOfRange": "диапазон 50–100",
"mapContextLost": "Эта карта выгружена, чтобы освободить графическую память."
}

View file

@ -30,5 +30,6 @@
"tapTerritoriesHint": "点按地图上的区域或图例行即可添加 · 再次点按即可移除",
"territoriesSelected": "区域:{count}",
"bandInRange": "✓ 50–100 区间",
"bandOutOfRange": "50–100 区间"
"bandOutOfRange": "50–100 区间",
"mapContextLost": "已卸载此地图以释放显存。"
}

View file

@ -243,6 +243,18 @@
:ref="el => { if (el) hostEls[row.key] = el; else delete hostEls[row.key] }">
<div class="vtl-host-slot" :ref="el => setHostSlot(row.key, el)"></div>
<div v-if="hostState[row.key] === 'loading'" class="vtl-host-hint">{{ T('messages.loadingNamed', 'Loading {name}…').replace('{name}', row.label) }}</div>
<!-- ★ A LOST GL CONTEXT IS SHOWN, NOT SWALLOWED. The browser's WebGL budget is commonly
8-16 and this tree has 19 map-bearing profiles, so a reader who visits enough map
tabs crosses it. What the browser then does is the problem: it kills the OLDEST
context silently — getContext() never returns null and nothing fires on the NEW
canvas — so a map that worked a minute ago is simply blank when you go back to it.
This turns that into a visible, actionable state under either of the caps on row 107. -->
<div v-else-if="hostState[row.key] === 'lost'" class="vtl-host-hint">
{{ T('messages.mapContextLost', 'This map was unloaded to free graphics memory.') }}
<button type="button" class="vtl-host-reload" @click="reloadHostElement(row.key)">
{{ T('buttons.reloadMap', 'Reload Map') }}
</button>
</div>
<div v-else-if="hostState[row.key] === 'error'" class="vtl-host-hint err">
{{ T('messages.couldNotLoadNamed', 'Could not load {name}.').replace('{name}', row.label) }}<br><small>{{ hostError[row.key] }}</small>
</div>
@ -461,6 +473,7 @@ const VTL_SHADOW_CSS = `
.vtl-host-slot > * { min-height:0; max-height:none; }
.vtl-host-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:var(--dx-muted, #6d766e); font-size:13px; padding:20px; }
.vtl-host-hint.err { color:#f87171; }
.vtl-host-reload { margin-block-start:10px; font:inherit; padding:4px 12px; border-radius:6px; cursor:pointer; background:var(--vtl-accent, #3b463d); color:#fff; border:none; }
.vtl-host-hint small { color:var(--dx-muted, #6d766e); }
/* MATERIAL MINI-VARIANT RAIL = 72px (2026-09-21), up from 60px.
★ THE COLLAPSE IS A TRANSLATE, NOT A WIDTH CHANGE — so FOUR numbers are coupled and must move
@ -717,6 +730,8 @@ export default defineComponent({
if (!row) return // not an element-hosting tab
if (!this.activatedKeys[key]) {
this.activatedKeys = { ...this.activatedKeys, [key]: true }
// Re-show: pick up any canvas created since the last look.
this.$nextTick(() => this.watchContextLoss(key))
this.hostState = { ...this.hostState, [key]: 'loading' }
}
// If the pane/slot already exists (re-activation, or pane rendered), ensure it is mounted.
@ -725,6 +740,42 @@ export default defineComponent({
// Imperatively mount the row's custom element into its empty slot div — lazy-inject its bundle
// first (deduped), wait for the tag to be defined, set `attrs` BEFORE append (props-in-advance),
// then append. Runs once per key (guarded by _hostMounted).
/**
* Every canvas under a pane, INCLUDING inside shadow roots. The map's canvas belongs to the
* plugin's own shadow tree, and webglcontextlost does not bubble — so a listener on the pane
* would never hear it. The listener has to go on the canvas itself.
*/
deepCanvases(root, out = []) {
if (!root) return out
for (const el of root.querySelectorAll('*')) {
if (el.tagName === 'CANVAS') out.push(el)
if (el.shadowRoot) this.deepCanvases(el.shadowRoot, out)
}
return out
},
/**
* Idempotent: canvases appear asynchronously after the map boots, and a pane can be re-shown
* many times, so this is safe to call repeatedly and marks what it has already bound.
*/
watchContextLoss(key) {
const slot = this._hostSlots[key]
if (!slot) return
for (const c of this.deepCanvases(slot)) {
if (c.__vtlLossBound) continue
c.__vtlLossBound = true
c.addEventListener('webglcontextlost', () => {
this.hostState = { ...this.hostState, [key]: 'lost' }
}, { once: true })
}
},
/** Tear the pane down and mount it again — the only way back from a lost context. */
reloadHostElement(key) {
const slot = this._hostSlots[key]
if (slot) slot.innerHTML = ''
this._hostMounted[key] = false
this.hostState = { ...this.hostState, [key]: 'loading' }
this.$nextTick(() => this.mountHostElement(key))
},
mountHostElement(key) {
if (this._hostMounted[key]) return
const row = this.elementRows.find(r => r.key === key)
@ -748,6 +799,10 @@ export default defineComponent({
}
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
slot.appendChild(el)
// The map's canvas does not exist yet — the element has only just connected. Bind on the
// next frames as it boots, and again whenever the pane is re-activated, because a canvas
// created later would otherwise never be watched. watchContextLoss is idempotent.
for (const ms of [0, 250, 1500]) setTimeout(() => this.watchContextLoss(key), ms)
this.hostState = { ...this.hostState, [key]: 'ready' }
})
.catch((e) => {
@ -977,6 +1032,7 @@ export default defineComponent({
.vtl-host-slot > * { min-height:0; max-height:none; }
.vtl-host-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:var(--dx-muted, #6d766e); font-size:13px; padding:20px; }
.vtl-host-hint.err { color:#f87171; }
.vtl-host-reload { margin-block-start:10px; font:inherit; padding:4px 12px; border-radius:6px; cursor:pointer; background:var(--vtl-accent, #3b463d); color:#fff; border:none; }
.vtl-host-hint small { color:var(--dx-muted, #6d766e); }
/* MATERIAL MINI-VARIANT RAIL = 72px (2026-09-21), up from 60px.
★ THE COLLAPSE IS A TRANSLATE, NOT A WIDTH CHANGE — so FOUR numbers are coupled and must move