2026-02-16 23:35:03 +09:00
|
|
|
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';
|
|
|
|
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
2026-02-17 10:52:51 +09:00
|
|
|
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
|
2026-02-16 23:35:03 +09:00
|
|
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
|
|
|
|
import type { Map3DSettings, MapProjectionId } from '../types';
|
|
|
|
|
import {
|
|
|
|
|
ANCHORED_SHIP_ICON_ID,
|
|
|
|
|
GLOBE_ICON_HEADING_OFFSET_DEG,
|
|
|
|
|
GLOBE_OUTLINE_PERMITTED,
|
|
|
|
|
GLOBE_OUTLINE_OTHER,
|
|
|
|
|
} from '../constants';
|
|
|
|
|
import { isFiniteNumber } from '../lib/setUtils';
|
|
|
|
|
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
|
|
|
|
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
|
|
|
|
import {
|
|
|
|
|
isAnchoredShip,
|
|
|
|
|
getDisplayHeading,
|
|
|
|
|
getGlobeBaseShipColor,
|
|
|
|
|
} from '../lib/shipUtils';
|
|
|
|
|
import {
|
|
|
|
|
buildFallbackGlobeAnchoredShipIcon,
|
|
|
|
|
ensureFallbackShipImage,
|
|
|
|
|
} from '../lib/globeShipIcon';
|
|
|
|
|
import { clampNumber } from '../lib/geometry';
|
|
|
|
|
import { guardedSetVisibility } from '../lib/layerHelpers';
|
|
|
|
|
|
2026-02-17 10:52:51 +09:00
|
|
|
// ── Alarm pulse animation constants ──
|
|
|
|
|
const ALARM_PULSE_R_MIN = 8;
|
|
|
|
|
const ALARM_PULSE_R_MAX = 14;
|
|
|
|
|
const ALARM_PULSE_R_HOVER_MIN = 12;
|
|
|
|
|
const ALARM_PULSE_R_HOVER_MAX = 18;
|
|
|
|
|
const ALARM_PULSE_PERIOD_MS = 1500;
|
|
|
|
|
|
|
|
|
|
/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label, alarm pulse, alarm badge) */
|
2026-02-16 23:35:03 +09:00
|
|
|
export function useGlobeShipLayers(
|
|
|
|
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
|
|
|
|
projectionBusyRef: MutableRefObject<boolean>,
|
|
|
|
|
reorderGlobeFeatureLayers: () => void,
|
|
|
|
|
opts: {
|
|
|
|
|
projection: MapProjectionId;
|
|
|
|
|
settings: Map3DSettings;
|
|
|
|
|
shipData: AisTarget[];
|
|
|
|
|
overlays: MapToggleState;
|
|
|
|
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
|
|
|
|
selectedMmsi: number | null;
|
|
|
|
|
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
|
|
|
|
mapSyncEpoch: number;
|
|
|
|
|
onGlobeShipsReady?: (ready: boolean) => void;
|
2026-02-17 10:52:51 +09:00
|
|
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
2026-02-16 23:35:03 +09:00
|
|
|
},
|
|
|
|
|
) {
|
|
|
|
|
const {
|
|
|
|
|
projection, settings, shipData, overlays, legacyHits,
|
|
|
|
|
selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, onGlobeShipsReady,
|
2026-02-17 10:52:51 +09:00
|
|
|
alarmMmsiMap,
|
2026-02-16 23:35:03 +09:00
|
|
|
} = opts;
|
|
|
|
|
|
|
|
|
|
const epochRef = useRef(-1);
|
2026-02-17 10:52:51 +09:00
|
|
|
const breatheRafRef = useRef<number>(0);
|
2026-02-17 16:38:51 +09:00
|
|
|
const prevGeoJsonRef = useRef<GeoJSON.FeatureCollection | null>(null);
|
|
|
|
|
const prevAlarmGeoJsonRef = useRef<GeoJSON.FeatureCollection | null>(null);
|
2026-02-16 23:35:03 +09:00
|
|
|
|
|
|
|
|
// 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;
|
2026-02-17 10:52:51 +09:00
|
|
|
const alarmKind = alarmMmsiMap?.get(t.mmsi) ?? null;
|
|
|
|
|
const baseName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
|
|
|
|
|
const labelName = alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
|
2026-02-16 23:35:03 +09:00
|
|
|
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);
|
2026-02-17 16:38:51 +09:00
|
|
|
// 상호작용 스케일링 제거 — selected/highlighted는 feature-state로 처리
|
|
|
|
|
// hover overlay 레이어가 확대 + z-priority를 담당
|
|
|
|
|
const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3);
|
|
|
|
|
const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45);
|
|
|
|
|
const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8);
|
|
|
|
|
const iconSize14 = clampNumber(0.85 * sizeScale, 0.45, 2.6);
|
|
|
|
|
const iconSize18 = clampNumber(2.5 * sizeScale, 1.0, 6.0);
|
2026-02-16 23:35:03 +09:00
|
|
|
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,
|
|
|
|
|
}),
|
2026-02-17 16:38:51 +09:00
|
|
|
iconSize3,
|
|
|
|
|
iconSize7,
|
|
|
|
|
iconSize10,
|
|
|
|
|
iconSize14,
|
|
|
|
|
iconSize18,
|
2026-02-16 23:35:03 +09:00
|
|
|
sizeScale,
|
|
|
|
|
permitted: legacy ? 1 : 0,
|
|
|
|
|
code: legacy?.shipCode || '',
|
2026-02-17 10:52:51 +09:00
|
|
|
alarmed: alarmKind ? 1 : 0,
|
|
|
|
|
alarmKind: alarmKind ?? '',
|
|
|
|
|
alarmBadgeLabel: alarmKind ? ALARM_BADGE[alarmKind].label : '',
|
|
|
|
|
alarmBadgeColor: alarmKind ? ALARM_BADGE[alarmKind].color : '#000',
|
2026-02-20 03:57:45 +09:00
|
|
|
hasPhoto: t.shipImagePath ? 1 : 0,
|
2026-02-16 23:35:03 +09:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
};
|
2026-02-17 16:38:51 +09:00
|
|
|
}, [shipData, legacyHits, alarmMmsiMap]);
|
2026-02-17 10:52:51 +09:00
|
|
|
|
|
|
|
|
// Alarm-only GeoJSON — separate source to avoid badge symbol re-placement
|
|
|
|
|
// when the main ship source updates (position polling)
|
|
|
|
|
const alarmGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
|
|
|
|
|
if (!alarmMmsiMap || alarmMmsiMap.size === 0) {
|
|
|
|
|
return { type: 'FeatureCollection', features: [] };
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
type: 'FeatureCollection',
|
|
|
|
|
features: shipData
|
|
|
|
|
.filter((t) => alarmMmsiMap.has(t.mmsi))
|
|
|
|
|
.map((t) => {
|
|
|
|
|
const alarmKind = alarmMmsiMap.get(t.mmsi)!;
|
|
|
|
|
return {
|
|
|
|
|
type: 'Feature' as const,
|
2026-02-17 16:38:51 +09:00
|
|
|
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
2026-02-17 10:52:51 +09:00
|
|
|
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
|
|
|
|
|
properties: {
|
|
|
|
|
mmsi: t.mmsi,
|
|
|
|
|
alarmed: 1,
|
|
|
|
|
alarmBadgeLabel: ALARM_BADGE[alarmKind].label,
|
|
|
|
|
alarmBadgeColor: ALARM_BADGE[alarmKind].color,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
};
|
2026-02-17 16:38:51 +09:00
|
|
|
}, [shipData, alarmMmsiMap]);
|
2026-02-16 23:35:03 +09:00
|
|
|
|
|
|
|
|
// Ships in globe mode
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
|
|
|
|
const imgId = 'ship-globe-icon';
|
|
|
|
|
const anchoredImgId = ANCHORED_SHIP_ICON_ID;
|
|
|
|
|
const srcId = 'ships-globe-src';
|
2026-02-17 10:52:51 +09:00
|
|
|
const alarmSrcId = 'ships-globe-alarm-src';
|
2026-02-16 23:35:03 +09:00
|
|
|
const haloId = 'ships-globe-halo';
|
|
|
|
|
const outlineId = 'ships-globe-outline';
|
|
|
|
|
const symbolLiteId = 'ships-globe-lite';
|
|
|
|
|
const symbolId = 'ships-globe';
|
|
|
|
|
const labelId = 'ships-globe-label';
|
2026-02-20 03:57:45 +09:00
|
|
|
const photoId = 'ships-globe-photo';
|
2026-02-17 10:52:51 +09:00
|
|
|
const pulseId = 'ships-globe-alarm-pulse';
|
|
|
|
|
const badgeId = 'ships-globe-alarm-badge';
|
2026-02-16 23:35:03 +09:00
|
|
|
|
|
|
|
|
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
|
|
|
|
|
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
|
|
|
|
|
const hide = () => {
|
2026-02-20 03:57:45 +09:00
|
|
|
for (const id of [badgeId, photoId, labelId, symbolId, symbolLiteId, pulseId, outlineId, haloId]) {
|
2026-02-16 23:35:03 +09:00
|
|
|
guardedSetVisibility(map, id, 'none');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ensureImage = () => {
|
|
|
|
|
ensureFallbackShipImage(map, imgId);
|
|
|
|
|
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
|
|
|
|
|
if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
|
|
|
|
|
kickRepaint(map);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ensure = () => {
|
|
|
|
|
if (!settings.showShips) {
|
|
|
|
|
hide();
|
|
|
|
|
onGlobeShipsReady?.(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 빠른 visibility 토글 — projectionBusy 중에도 실행
|
|
|
|
|
// guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출
|
|
|
|
|
// → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지
|
|
|
|
|
const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
|
|
|
|
|
const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
|
2026-02-20 03:57:45 +09:00
|
|
|
const photoVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipPhotos ? 'visible' : 'none';
|
2026-02-16 23:35:03 +09:00
|
|
|
if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) {
|
|
|
|
|
const changed =
|
|
|
|
|
map.getLayoutProperty(symbolId, 'visibility') !== visibility ||
|
|
|
|
|
map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility;
|
|
|
|
|
if (changed) {
|
2026-02-17 10:52:51 +09:00
|
|
|
for (const id of [haloId, outlineId, pulseId, symbolLiteId, symbolId, badgeId]) {
|
2026-02-16 23:35:03 +09:00
|
|
|
guardedSetVisibility(map, id, visibility);
|
|
|
|
|
}
|
|
|
|
|
if (projection === 'globe') kickRepaint(map);
|
|
|
|
|
}
|
|
|
|
|
guardedSetVisibility(map, labelId, labelVisibility);
|
2026-02-20 03:57:45 +09:00
|
|
|
guardedSetVisibility(map, photoId, photoVisibility);
|
2026-02-16 23:35:03 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 데이터 업데이트는 projectionBusy 중에는 차단
|
|
|
|
|
if (projectionBusyRef.current) {
|
|
|
|
|
// 레이어가 이미 존재하면 ready 상태 유지
|
|
|
|
|
if (map.getLayer(symbolId)) onGlobeShipsReady?.(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!map.isStyleLoaded()) return;
|
|
|
|
|
|
|
|
|
|
if (epochRef.current !== mapSyncEpoch) {
|
|
|
|
|
epochRef.current = mapSyncEpoch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
ensureImage();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Ship icon image setup failed:', e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
|
2026-02-17 16:38:51 +09:00
|
|
|
// 참조 동일성 기반 setData 스킵 — 위치 변경 없는 epoch/설정 변경 시 재전송 방지
|
2026-02-16 23:35:03 +09:00
|
|
|
const geojson = globeShipGeoJson;
|
2026-02-17 16:38:51 +09:00
|
|
|
const geoJsonChanged = geojson !== prevGeoJsonRef.current;
|
2026-02-16 23:35:03 +09:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
2026-02-17 16:38:51 +09:00
|
|
|
if (existing) {
|
|
|
|
|
if (geoJsonChanged) existing.setData(geojson);
|
|
|
|
|
} else {
|
|
|
|
|
map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification);
|
|
|
|
|
}
|
|
|
|
|
prevGeoJsonRef.current = geojson;
|
2026-02-16 23:35:03 +09:00
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Ship source setup failed:', e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 10:52:51 +09:00
|
|
|
// Alarm source — isolated from main source for stable badge rendering
|
|
|
|
|
try {
|
|
|
|
|
const existingAlarm = map.getSource(alarmSrcId) as GeoJSONSource | undefined;
|
2026-02-17 16:38:51 +09:00
|
|
|
const alarmChanged = alarmGeoJson !== prevAlarmGeoJsonRef.current;
|
|
|
|
|
if (existingAlarm) {
|
|
|
|
|
if (alarmChanged) existingAlarm.setData(alarmGeoJson);
|
|
|
|
|
} else {
|
|
|
|
|
map.addSource(alarmSrcId, { type: 'geojson', data: alarmGeoJson } as GeoJSONSourceSpecification);
|
|
|
|
|
}
|
|
|
|
|
prevAlarmGeoJsonRef.current = alarmGeoJson;
|
2026-02-17 10:52:51 +09:00
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Alarm source setup failed:', e);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 23:35:03 +09:00
|
|
|
const before = undefined;
|
2026-02-17 16:38:51 +09:00
|
|
|
let needReorder = false;
|
2026-02-16 23:35:03 +09:00
|
|
|
const priorityFilter = [
|
|
|
|
|
'any',
|
|
|
|
|
['==', ['to-number', ['get', 'permitted'], 0], 1],
|
2026-02-17 16:38:51 +09:00
|
|
|
['==', ['to-number', ['get', 'alarmed'], 0], 1],
|
2026-02-16 23:35:03 +09:00
|
|
|
] as unknown as unknown[];
|
|
|
|
|
const nonPriorityFilter = [
|
|
|
|
|
'all',
|
|
|
|
|
['==', ['to-number', ['get', 'permitted'], 0], 0],
|
2026-02-17 16:38:51 +09:00
|
|
|
['==', ['to-number', ['get', 'alarmed'], 0], 0],
|
2026-02-16 23:35:03 +09:00
|
|
|
] as unknown as unknown[];
|
|
|
|
|
|
|
|
|
|
if (!map.getLayer(haloId)) {
|
2026-02-17 16:38:51 +09:00
|
|
|
needReorder = true;
|
2026-02-16 23:35:03 +09:00
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: haloId,
|
|
|
|
|
type: 'circle',
|
|
|
|
|
source: srcId,
|
|
|
|
|
layout: {
|
|
|
|
|
visibility,
|
|
|
|
|
'circle-sort-key': [
|
|
|
|
|
'case',
|
2026-02-17 10:52:51 +09:00
|
|
|
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 112,
|
2026-02-16 23:35:03 +09:00
|
|
|
['==', ['get', 'permitted'], 1], 110,
|
2026-02-17 10:52:51 +09:00
|
|
|
['==', ['get', 'alarmed'], 1], 22,
|
2026-02-16 23:35:03 +09:00
|
|
|
20,
|
|
|
|
|
] as never,
|
|
|
|
|
},
|
|
|
|
|
paint: {
|
|
|
|
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
|
|
|
|
'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
|
|
|
|
'circle-opacity': [
|
|
|
|
|
'case',
|
2026-02-17 16:38:51 +09:00
|
|
|
['==', ['feature-state', 'selected'], 1], 0.38,
|
|
|
|
|
['==', ['feature-state', 'highlighted'], 1], 0.34,
|
2026-02-20 03:45:25 +09:00
|
|
|
['==', ['get', 'permitted'], 1], 0.16,
|
|
|
|
|
0.25,
|
2026-02-16 23:35:03 +09:00
|
|
|
] as never,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Ship halo layer add failed:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!map.getLayer(outlineId)) {
|
2026-02-17 16:38:51 +09:00
|
|
|
needReorder = true;
|
2026-02-16 23:35:03 +09:00
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: outlineId,
|
|
|
|
|
type: 'circle',
|
|
|
|
|
source: srcId,
|
|
|
|
|
paint: {
|
|
|
|
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
|
|
|
|
'circle-color': 'rgba(0,0,0,0)',
|
|
|
|
|
'circle-stroke-color': [
|
|
|
|
|
'case',
|
2026-02-17 16:38:51 +09:00
|
|
|
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
|
|
|
|
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
2026-02-16 23:35:03 +09:00
|
|
|
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
|
|
|
|
|
GLOBE_OUTLINE_OTHER,
|
|
|
|
|
] as never,
|
|
|
|
|
'circle-stroke-width': [
|
|
|
|
|
'case',
|
2026-02-17 16:38:51 +09:00
|
|
|
['==', ['feature-state', 'selected'], 1], 3.4,
|
|
|
|
|
['==', ['feature-state', 'highlighted'], 1], 2.7,
|
2026-02-16 23:35:03 +09:00
|
|
|
['==', ['get', 'permitted'], 1], 1.8,
|
2026-02-20 03:45:25 +09:00
|
|
|
1.2,
|
2026-02-16 23:35:03 +09:00
|
|
|
] as never,
|
|
|
|
|
'circle-stroke-opacity': 0.85,
|
|
|
|
|
},
|
|
|
|
|
layout: {
|
|
|
|
|
visibility,
|
|
|
|
|
'circle-sort-key': [
|
|
|
|
|
'case',
|
2026-02-17 10:52:51 +09:00
|
|
|
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 122,
|
2026-02-16 23:35:03 +09:00
|
|
|
['==', ['get', 'permitted'], 1], 120,
|
2026-02-17 10:52:51 +09:00
|
|
|
['==', ['get', 'alarmed'], 1], 32,
|
2026-02-16 23:35:03 +09:00
|
|
|
30,
|
|
|
|
|
] as never,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Ship outline layer add failed:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 10:52:51 +09:00
|
|
|
// Alarm pulse circle (above outline, below ship icons)
|
|
|
|
|
// Uses separate alarm source for stable rendering
|
|
|
|
|
if (!map.getLayer(pulseId)) {
|
2026-02-17 16:38:51 +09:00
|
|
|
needReorder = true;
|
2026-02-17 10:52:51 +09:00
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: pulseId,
|
|
|
|
|
type: 'circle',
|
|
|
|
|
source: alarmSrcId,
|
|
|
|
|
filter: ['==', ['get', 'alarmed'], 1] as never,
|
|
|
|
|
layout: { visibility },
|
|
|
|
|
paint: {
|
|
|
|
|
'circle-radius': ALARM_PULSE_R_MIN,
|
|
|
|
|
'circle-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
|
|
|
|
|
'circle-opacity': 0.35,
|
|
|
|
|
'circle-stroke-width': 0,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Ship alarm pulse layer add failed:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 23:35:03 +09:00
|
|
|
if (!map.getLayer(symbolLiteId)) {
|
2026-02-17 16:38:51 +09:00
|
|
|
needReorder = true;
|
2026-02-16 23:35:03 +09:00
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: symbolLiteId,
|
|
|
|
|
type: 'symbol',
|
|
|
|
|
source: srcId,
|
|
|
|
|
minzoom: 6.5,
|
|
|
|
|
filter: nonPriorityFilter as never,
|
|
|
|
|
layout: {
|
|
|
|
|
visibility,
|
|
|
|
|
'symbol-sort-key': 40 as never,
|
|
|
|
|
'icon-image': [
|
|
|
|
|
'case',
|
|
|
|
|
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
|
|
|
|
|
anchoredImgId,
|
|
|
|
|
imgId,
|
|
|
|
|
] as never,
|
|
|
|
|
'icon-size': [
|
|
|
|
|
'interpolate',
|
|
|
|
|
['linear'],
|
|
|
|
|
['zoom'],
|
|
|
|
|
6.5,
|
|
|
|
|
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45],
|
|
|
|
|
8,
|
|
|
|
|
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62],
|
|
|
|
|
10,
|
|
|
|
|
['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72],
|
|
|
|
|
14,
|
|
|
|
|
['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78],
|
|
|
|
|
18,
|
|
|
|
|
['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78],
|
|
|
|
|
] as unknown as number[],
|
|
|
|
|
'icon-allow-overlap': true,
|
|
|
|
|
'icon-ignore-placement': true,
|
|
|
|
|
'icon-anchor': 'center',
|
|
|
|
|
'icon-rotate': [
|
|
|
|
|
'case',
|
|
|
|
|
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
|
|
|
|
|
0,
|
|
|
|
|
['to-number', ['get', 'heading'], 0],
|
|
|
|
|
] as never,
|
|
|
|
|
'icon-rotation-alignment': 'map',
|
|
|
|
|
'icon-pitch-alignment': 'map',
|
|
|
|
|
},
|
|
|
|
|
paint: {
|
|
|
|
|
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
|
|
|
|
'icon-opacity': [
|
|
|
|
|
'interpolate',
|
|
|
|
|
['linear'],
|
|
|
|
|
['zoom'],
|
|
|
|
|
6.5,
|
2026-02-20 03:45:25 +09:00
|
|
|
0.28,
|
2026-02-16 23:35:03 +09:00
|
|
|
8,
|
2026-02-20 03:45:25 +09:00
|
|
|
0.45,
|
2026-02-16 23:35:03 +09:00
|
|
|
11,
|
2026-02-20 03:45:25 +09:00
|
|
|
0.65,
|
2026-02-16 23:35:03 +09:00
|
|
|
14,
|
2026-02-20 03:45:25 +09:00
|
|
|
0.78,
|
2026-02-16 23:35:03 +09:00
|
|
|
] as never,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Ship lite symbol layer add failed:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!map.getLayer(symbolId)) {
|
2026-02-17 16:38:51 +09:00
|
|
|
needReorder = true;
|
2026-02-16 23:35:03 +09:00
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: symbolId,
|
|
|
|
|
type: 'symbol',
|
|
|
|
|
source: srcId,
|
|
|
|
|
filter: priorityFilter as never,
|
|
|
|
|
layout: {
|
|
|
|
|
visibility,
|
|
|
|
|
'symbol-sort-key': [
|
|
|
|
|
'case',
|
2026-02-17 10:52:51 +09:00
|
|
|
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 132,
|
2026-02-16 23:35:03 +09:00
|
|
|
['==', ['get', 'permitted'], 1], 130,
|
2026-02-17 10:52:51 +09:00
|
|
|
['==', ['get', 'alarmed'], 1], 47,
|
2026-02-16 23:35:03 +09:00
|
|
|
45,
|
|
|
|
|
] as never,
|
|
|
|
|
'icon-image': [
|
|
|
|
|
'case',
|
|
|
|
|
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
|
|
|
|
|
anchoredImgId,
|
|
|
|
|
imgId,
|
|
|
|
|
] as never,
|
|
|
|
|
'icon-size': [
|
|
|
|
|
'interpolate', ['linear'], ['zoom'],
|
|
|
|
|
3, ['to-number', ['get', 'iconSize3'], 0.35],
|
|
|
|
|
7, ['to-number', ['get', 'iconSize7'], 0.45],
|
|
|
|
|
10, ['to-number', ['get', 'iconSize10'], 0.58],
|
|
|
|
|
14, ['to-number', ['get', 'iconSize14'], 0.85],
|
|
|
|
|
18, ['to-number', ['get', 'iconSize18'], 2.5],
|
|
|
|
|
] as unknown as number[],
|
|
|
|
|
'icon-allow-overlap': true,
|
|
|
|
|
'icon-ignore-placement': true,
|
|
|
|
|
'icon-anchor': 'center',
|
|
|
|
|
'icon-rotate': [
|
|
|
|
|
'case',
|
|
|
|
|
['==', ['to-number', ['get', 'isAnchored'], 0], 1], 0,
|
|
|
|
|
['to-number', ['get', 'heading'], 0],
|
|
|
|
|
] as never,
|
|
|
|
|
'icon-rotation-alignment': 'map',
|
|
|
|
|
'icon-pitch-alignment': 'map',
|
|
|
|
|
},
|
|
|
|
|
paint: {
|
|
|
|
|
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
|
|
|
|
'icon-opacity': [
|
|
|
|
|
'case',
|
2026-02-17 16:38:51 +09:00
|
|
|
['==', ['feature-state', 'selected'], 1], 1,
|
|
|
|
|
['==', ['feature-state', 'highlighted'], 1], 0.95,
|
2026-02-16 23:35:03 +09:00
|
|
|
['==', ['get', 'permitted'], 1], 0.93,
|
|
|
|
|
0.9,
|
|
|
|
|
] as never,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Ship symbol layer add failed:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 03:57:45 +09:00
|
|
|
// Photo indicator circle (above ship icons, below labels)
|
|
|
|
|
if (!map.getLayer(photoId)) {
|
|
|
|
|
needReorder = true;
|
|
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: photoId,
|
|
|
|
|
type: 'circle',
|
|
|
|
|
source: srcId,
|
|
|
|
|
filter: ['==', ['get', 'hasPhoto'], 1] as never,
|
|
|
|
|
layout: { visibility: photoVisibility },
|
|
|
|
|
paint: {
|
|
|
|
|
'circle-radius': [
|
|
|
|
|
'interpolate', ['linear'], ['zoom'],
|
|
|
|
|
3, 3, 7, 4, 10, 5, 14, 6,
|
|
|
|
|
] as never,
|
|
|
|
|
'circle-color': 'rgba(0, 188, 212, 0.7)',
|
|
|
|
|
'circle-stroke-color': 'rgba(255, 255, 255, 0.8)',
|
|
|
|
|
'circle-stroke-width': 1,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Ship photo indicator layer add failed:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 23:35:03 +09:00
|
|
|
const labelFilter = [
|
|
|
|
|
'all',
|
|
|
|
|
['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
|
2026-02-17 16:38:51 +09:00
|
|
|
['==', ['get', 'permitted'], 1],
|
2026-02-16 23:35:03 +09:00
|
|
|
] as unknown as unknown[];
|
|
|
|
|
|
|
|
|
|
if (!map.getLayer(labelId)) {
|
2026-02-17 16:38:51 +09:00
|
|
|
needReorder = true;
|
2026-02-16 23:35:03 +09:00
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: labelId,
|
|
|
|
|
type: 'symbol',
|
|
|
|
|
source: srcId,
|
|
|
|
|
minzoom: 7,
|
|
|
|
|
filter: labelFilter as never,
|
|
|
|
|
layout: {
|
|
|
|
|
visibility: labelVisibility,
|
|
|
|
|
'symbol-placement': 'point',
|
|
|
|
|
'text-field': ['get', 'labelName'] as never,
|
|
|
|
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
|
|
|
|
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
|
|
|
|
|
'text-anchor': 'top',
|
|
|
|
|
'text-offset': [0, 1.1],
|
|
|
|
|
'text-padding': 2,
|
|
|
|
|
'text-allow-overlap': false,
|
|
|
|
|
'text-ignore-placement': false,
|
|
|
|
|
},
|
|
|
|
|
paint: {
|
|
|
|
|
'text-color': [
|
|
|
|
|
'case',
|
2026-02-17 16:38:51 +09:00
|
|
|
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
|
|
|
|
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
2026-02-16 23:35:03 +09:00
|
|
|
'rgba(226,232,240,0.92)',
|
|
|
|
|
] as never,
|
|
|
|
|
'text-halo-color': 'rgba(2,6,23,0.85)',
|
|
|
|
|
'text-halo-width': 1.2,
|
|
|
|
|
'text-halo-blur': 0.8,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
undefined,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Ship label layer add failed:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 10:52:51 +09:00
|
|
|
// Alarm badge symbol (above labels)
|
|
|
|
|
// Uses separate alarm source for stable rendering
|
|
|
|
|
if (!map.getLayer(badgeId)) {
|
2026-02-17 16:38:51 +09:00
|
|
|
needReorder = true;
|
2026-02-17 10:52:51 +09:00
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: badgeId,
|
|
|
|
|
type: 'symbol',
|
|
|
|
|
source: alarmSrcId,
|
|
|
|
|
filter: ['==', ['get', 'alarmed'], 1] as never,
|
|
|
|
|
layout: {
|
|
|
|
|
visibility,
|
|
|
|
|
'text-field': ['get', 'alarmBadgeLabel'] as never,
|
|
|
|
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
|
|
|
|
'text-size': 11,
|
|
|
|
|
'text-allow-overlap': true,
|
|
|
|
|
'text-ignore-placement': true,
|
|
|
|
|
'text-anchor': 'center',
|
|
|
|
|
},
|
|
|
|
|
paint: {
|
|
|
|
|
'text-color': '#ffffff',
|
|
|
|
|
'text-halo-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
|
|
|
|
|
'text-halo-width': 6,
|
|
|
|
|
'text-translate': [12, -12],
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
undefined,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Ship alarm badge layer add failed:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 23:35:03 +09:00
|
|
|
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
|
|
|
|
|
onGlobeShipsReady?.(true);
|
2026-02-17 16:38:51 +09:00
|
|
|
// needReorder: 새 레이어가 생성된 경우에만 reorder 호출
|
|
|
|
|
// 매 AIS poll마다 28개 moveLayer → style._changed 방지
|
|
|
|
|
if (projection === 'globe' && needReorder) {
|
2026-02-16 23:35:03 +09:00
|
|
|
reorderGlobeFeatureLayers();
|
|
|
|
|
}
|
|
|
|
|
kickRepaint(map);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const stop = onMapStyleReady(map, ensure);
|
|
|
|
|
return () => {
|
|
|
|
|
stop();
|
|
|
|
|
};
|
|
|
|
|
}, [
|
|
|
|
|
projection,
|
|
|
|
|
settings.showShips,
|
|
|
|
|
overlays.shipLabels,
|
2026-02-20 03:57:45 +09:00
|
|
|
overlays.shipPhotos,
|
2026-02-16 23:35:03 +09:00
|
|
|
globeShipGeoJson,
|
2026-02-17 10:52:51 +09:00
|
|
|
alarmGeoJson,
|
2026-02-16 23:35:03 +09:00
|
|
|
mapSyncEpoch,
|
|
|
|
|
reorderGlobeFeatureLayers,
|
|
|
|
|
onGlobeShipsReady,
|
|
|
|
|
]);
|
2026-02-17 10:52:51 +09:00
|
|
|
|
2026-02-17 16:38:51 +09:00
|
|
|
// Feature-state로 상호작용 상태(selected/highlighted) 즉시 반영 — setData 없이
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map || projection !== 'globe' || projectionBusyRef.current) return;
|
|
|
|
|
if (!map.isStyleLoaded() || !map.getSource('ships-globe-src')) return;
|
|
|
|
|
|
|
|
|
|
const raf = requestAnimationFrame(() => {
|
|
|
|
|
if (!map.isStyleLoaded()) return;
|
|
|
|
|
const src = 'ships-globe-src';
|
|
|
|
|
const alarmSrc = 'ships-globe-alarm-src';
|
|
|
|
|
for (const t of shipData) {
|
|
|
|
|
if (!isFiniteNumber(t.mmsi)) continue;
|
|
|
|
|
const id = Math.trunc(t.mmsi);
|
|
|
|
|
const s = t.mmsi === selectedMmsi ? 1 : 0;
|
|
|
|
|
const h = isBaseHighlightedMmsi(t.mmsi) ? 1 : 0;
|
|
|
|
|
try {
|
|
|
|
|
map.setFeatureState({ source: src, id }, { selected: s, highlighted: h });
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
}
|
|
|
|
|
if (map.getSource(alarmSrc) && alarmMmsiMap) {
|
|
|
|
|
for (const t of shipData) {
|
|
|
|
|
if (!alarmMmsiMap.has(t.mmsi)) continue;
|
|
|
|
|
const id = Math.trunc(t.mmsi);
|
|
|
|
|
try {
|
|
|
|
|
map.setFeatureState(
|
|
|
|
|
{ source: alarmSrc, id },
|
|
|
|
|
{ selected: t.mmsi === selectedMmsi ? 1 : 0, highlighted: isBaseHighlightedMmsi(t.mmsi) ? 1 : 0 },
|
|
|
|
|
);
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
kickRepaint(map);
|
|
|
|
|
});
|
|
|
|
|
return () => cancelAnimationFrame(raf);
|
|
|
|
|
}, [projection, selectedMmsi, isBaseHighlightedMmsi, shipData, alarmMmsiMap]);
|
|
|
|
|
|
2026-02-17 10:52:51 +09:00
|
|
|
// Alarm pulse breathing animation (rAF)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map || projection !== 'globe' || !alarmMmsiMap || alarmMmsiMap.size === 0) {
|
|
|
|
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
|
|
|
|
breatheRafRef.current = 0;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const animate = () => {
|
|
|
|
|
if (!map.isStyleLoaded()) {
|
|
|
|
|
breatheRafRef.current = requestAnimationFrame(animate);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const t = (Math.sin(Date.now() / ALARM_PULSE_PERIOD_MS * Math.PI * 2) + 1) / 2;
|
|
|
|
|
const normalR = ALARM_PULSE_R_MIN + t * (ALARM_PULSE_R_MAX - ALARM_PULSE_R_MIN);
|
|
|
|
|
const hoverR = ALARM_PULSE_R_HOVER_MIN + t * (ALARM_PULSE_R_HOVER_MAX - ALARM_PULSE_R_HOVER_MIN);
|
|
|
|
|
try {
|
|
|
|
|
if (map.getLayer('ships-globe-alarm-pulse')) {
|
|
|
|
|
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [
|
|
|
|
|
'case',
|
2026-02-17 16:38:51 +09:00
|
|
|
['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]],
|
2026-02-17 10:52:51 +09:00
|
|
|
hoverR,
|
|
|
|
|
normalR,
|
|
|
|
|
] as never);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
breatheRafRef.current = requestAnimationFrame(animate);
|
|
|
|
|
};
|
|
|
|
|
breatheRafRef.current = requestAnimationFrame(animate);
|
|
|
|
|
return () => {
|
|
|
|
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
|
|
|
|
breatheRafRef.current = 0;
|
|
|
|
|
};
|
|
|
|
|
}, [projection, alarmMmsiMap]);
|
2026-02-16 23:35:03 +09:00
|
|
|
}
|