From 5b7d1c4331b04f8414fa0d7f63db4765817d069f Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 16:12:10 +0900 Subject: [PATCH] fix: stabilize globe projection loading and globe ship icon fallback --- apps/web/src/widgets/map3d/Map3D.tsx | 139 ++++++++++++++++++--------- 1 file changed, 92 insertions(+), 47 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index d341d8f..0e3fe85 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -606,6 +606,44 @@ function makeGlobeCircleRadiusExpr() { const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never; +function buildFallbackGlobeShipIcon() { + const size = 96; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + ctx.clearRect(0, 0, size, size); + ctx.fillStyle = "rgba(255,255,255,1)"; + ctx.beginPath(); + ctx.moveTo(size / 2, 6); + ctx.lineTo(size / 2 - 14, 24); + ctx.lineTo(size / 2 - 18, 58); + ctx.lineTo(size / 2 - 10, 88); + ctx.lineTo(size / 2 + 10, 88); + ctx.lineTo(size / 2 + 18, 58); + ctx.lineTo(size / 2 + 14, 24); + ctx.closePath(); + ctx.fill(); + + ctx.fillRect(size / 2 - 8, 34, 16, 18); + + return ctx.getImageData(0, 0, size, size); +} + +function ensureFallbackShipImage(map: maplibregl.Map, imageId: string) { + if (!map || map.hasImage(imageId)) return; + const image = buildFallbackGlobeShipIcon(); + if (!image) return; + + try { + map.addImage(imageId, image, { pixelRatio: 2, sdf: true }); + } catch { + // ignore + } +} + function getMapTilerKey(): string | null { const k = import.meta.env.VITE_MAPTILER_KEY; if (typeof k !== "string") return null; @@ -1051,6 +1089,7 @@ export function Map3D({ const projectionRef = useRef(projection); const globeShipIconLoadingRef = useRef(false); const projectionBusyRef = useRef(false); + const projectionBusyTokenRef = useRef(0); const projectionBusyTimerRef = useRef | null>(null); const projectionPrevRef = useRef(projection); const mapTooltipRef = useRef(null); @@ -1344,28 +1383,37 @@ export function Map3D({ projectionBusyTimerRef.current = null; }, []); + const endProjectionLoading = useCallback(() => { + if (!projectionBusyRef.current) return; + projectionBusyRef.current = false; + clearProjectionBusyTimer(); + if (onProjectionLoadingChange) { + onProjectionLoadingChange(false); + } + }, [clearProjectionBusyTimer, onProjectionLoadingChange]); + const setProjectionLoading = useCallback( (loading: boolean) => { if (projectionBusyRef.current === loading) return; - projectionBusyRef.current = loading; - - if (loading) { - clearProjectionBusyTimer(); - projectionBusyTimerRef.current = setTimeout(() => { - if (projectionBusyRef.current) { - setProjectionLoading(false); - console.warn("Projection loading fallback timeout reached."); - } - }, 3000); - } else { - clearProjectionBusyTimer(); + if (!loading) { + endProjectionLoading(); + return; } + clearProjectionBusyTimer(); + projectionBusyRef.current = true; + const token = ++projectionBusyTokenRef.current; if (onProjectionLoadingChange) { - onProjectionLoadingChange(loading); + onProjectionLoadingChange(true); } + + projectionBusyTimerRef.current = setTimeout(() => { + if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return; + console.debug("Projection loading fallback timeout reached."); + endProjectionLoading(); + }, 4000); }, - [onProjectionLoadingChange, clearProjectionBusyTimer], + [clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange], ); const pulseMapSync = () => { @@ -1379,11 +1427,9 @@ export function Map3D({ useEffect(() => { return () => { clearProjectionBusyTimer(); - if (projectionBusyRef.current) { - setProjectionLoading(false); - } + endProjectionLoading(); }; - }, [clearProjectionBusyTimer, setProjectionLoading]); + }, [clearProjectionBusyTimer, endProjectionLoading]); useEffect(() => { showSeamarkRef.current = settings.showSeamark; @@ -2167,45 +2213,29 @@ export function Map3D({ }; const ensureImage = () => { + ensureFallbackShipImage(map, imgId); + if (globeShipIconLoadingRef.current) return; + if (map.hasImage(imgId)) return; + const addFallbackImage = () => { - const size = 96; - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - // Simple top-down ship silhouette, pointing north. - ctx.clearRect(0, 0, size, size); - ctx.fillStyle = "rgba(255,255,255,1)"; - ctx.beginPath(); - ctx.moveTo(size / 2, 6); - ctx.lineTo(size / 2 - 14, 24); - ctx.lineTo(size / 2 - 18, 58); - ctx.lineTo(size / 2 - 10, 88); - ctx.lineTo(size / 2 + 10, 88); - ctx.lineTo(size / 2 + 18, 58); - ctx.lineTo(size / 2 + 14, 24); - ctx.closePath(); - ctx.fill(); - - ctx.fillRect(size / 2 - 8, 34, 16, 18); - - const img = ctx.getImageData(0, 0, size, size); - map.addImage(imgId, img, { pixelRatio: 2, sdf: true }); + ensureFallbackShipImage(map, imgId); kickRepaint(map); }; - if (map.hasImage(imgId)) return; - if (globeShipIconLoadingRef.current) return; - + let fallbackTimer: ReturnType | null = null; try { globeShipIconLoadingRef.current = true; + fallbackTimer = window.setTimeout(() => { + addFallbackImage(); + }, 80); void map .loadImage("/assets/ship.svg") .then((response) => { globeShipIconLoadingRef.current = false; - if (map.hasImage(imgId)) return; + if (fallbackTimer != null) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data; if (!loadedImage) { @@ -2214,6 +2244,13 @@ export function Map3D({ } try { + if (map.hasImage(imgId)) { + try { + map.removeImage(imgId); + } catch { + // ignore + } + } map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true }); kickRepaint(map); } catch (e) { @@ -2222,10 +2259,18 @@ export function Map3D({ }) .catch(() => { globeShipIconLoadingRef.current = false; + if (fallbackTimer != null) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } addFallbackImage(); }); } catch (e) { globeShipIconLoadingRef.current = false; + if (fallbackTimer != null) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } try { addFallbackImage(); } catch (fallbackError) {