Merge branch 'feature/globe-ship-precompute' into develop
This commit is contained in:
커밋
4cf0f20504
@ -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 { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
||||
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
|
||||
@ -132,6 +132,12 @@ export function DashboardPage() {
|
||||
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
|
||||
|
||||
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()));
|
||||
useEffect(() => {
|
||||
@ -663,7 +669,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="map-area">
|
||||
{isProjectionLoading ? (
|
||||
{showMapLoader ? (
|
||||
<div className="map-loader-overlay" role="status" aria-live="polite">
|
||||
<div className="map-loader-overlay__panel">
|
||||
<div className="map-loader-overlay__spinner" />
|
||||
@ -695,7 +701,8 @@ export function DashboardPage() {
|
||||
fcLinks={fcLinksForMap}
|
||||
fleetCircles={fleetCirclesForMap}
|
||||
fleetFocus={fleetFocus}
|
||||
onProjectionLoadingChange={setIsProjectionLoading}
|
||||
onProjectionLoadingChange={handleProjectionLoadingChange}
|
||||
onGlobeShipsReady={setIsGlobeShipsReady}
|
||||
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
||||
onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))}
|
||||
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
||||
|
||||
@ -68,6 +68,7 @@ export function Map3D({
|
||||
mapStyleSettings,
|
||||
initialView,
|
||||
onViewStateChange,
|
||||
onGlobeShipsReady,
|
||||
}: Props) {
|
||||
void onHoverFleet;
|
||||
void onClearFleetHover;
|
||||
@ -474,6 +475,7 @@ export function Map3D({
|
||||
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
||||
onSelectMmsi, onToggleHighlightMmsi, targets, overlays,
|
||||
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 { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||
@ -48,18 +48,81 @@ export function useGlobeShips(
|
||||
selectedMmsi: number | null;
|
||||
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
||||
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
||||
onGlobeShipsReady?: (ready: boolean) => void;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
|
||||
shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets,
|
||||
overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
||||
onGlobeShipsReady,
|
||||
} = opts;
|
||||
|
||||
const globeShipsEpochRef = useRef(-1);
|
||||
const globeShipIconLoadingRef = useRef(false);
|
||||
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
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
@ -227,81 +290,14 @@ export function useGlobeShips(
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
// 이미지 보장: useMapInit에서 미리 로드됨 → 대부분 즉시 반환
|
||||
// 미리 로드되지 않았다면 fallback canvas 아이콘 사용
|
||||
const ensureImage = () => {
|
||||
ensureFallbackShipImage(map, imgId);
|
||||
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
|
||||
if (globeShipIconLoadingRef.current) return;
|
||||
if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
|
||||
|
||||
const addFallbackImage = () => {
|
||||
ensureFallbackShipImage(map, imgId);
|
||||
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
|
||||
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);
|
||||
}
|
||||
}
|
||||
// useMapInit에서 pre-load가 아직 완료되지 않은 경우 fallback으로 진행
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
@ -310,6 +306,7 @@ export function useGlobeShips(
|
||||
|
||||
if (projection !== 'globe' || !settings.showShips) {
|
||||
remove();
|
||||
onGlobeShipsReady?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -323,69 +320,8 @@ export function useGlobeShips(
|
||||
console.warn('Ship icon image setup failed:', e);
|
||||
}
|
||||
|
||||
const globeShipData = shipData;
|
||||
const geojson: GeoJSON.FeatureCollection<GeoJSON.Point> = {
|
||||
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 || '',
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
|
||||
const geojson = globeShipGeoJson;
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
@ -684,6 +620,7 @@ export function useGlobeShips(
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
onGlobeShipsReady?.(true);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
@ -694,12 +631,12 @@ export function useGlobeShips(
|
||||
projection,
|
||||
settings.showShips,
|
||||
overlays.shipLabels,
|
||||
shipData,
|
||||
legacyHits,
|
||||
globeShipGeoJson,
|
||||
selectedMmsi,
|
||||
isBaseHighlightedMmsi,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
onGlobeShipsReady,
|
||||
]);
|
||||
|
||||
// Globe hover overlay ships
|
||||
|
||||
@ -3,7 +3,7 @@ import maplibregl, { type StyleSpecification } from 'maplibre-gl';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||
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 { ensureSeamarkOverlay } from '../layers/seamark';
|
||||
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.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;
|
||||
|
||||
if (projectionRef.current === 'mercator') {
|
||||
@ -175,6 +213,8 @@ export function useMapInit(
|
||||
// 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 = [
|
||||
'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',
|
||||
'fc-lines-ml',
|
||||
'fleet-circles-ml-fill',
|
||||
@ -47,8 +41,6 @@ const GLOBE_NATIVE_LAYER_IDS = [
|
||||
];
|
||||
|
||||
const GLOBE_NATIVE_SOURCE_IDS = [
|
||||
'ships-globe-src',
|
||||
'ships-globe-hover-src',
|
||||
'pair-lines-ml-src',
|
||||
'fc-lines-ml-src',
|
||||
'fleet-circles-ml-src',
|
||||
|
||||
@ -61,6 +61,7 @@ export interface Map3DProps {
|
||||
mapStyleSettings?: MapStyleSettings;
|
||||
initialView?: MapViewState | null;
|
||||
onViewStateChange?: (view: MapViewState) => void;
|
||||
onGlobeShipsReady?: (ready: boolean) => void;
|
||||
}
|
||||
|
||||
export type DashSeg = {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user