From cbb6f0040d629b1a93526dbf6213911807a42320 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 19:49:23 +0000 Subject: [PATCH] Show a lost WebGL context instead of a blank pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- library/i18n/locales/ar/messages.json | 3 +- library/i18n/locales/de/messages.json | 3 +- library/i18n/locales/en/messages.json | 3 +- library/i18n/locales/es/messages.json | 3 +- library/i18n/locales/fr/messages.json | 3 +- library/i18n/locales/hi/messages.json | 3 +- library/i18n/locales/it/messages.json | 3 +- library/i18n/locales/pt/messages.json | 3 +- library/i18n/locales/ro/messages.json | 3 +- library/i18n/locales/ru/messages.json | 3 +- library/i18n/locales/zh/messages.json | 3 +- library/layouts/VerticalTabsLayout.vue | 56 ++++++++++++++++++++++++++ 12 files changed, 78 insertions(+), 11 deletions(-) diff --git a/library/i18n/locales/ar/messages.json b/library/i18n/locales/ar/messages.json index e332976a8..4603232e7 100644 --- a/library/i18n/locales/ar/messages.json +++ b/library/i18n/locales/ar/messages.json @@ -30,5 +30,6 @@ "tapTerritoriesHint": "انقر على الأقاليم في الخريطة أو صفوف وسيلة الإيضاح لإضافتها · انقر مجددًا لإزالتها", "territoriesSelected": "الأقاليم: {count}", "bandInRange": "✓ النطاق 50–100", - "bandOutOfRange": "النطاق 50–100" + "bandOutOfRange": "النطاق 50–100", + "mapContextLost": "تم إلغاء تحميل هذه الخريطة لتحرير ذاكرة الرسوميات." } diff --git a/library/i18n/locales/de/messages.json b/library/i18n/locales/de/messages.json index 776036ca0..6e0983351 100644 --- a/library/i18n/locales/de/messages.json +++ b/library/i18n/locales/de/messages.json @@ -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." } diff --git a/library/i18n/locales/en/messages.json b/library/i18n/locales/en/messages.json index dd4cadd28..6ea00cad6 100644 --- a/library/i18n/locales/en/messages.json +++ b/library/i18n/locales/en/messages.json @@ -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." } diff --git a/library/i18n/locales/es/messages.json b/library/i18n/locales/es/messages.json index ea4bc16e3..602a36880 100644 --- a/library/i18n/locales/es/messages.json +++ b/library/i18n/locales/es/messages.json @@ -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." } diff --git a/library/i18n/locales/fr/messages.json b/library/i18n/locales/fr/messages.json index cb82fb0ff..80cd06128 100644 --- a/library/i18n/locales/fr/messages.json +++ b/library/i18n/locales/fr/messages.json @@ -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." } diff --git a/library/i18n/locales/hi/messages.json b/library/i18n/locales/hi/messages.json index 3adcf86c7..8cc8e4d96 100644 --- a/library/i18n/locales/hi/messages.json +++ b/library/i18n/locales/hi/messages.json @@ -30,5 +30,6 @@ "tapTerritoriesHint": "जोड़ने के लिए मानचित्र पर क्षेत्रों या लेजेंड की पंक्तियों पर टैप करें · हटाने के लिए फिर से टैप करें", "territoriesSelected": "क्षेत्र: {count}", "bandInRange": "✓ 50–100 दायरा", - "bandOutOfRange": "50–100 दायरा" + "bandOutOfRange": "50–100 दायरा", + "mapContextLost": "ग्राफ़िक्स मेमोरी खाली करने के लिए यह मानचित्र अनलोड कर दिया गया।" } diff --git a/library/i18n/locales/it/messages.json b/library/i18n/locales/it/messages.json index eb40723bf..3c9dc9ef6 100644 --- a/library/i18n/locales/it/messages.json +++ b/library/i18n/locales/it/messages.json @@ -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." } diff --git a/library/i18n/locales/pt/messages.json b/library/i18n/locales/pt/messages.json index a29b5860b..009ee0029 100644 --- a/library/i18n/locales/pt/messages.json +++ b/library/i18n/locales/pt/messages.json @@ -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." } diff --git a/library/i18n/locales/ro/messages.json b/library/i18n/locales/ro/messages.json index b5b235b12..ac25455dc 100644 --- a/library/i18n/locales/ro/messages.json +++ b/library/i18n/locales/ro/messages.json @@ -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ă." } diff --git a/library/i18n/locales/ru/messages.json b/library/i18n/locales/ru/messages.json index ff98c1755..fa2346f05 100644 --- a/library/i18n/locales/ru/messages.json +++ b/library/i18n/locales/ru/messages.json @@ -30,5 +30,6 @@ "tapTerritoriesHint": "Нажимайте на территории на карте или строки легенды, чтобы добавить · нажмите ещё раз, чтобы убрать", "territoriesSelected": "Территории: {count}", "bandInRange": "✓ диапазон 50–100", - "bandOutOfRange": "диапазон 50–100" + "bandOutOfRange": "диапазон 50–100", + "mapContextLost": "Эта карта выгружена, чтобы освободить графическую память." } diff --git a/library/i18n/locales/zh/messages.json b/library/i18n/locales/zh/messages.json index 3e464c825..d1a128dff 100644 --- a/library/i18n/locales/zh/messages.json +++ b/library/i18n/locales/zh/messages.json @@ -30,5 +30,6 @@ "tapTerritoriesHint": "点按地图上的区域或图例行即可添加 · 再次点按即可移除", "territoriesSelected": "区域:{count}", "bandInRange": "✓ 50–100 区间", - "bandOutOfRange": "50–100 区间" + "bandOutOfRange": "50–100 区间", + "mapContextLost": "已卸载此地图以释放显存。" } diff --git a/library/layouts/VerticalTabsLayout.vue b/library/layouts/VerticalTabsLayout.vue index feac6a28f..cd76cd0b9 100644 --- a/library/layouts/VerticalTabsLayout.vue +++ b/library/layouts/VerticalTabsLayout.vue @@ -243,6 +243,18 @@ :ref="el => { if (el) hostEls[row.key] = el; else delete hostEls[row.key] }">
{{ T('messages.loadingNamed', 'Loading {name}…').replace('{name}', row.label) }}
+ +
+ {{ T('messages.mapContextLost', 'This map was unloaded to free graphics memory.') }} + +
{{ T('messages.couldNotLoadNamed', 'Could not load {name}.').replace('{name}', row.label) }}
{{ hostError[row.key] }}
@@ -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