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