perf(map): globe 선박 렌더링 사전계산 최적화
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
77e3a531e8
커밋
d88c89403d
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { usePersistedState } from "../../shared/hooks";
|
import { usePersistedState } from "../../shared/hooks";
|
||||||
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
||||||
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
|
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
|
||||||
@ -132,6 +132,12 @@ export function DashboardPage() {
|
|||||||
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
|
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
|
||||||
|
|
||||||
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
|
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
|
||||||
|
const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(true);
|
||||||
|
const handleProjectionLoadingChange = useCallback((loading: boolean) => {
|
||||||
|
setIsProjectionLoading(loading);
|
||||||
|
if (loading) setIsGlobeShipsReady(false);
|
||||||
|
}, []);
|
||||||
|
const showMapLoader = isProjectionLoading || (projection === "globe" && !isGlobeShipsReady);
|
||||||
|
|
||||||
const [clock, setClock] = useState(() => fmtDateTimeFull(new Date()));
|
const [clock, setClock] = useState(() => fmtDateTimeFull(new Date()));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -663,7 +669,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="map-area">
|
<div className="map-area">
|
||||||
{isProjectionLoading ? (
|
{showMapLoader ? (
|
||||||
<div className="map-loader-overlay" role="status" aria-live="polite">
|
<div className="map-loader-overlay" role="status" aria-live="polite">
|
||||||
<div className="map-loader-overlay__panel">
|
<div className="map-loader-overlay__panel">
|
||||||
<div className="map-loader-overlay__spinner" />
|
<div className="map-loader-overlay__spinner" />
|
||||||
@ -695,7 +701,8 @@ export function DashboardPage() {
|
|||||||
fcLinks={fcLinksForMap}
|
fcLinks={fcLinksForMap}
|
||||||
fleetCircles={fleetCirclesForMap}
|
fleetCircles={fleetCirclesForMap}
|
||||||
fleetFocus={fleetFocus}
|
fleetFocus={fleetFocus}
|
||||||
onProjectionLoadingChange={setIsProjectionLoading}
|
onProjectionLoadingChange={handleProjectionLoadingChange}
|
||||||
|
onGlobeShipsReady={setIsGlobeShipsReady}
|
||||||
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
||||||
onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))}
|
onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))}
|
||||||
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
||||||
|
|||||||
@ -68,6 +68,7 @@ export function Map3D({
|
|||||||
mapStyleSettings,
|
mapStyleSettings,
|
||||||
initialView,
|
initialView,
|
||||||
onViewStateChange,
|
onViewStateChange,
|
||||||
|
onGlobeShipsReady,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
void onHoverFleet;
|
void onHoverFleet;
|
||||||
void onClearFleetHover;
|
void onClearFleetHover;
|
||||||
@ -474,6 +475,7 @@ export function Map3D({
|
|||||||
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
||||||
onSelectMmsi, onToggleHighlightMmsi, targets, overlays,
|
onSelectMmsi, onToggleHighlightMmsi, targets, overlays,
|
||||||
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
||||||
|
onGlobeShipsReady,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, type MutableRefObject } from 'react';
|
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
|
||||||
import type maplibregl from 'maplibre-gl';
|
import type maplibregl from 'maplibre-gl';
|
||||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
@ -48,18 +48,81 @@ export function useGlobeShips(
|
|||||||
selectedMmsi: number | null;
|
selectedMmsi: number | null;
|
||||||
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
||||||
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
||||||
|
onGlobeShipsReady?: (ready: boolean) => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
|
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
|
||||||
shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets,
|
shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets,
|
||||||
overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
||||||
|
onGlobeShipsReady,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const globeShipsEpochRef = useRef(-1);
|
const globeShipsEpochRef = useRef(-1);
|
||||||
const globeShipIconLoadingRef = useRef(false);
|
|
||||||
const globeHoverShipSignatureRef = useRef('');
|
const globeHoverShipSignatureRef = useRef('');
|
||||||
|
|
||||||
|
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
|
||||||
|
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
|
||||||
|
const globeShipGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: shipData.map((t) => {
|
||||||
|
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||||
|
const labelName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
|
||||||
|
const heading = getDisplayHeading({
|
||||||
|
cog: t.cog,
|
||||||
|
heading: t.heading,
|
||||||
|
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
|
||||||
|
});
|
||||||
|
const isAnchored = isAnchoredShip({ sog: t.sog, cog: t.cog, heading: t.heading });
|
||||||
|
const shipHeading = isAnchored ? 0 : heading;
|
||||||
|
const hull = clampNumber(
|
||||||
|
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
|
||||||
|
50, 420,
|
||||||
|
);
|
||||||
|
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
||||||
|
const selected = t.mmsi === selectedMmsi;
|
||||||
|
const highlighted = isBaseHighlightedMmsi(t.mmsi);
|
||||||
|
const selectedScale = selected ? 1.08 : 1;
|
||||||
|
const highlightScale = highlighted ? 1.06 : 1;
|
||||||
|
const iconScale = selected ? selectedScale : highlightScale;
|
||||||
|
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
|
||||||
|
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
|
||||||
|
const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8);
|
||||||
|
const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6);
|
||||||
|
const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0);
|
||||||
|
return {
|
||||||
|
type: 'Feature' as const,
|
||||||
|
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
||||||
|
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
|
||||||
|
properties: {
|
||||||
|
mmsi: t.mmsi,
|
||||||
|
name: t.name || '',
|
||||||
|
labelName,
|
||||||
|
cog: shipHeading,
|
||||||
|
heading: shipHeading,
|
||||||
|
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
||||||
|
isAnchored: isAnchored ? 1 : 0,
|
||||||
|
shipColor: getGlobeBaseShipColor({
|
||||||
|
legacy: legacy?.shipCode || null,
|
||||||
|
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
||||||
|
}),
|
||||||
|
iconSize3: iconSize3 * iconScale,
|
||||||
|
iconSize7: iconSize7 * iconScale,
|
||||||
|
iconSize10: iconSize10 * iconScale,
|
||||||
|
iconSize14: iconSize14 * iconScale,
|
||||||
|
iconSize18: iconSize18 * iconScale,
|
||||||
|
sizeScale,
|
||||||
|
selected: selected ? 1 : 0,
|
||||||
|
highlighted: highlighted ? 1 : 0,
|
||||||
|
permitted: legacy ? 1 : 0,
|
||||||
|
code: legacy?.shipCode || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi]);
|
||||||
|
|
||||||
// Ship name labels in mercator
|
// Ship name labels in mercator
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@ -227,89 +290,23 @@ export function useGlobeShips(
|
|||||||
kickRepaint(map);
|
kickRepaint(map);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 이미지 보장: useMapInit에서 미리 로드됨 → 대부분 즉시 반환
|
||||||
|
// 미리 로드되지 않았다면 fallback canvas 아이콘 사용
|
||||||
const ensureImage = () => {
|
const ensureImage = () => {
|
||||||
ensureFallbackShipImage(map, imgId);
|
ensureFallbackShipImage(map, imgId);
|
||||||
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
|
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
|
||||||
if (globeShipIconLoadingRef.current) return;
|
|
||||||
if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
|
if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
|
||||||
|
// useMapInit에서 pre-load가 아직 완료되지 않은 경우 fallback으로 진행
|
||||||
const addFallbackImage = () => {
|
|
||||||
ensureFallbackShipImage(map, imgId);
|
|
||||||
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
|
|
||||||
kickRepaint(map);
|
kickRepaint(map);
|
||||||
};
|
};
|
||||||
|
|
||||||
let fallbackTimer: ReturnType<typeof window.setTimeout> | null = null;
|
|
||||||
try {
|
|
||||||
globeShipIconLoadingRef.current = true;
|
|
||||||
fallbackTimer = window.setTimeout(() => {
|
|
||||||
addFallbackImage();
|
|
||||||
}, 80);
|
|
||||||
void map
|
|
||||||
.loadImage('/assets/ship.svg')
|
|
||||||
.then((response) => {
|
|
||||||
globeShipIconLoadingRef.current = false;
|
|
||||||
if (fallbackTimer != null) {
|
|
||||||
clearTimeout(fallbackTimer);
|
|
||||||
fallbackTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data;
|
|
||||||
if (!loadedImage) {
|
|
||||||
addFallbackImage();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (map.hasImage(imgId)) {
|
|
||||||
try {
|
|
||||||
map.removeImage(imgId);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (map.hasImage(anchoredImgId)) {
|
|
||||||
try {
|
|
||||||
map.removeImage(anchoredImgId);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true });
|
|
||||||
map.addImage(anchoredImgId, loadedImage, { pixelRatio: 2, sdf: true });
|
|
||||||
kickRepaint(map);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Ship icon image add failed:', e);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.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) {
|
|
||||||
console.warn('Ship icon image setup failed:', e, fallbackError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
if (projectionBusyRef.current) return;
|
if (projectionBusyRef.current) return;
|
||||||
if (!map.isStyleLoaded()) return;
|
if (!map.isStyleLoaded()) return;
|
||||||
|
|
||||||
if (projection !== 'globe' || !settings.showShips) {
|
if (projection !== 'globe' || !settings.showShips) {
|
||||||
remove();
|
remove();
|
||||||
|
onGlobeShipsReady?.(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -323,69 +320,8 @@ export function useGlobeShips(
|
|||||||
console.warn('Ship icon image setup failed:', e);
|
console.warn('Ship icon image setup failed:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const globeShipData = shipData;
|
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
|
||||||
const geojson: GeoJSON.FeatureCollection<GeoJSON.Point> = {
|
const geojson = globeShipGeoJson;
|
||||||
type: 'FeatureCollection',
|
|
||||||
features: globeShipData.map((t) => {
|
|
||||||
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
|
||||||
const labelName =
|
|
||||||
legacy?.shipNameCn ||
|
|
||||||
legacy?.shipNameRoman ||
|
|
||||||
t.name ||
|
|
||||||
'';
|
|
||||||
const heading = getDisplayHeading({
|
|
||||||
cog: t.cog,
|
|
||||||
heading: t.heading,
|
|
||||||
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
|
|
||||||
});
|
|
||||||
const isAnchored = isAnchoredShip({
|
|
||||||
sog: t.sog,
|
|
||||||
cog: t.cog,
|
|
||||||
heading: t.heading,
|
|
||||||
});
|
|
||||||
const shipHeading = isAnchored ? 0 : heading;
|
|
||||||
const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420);
|
|
||||||
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
|
||||||
const selected = t.mmsi === selectedMmsi;
|
|
||||||
const highlighted = isBaseHighlightedMmsi(t.mmsi);
|
|
||||||
const selectedScale = selected ? 1.08 : 1;
|
|
||||||
const highlightScale = highlighted ? 1.06 : 1;
|
|
||||||
const iconScale = selected ? selectedScale : highlightScale;
|
|
||||||
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
|
|
||||||
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
|
|
||||||
const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8);
|
|
||||||
const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6);
|
|
||||||
const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0);
|
|
||||||
return {
|
|
||||||
type: 'Feature',
|
|
||||||
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
|
||||||
geometry: { type: 'Point', coordinates: [t.lon, t.lat] },
|
|
||||||
properties: {
|
|
||||||
mmsi: t.mmsi,
|
|
||||||
name: t.name || '',
|
|
||||||
labelName,
|
|
||||||
cog: shipHeading,
|
|
||||||
heading: shipHeading,
|
|
||||||
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
|
||||||
isAnchored: isAnchored ? 1 : 0,
|
|
||||||
shipColor: getGlobeBaseShipColor({
|
|
||||||
legacy: legacy?.shipCode || null,
|
|
||||||
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
|
||||||
}),
|
|
||||||
iconSize3: iconSize3 * iconScale,
|
|
||||||
iconSize7: iconSize7 * iconScale,
|
|
||||||
iconSize10: iconSize10 * iconScale,
|
|
||||||
iconSize14: iconSize14 * iconScale,
|
|
||||||
iconSize18: iconSize18 * iconScale,
|
|
||||||
sizeScale,
|
|
||||||
selected: selected ? 1 : 0,
|
|
||||||
highlighted: highlighted ? 1 : 0,
|
|
||||||
permitted: legacy ? 1 : 0,
|
|
||||||
code: legacy?.shipCode || '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
@ -684,6 +620,7 @@ export function useGlobeShips(
|
|||||||
|
|
||||||
reorderGlobeFeatureLayers();
|
reorderGlobeFeatureLayers();
|
||||||
kickRepaint(map);
|
kickRepaint(map);
|
||||||
|
onGlobeShipsReady?.(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = onMapStyleReady(map, ensure);
|
const stop = onMapStyleReady(map, ensure);
|
||||||
@ -694,12 +631,12 @@ export function useGlobeShips(
|
|||||||
projection,
|
projection,
|
||||||
settings.showShips,
|
settings.showShips,
|
||||||
overlays.shipLabels,
|
overlays.shipLabels,
|
||||||
shipData,
|
globeShipGeoJson,
|
||||||
legacyHits,
|
|
||||||
selectedMmsi,
|
selectedMmsi,
|
||||||
isBaseHighlightedMmsi,
|
isBaseHighlightedMmsi,
|
||||||
mapSyncEpoch,
|
mapSyncEpoch,
|
||||||
reorderGlobeFeatureLayers,
|
reorderGlobeFeatureLayers,
|
||||||
|
onGlobeShipsReady,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Globe hover overlay ships
|
// Globe hover overlay ships
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import maplibregl, { type StyleSpecification } from 'maplibre-gl';
|
|||||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||||
import type { BaseMapId, MapProjectionId, MapViewState } from '../types';
|
import type { BaseMapId, MapProjectionId, MapViewState } from '../types';
|
||||||
import { DECK_VIEW_ID } from '../constants';
|
import { DECK_VIEW_ID, ANCHORED_SHIP_ICON_ID } from '../constants';
|
||||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
import { ensureSeamarkOverlay } from '../layers/seamark';
|
import { ensureSeamarkOverlay } from '../layers/seamark';
|
||||||
import { resolveMapStyle } from '../layers/bathymetry';
|
import { resolveMapStyle } from '../layers/bathymetry';
|
||||||
@ -100,6 +100,44 @@ export function useMapInit(
|
|||||||
map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left');
|
map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left');
|
||||||
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
|
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
|
||||||
|
|
||||||
|
// MapLibre 내부 placement TypeError 방어
|
||||||
|
// symbol layer 추가/제거와 render cycle 간 타이밍 이슈로 발생하는 에러 억제
|
||||||
|
{
|
||||||
|
const origRender = (map as unknown as { _render: (arg?: number) => void })._render;
|
||||||
|
(map as unknown as { _render: (arg?: number) => void })._render = function (arg?: number) {
|
||||||
|
try {
|
||||||
|
origRender.call(this, arg);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof TypeError && (e.message?.includes("reading 'get'") || e.message?.includes('placement'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Globe 모드 전환 시 지연을 제거하기 위해 ship.svg를 미리 로드
|
||||||
|
{
|
||||||
|
const SHIP_IMG_ID = 'ship-globe-icon';
|
||||||
|
const localMap = map;
|
||||||
|
void localMap
|
||||||
|
.loadImage('/assets/ship.svg')
|
||||||
|
.then((response) => {
|
||||||
|
if (cancelled || !localMap) return;
|
||||||
|
const img = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data;
|
||||||
|
if (!img) return;
|
||||||
|
try {
|
||||||
|
if (!localMap.hasImage(SHIP_IMG_ID)) localMap.addImage(SHIP_IMG_ID, img, { pixelRatio: 2, sdf: true });
|
||||||
|
if (!localMap.hasImage(ANCHORED_SHIP_ICON_ID)) localMap.addImage(ANCHORED_SHIP_ICON_ID, img, { pixelRatio: 2, sdf: true });
|
||||||
|
} catch {
|
||||||
|
// ignore — fallback canvas icon이 useGlobeShips에서 사용됨
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// ignore — useGlobeShips에서 fallback 처리
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
if (projectionRef.current === 'mercator') {
|
if (projectionRef.current === 'mercator') {
|
||||||
@ -175,6 +213,8 @@ export function useMapInit(
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 종속 hook들(useMapStyleSettings 등)이 저장된 설정을 적용하도록 트리거
|
||||||
|
setMapSyncEpoch((prev) => prev + 1);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@ -24,14 +24,8 @@ export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ship 레이어/소스는 useGlobeShips에서 visibility 토글로 관리 (재생성 비용 회피)
|
||||||
const GLOBE_NATIVE_LAYER_IDS = [
|
const GLOBE_NATIVE_LAYER_IDS = [
|
||||||
'ships-globe-halo',
|
|
||||||
'ships-globe-outline',
|
|
||||||
'ships-globe',
|
|
||||||
'ships-globe-label',
|
|
||||||
'ships-globe-hover-halo',
|
|
||||||
'ships-globe-hover-outline',
|
|
||||||
'ships-globe-hover',
|
|
||||||
'pair-lines-ml',
|
'pair-lines-ml',
|
||||||
'fc-lines-ml',
|
'fc-lines-ml',
|
||||||
'fleet-circles-ml-fill',
|
'fleet-circles-ml-fill',
|
||||||
@ -47,8 +41,6 @@ const GLOBE_NATIVE_LAYER_IDS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const GLOBE_NATIVE_SOURCE_IDS = [
|
const GLOBE_NATIVE_SOURCE_IDS = [
|
||||||
'ships-globe-src',
|
|
||||||
'ships-globe-hover-src',
|
|
||||||
'pair-lines-ml-src',
|
'pair-lines-ml-src',
|
||||||
'fc-lines-ml-src',
|
'fc-lines-ml-src',
|
||||||
'fleet-circles-ml-src',
|
'fleet-circles-ml-src',
|
||||||
|
|||||||
@ -61,6 +61,7 @@ export interface Map3DProps {
|
|||||||
mapStyleSettings?: MapStyleSettings;
|
mapStyleSettings?: MapStyleSettings;
|
||||||
initialView?: MapViewState | null;
|
initialView?: MapViewState | null;
|
||||||
onViewStateChange?: (view: MapViewState) => void;
|
onViewStateChange?: (view: MapViewState) => void;
|
||||||
|
onGlobeShipsReady?: (ready: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DashSeg = {
|
export type DashSeg = {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user