Merge branch 'feature/globe-ship-precompute' into develop

This commit is contained in:
htlee 2026-02-16 12:26:47 +09:00
커밋 4cf0f20504
6개의 변경된 파일130개의 추가작업 그리고 151개의 파일을 삭제

파일 보기

@ -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 = {