fix: stabilize globe projection loading and globe ship icon fallback

This commit is contained in:
htlee 2026-02-15 16:12:10 +09:00
부모 05b0c6b881
커밋 5b7d1c4331

파일 보기

@ -606,6 +606,44 @@ function makeGlobeCircleRadiusExpr() {
const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never; 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 { function getMapTilerKey(): string | null {
const k = import.meta.env.VITE_MAPTILER_KEY; const k = import.meta.env.VITE_MAPTILER_KEY;
if (typeof k !== "string") return null; if (typeof k !== "string") return null;
@ -1051,6 +1089,7 @@ export function Map3D({
const projectionRef = useRef<MapProjectionId>(projection); const projectionRef = useRef<MapProjectionId>(projection);
const globeShipIconLoadingRef = useRef(false); const globeShipIconLoadingRef = useRef(false);
const projectionBusyRef = useRef(false); const projectionBusyRef = useRef(false);
const projectionBusyTokenRef = useRef(0);
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null); const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const projectionPrevRef = useRef<MapProjectionId>(projection); const projectionPrevRef = useRef<MapProjectionId>(projection);
const mapTooltipRef = useRef<maplibregl.Popup | null>(null); const mapTooltipRef = useRef<maplibregl.Popup | null>(null);
@ -1344,28 +1383,37 @@ export function Map3D({
projectionBusyTimerRef.current = null; projectionBusyTimerRef.current = null;
}, []); }, []);
const endProjectionLoading = useCallback(() => {
if (!projectionBusyRef.current) return;
projectionBusyRef.current = false;
clearProjectionBusyTimer();
if (onProjectionLoadingChange) {
onProjectionLoadingChange(false);
}
}, [clearProjectionBusyTimer, onProjectionLoadingChange]);
const setProjectionLoading = useCallback( const setProjectionLoading = useCallback(
(loading: boolean) => { (loading: boolean) => {
if (projectionBusyRef.current === loading) return; if (projectionBusyRef.current === loading) return;
projectionBusyRef.current = loading; if (!loading) {
endProjectionLoading();
if (loading) { return;
clearProjectionBusyTimer();
projectionBusyTimerRef.current = setTimeout(() => {
if (projectionBusyRef.current) {
setProjectionLoading(false);
console.warn("Projection loading fallback timeout reached.");
}
}, 3000);
} else {
clearProjectionBusyTimer();
} }
clearProjectionBusyTimer();
projectionBusyRef.current = true;
const token = ++projectionBusyTokenRef.current;
if (onProjectionLoadingChange) { 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 = () => { const pulseMapSync = () => {
@ -1379,11 +1427,9 @@ export function Map3D({
useEffect(() => { useEffect(() => {
return () => { return () => {
clearProjectionBusyTimer(); clearProjectionBusyTimer();
if (projectionBusyRef.current) { endProjectionLoading();
setProjectionLoading(false);
}
}; };
}, [clearProjectionBusyTimer, setProjectionLoading]); }, [clearProjectionBusyTimer, endProjectionLoading]);
useEffect(() => { useEffect(() => {
showSeamarkRef.current = settings.showSeamark; showSeamarkRef.current = settings.showSeamark;
@ -2167,45 +2213,29 @@ export function Map3D({
}; };
const ensureImage = () => { const ensureImage = () => {
ensureFallbackShipImage(map, imgId);
if (globeShipIconLoadingRef.current) return;
if (map.hasImage(imgId)) return;
const addFallbackImage = () => { const addFallbackImage = () => {
const size = 96; ensureFallbackShipImage(map, imgId);
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 });
kickRepaint(map); kickRepaint(map);
}; };
if (map.hasImage(imgId)) return; let fallbackTimer: ReturnType<typeof window.setTimeout> | null = null;
if (globeShipIconLoadingRef.current) return;
try { try {
globeShipIconLoadingRef.current = true; globeShipIconLoadingRef.current = true;
fallbackTimer = window.setTimeout(() => {
addFallbackImage();
}, 80);
void map void map
.loadImage("/assets/ship.svg") .loadImage("/assets/ship.svg")
.then((response) => { .then((response) => {
globeShipIconLoadingRef.current = false; globeShipIconLoadingRef.current = false;
if (map.hasImage(imgId)) return; if (fallbackTimer != null) {
clearTimeout(fallbackTimer);
fallbackTimer = null;
}
const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data; const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data;
if (!loadedImage) { if (!loadedImage) {
@ -2214,6 +2244,13 @@ export function Map3D({
} }
try { try {
if (map.hasImage(imgId)) {
try {
map.removeImage(imgId);
} catch {
// ignore
}
}
map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true }); map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true });
kickRepaint(map); kickRepaint(map);
} catch (e) { } catch (e) {
@ -2222,10 +2259,18 @@ export function Map3D({
}) })
.catch(() => { .catch(() => {
globeShipIconLoadingRef.current = false; globeShipIconLoadingRef.current = false;
if (fallbackTimer != null) {
clearTimeout(fallbackTimer);
fallbackTimer = null;
}
addFallbackImage(); addFallbackImage();
}); });
} catch (e) { } catch (e) {
globeShipIconLoadingRef.current = false; globeShipIconLoadingRef.current = false;
if (fallbackTimer != null) {
clearTimeout(fallbackTimer);
fallbackTimer = null;
}
try { try {
addFallbackImage(); addFallbackImage();
} catch (fallbackError) { } catch (fallbackError) {