From ccf3f2361fe8871f38975eb88f67ab5c1ae85655 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 15:30:09 +0900 Subject: [PATCH] fix: guard deck layer arrays against null ids --- apps/web/src/widgets/map3d/Map3D.tsx | 67 ++++++++++++++++++- .../widgets/map3d/MaplibreDeckCustomLayer.ts | 30 ++++++++- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index c082500..f37f1e5 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -195,6 +195,33 @@ function getLayerId(value: unknown): string | null { return typeof candidate === "string" ? candidate : null; } +function sanitizeDeckLayerList(value: unknown): unknown[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const out: unknown[] = []; + let dropped = 0; + + for (const layer of value) { + const layerId = getLayerId(layer); + if (!layerId) { + dropped += 1; + continue; + } + if (seen.has(layerId)) { + dropped += 1; + continue; + } + seen.add(layerId); + out.push(layer); + } + + if (dropped > 0 && import.meta.env.DEV) { + console.warn(`Sanitized deck layer list: dropped ${dropped} invalid/duplicate entries`); + } + + return out; +} + function normalizeAngleDeg(value: number, offset = 0): number { const v = value + offset; return ((v % 360) + 360) % 360; @@ -3674,8 +3701,10 @@ export function Map3D({ ); } + const normalizedLayers = sanitizeDeckLayerList(layers); + const deckProps = { - layers, + layers: normalizedLayers, getTooltip: projection === "globe" ? undefined @@ -3771,8 +3800,40 @@ export function Map3D({ }, } as const; - if (projection === "globe") globeDeckLayerRef.current?.setProps(deckProps); - else overlayRef.current?.setProps(deckProps as unknown as never); + const safeDeckProps = { ...deckProps, layers: normalizedLayers }; + const fallbackDeckProps = { ...safeDeckProps, layers: [] as unknown[] }; + const applyDeckProps = () => { + if (projection === "globe") { + const target = globeDeckLayerRef.current; + if (!target) return; + try { + target.setProps(safeDeckProps as never); + } catch (e) { + console.error("Failed to apply deck props on globe overlay. Falling back to empty deck layer set.", e); + try { + target.setProps(fallbackDeckProps as never); + } catch { + // Ignore secondary failure; rendering will recover on next update. + } + } + return; + } + + const target = overlayRef.current; + if (!target) return; + try { + target.setProps(safeDeckProps as unknown as never); + } catch (e) { + console.error("Failed to apply deck props on mercator overlay. Falling back to empty deck layer set.", e); + try { + target.setProps(fallbackDeckProps as unknown as never); + } catch { + // Ignore secondary failure. + } + } + }; + + applyDeckProps(); }, [ projection, shipData, diff --git a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts index f3bc99b..4998c26 100644 --- a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts +++ b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts @@ -24,6 +24,7 @@ class MatrixView extends View { } const IDENTITY_4x4: number[] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; +type DeckLayerList = NonNullable["layers"]>; function readMat4(m: ArrayLike): number[] { const out = new Array(16); @@ -40,6 +41,25 @@ function mat4Changed(a: number[] | undefined, b: ArrayLike): boolean { return false; } +function getDeckLayerId(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const candidate = (value as { id?: unknown }).id; + return typeof candidate === "string" ? candidate : null; +} + +function sanitizeDeckLayers(value: unknown): DeckLayerList { + if (!Array.isArray(value)) return [] as DeckLayerList; + const out: DeckLayerList = []; + const seen = new Set(); + for (const item of value) { + const layerId = getDeckLayerId(item); + if (!layerId || seen.has(layerId)) continue; + seen.add(layerId); + out.push(item as DeckLayerList[number]); + } + return out; +} + export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface { id: string; type = "custom" as const; @@ -67,7 +87,8 @@ export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface } setProps(next: Partial>) { - this._deckProps = { ...this._deckProps, ...next }; + const normalized = next.layers ? { ...next, layers: sanitizeDeckLayers(next.layers) } : next; + this._deckProps = { ...this._deckProps, ...normalized }; if (this._deck) this._deck.setProps(this._deckProps as DeckProps); this._map?.triggerRepaint(); } @@ -80,17 +101,20 @@ export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface // Re-attached after a style change; keep the existing Deck instance so we don't reuse // finalized Layer objects (Deck does not allow that). this._lastMvp = undefined; - this._deck.setProps({ + const nextDeckProps = { ...this._deckProps, + layers: sanitizeDeckLayers(this._deckProps.layers), canvas: map.getCanvas(), // Ensure any pending redraw requests trigger a map repaint again. _customRender: () => map.triggerRepaint(), - } as DeckProps); + }; + this._deck.setProps(nextDeckProps as DeckProps); return; } const deck = new Deck({ ...this._deckProps, + layers: sanitizeDeckLayers(this._deckProps.layers), // Share MapLibre's WebGL context + canvas (single context). gl: gl as WebGL2RenderingContext, canvas: map.getCanvas(),