- 대상선박 우클릭 컨텍스트 메뉴로 항적 조회 (6h~5d) - Mercator: PathLayer(고정) + TripsLayer(애니메이션) + ScatterplotLayer(포인트) - Globe: MapLibre 네이티브 line + arrow + circle 레이어 - rAF 직접 overlay 조작으로 React 재렌더링 방지 - SVG 아이콘 data URL 캐시로 네트워크 재요청 방지 - 항적 조회 시 자동 fitBounds (전체 항적 뷰포트 맞춤) - API 프록시 /api/ais-target/:mmsi/track 엔드포인트 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
142 lines
3.5 KiB
TypeScript
142 lines
3.5 KiB
TypeScript
import maplibregl, {
|
|
type GeoJSONSourceSpecification,
|
|
type LayerSpecification,
|
|
} from 'maplibre-gl';
|
|
|
|
export function removeLayerIfExists(map: maplibregl.Map, layerId: string | null | undefined) {
|
|
if (!layerId) return;
|
|
try {
|
|
if (map.getLayer(layerId)) {
|
|
map.removeLayer(layerId);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) {
|
|
try {
|
|
if (map.getSource(sourceId)) {
|
|
map.removeSource(sourceId);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// Ship 레이어/소스는 useGlobeShips에서 visibility 토글로 관리 (재생성 비용 회피)
|
|
const GLOBE_NATIVE_LAYER_IDS = [
|
|
'pair-lines-ml',
|
|
'fc-lines-ml',
|
|
'fleet-circles-ml',
|
|
'pair-range-ml',
|
|
'subcables-hitarea',
|
|
'subcables-casing',
|
|
'subcables-line',
|
|
'subcables-glow',
|
|
'subcables-points',
|
|
'subcables-label',
|
|
'vessel-track-line',
|
|
'vessel-track-line-hitarea',
|
|
'vessel-track-arrow',
|
|
'vessel-track-pts',
|
|
'vessel-track-pts-highlight',
|
|
'deck-globe',
|
|
];
|
|
|
|
const GLOBE_NATIVE_SOURCE_IDS = [
|
|
'pair-lines-ml-src',
|
|
'fc-lines-ml-src',
|
|
'fleet-circles-ml-src',
|
|
'pair-range-ml-src',
|
|
'subcables-src',
|
|
'subcables-pts-src',
|
|
'vessel-track-line-src',
|
|
'vessel-track-pts-src',
|
|
];
|
|
|
|
export function clearGlobeNativeLayers(map: maplibregl.Map) {
|
|
for (const id of GLOBE_NATIVE_LAYER_IDS) {
|
|
removeLayerIfExists(map, id);
|
|
}
|
|
for (const id of GLOBE_NATIVE_SOURCE_IDS) {
|
|
removeSourceIfExists(map, id);
|
|
}
|
|
}
|
|
|
|
export function ensureGeoJsonSource(
|
|
map: maplibregl.Map,
|
|
sourceId: string,
|
|
data: GeoJSON.GeoJSON,
|
|
options?: Partial<Omit<GeoJSONSourceSpecification, 'type' | 'data'>>,
|
|
) {
|
|
const existing = map.getSource(sourceId);
|
|
if (existing) {
|
|
(existing as maplibregl.GeoJSONSource).setData(data);
|
|
} else {
|
|
map.addSource(sourceId, {
|
|
type: 'geojson',
|
|
data,
|
|
...options,
|
|
} satisfies GeoJSONSourceSpecification);
|
|
}
|
|
}
|
|
|
|
export function ensureLayer(
|
|
map: maplibregl.Map,
|
|
spec: LayerSpecification,
|
|
options?: { before?: string },
|
|
) {
|
|
if (map.getLayer(spec.id)) return;
|
|
const before = options?.before && map.getLayer(options.before) ? options.before : undefined;
|
|
map.addLayer(spec, before);
|
|
}
|
|
|
|
export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible: boolean) {
|
|
if (!map.getLayer(layerId)) return;
|
|
try {
|
|
map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none');
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
/**
|
|
* setLayoutProperty('visibility') wrapper — 현재 값과 동일하면 호출 생략.
|
|
* MapLibre는 setLayoutProperty 호출 시 항상 style._changed = true를 설정하여
|
|
* 모든 symbol layer의 placement를 재계산시킴. text-allow-overlap:false 라벨이
|
|
* 충돌 검사에 의해 사라지는 문제를 방지하기 위해, 값이 실제로 바뀔 때만 호출.
|
|
*/
|
|
export function guardedSetVisibility(map: maplibregl.Map, layerId: string, target: 'visible' | 'none') {
|
|
if (!map.getLayer(layerId)) return;
|
|
try {
|
|
if (map.getLayoutProperty(layerId, 'visibility') === target) return;
|
|
map.setLayoutProperty(layerId, 'visibility', target);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
export function cleanupLayers(
|
|
map: maplibregl.Map,
|
|
layerIds: string[],
|
|
sourceIds: string[],
|
|
) {
|
|
requestAnimationFrame(() => {
|
|
for (const id of layerIds) {
|
|
try {
|
|
if (map.getLayer(id)) map.removeLayer(id);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
for (const id of sourceIds) {
|
|
try {
|
|
if (map.getSource(id)) map.removeSource(id);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
});
|
|
}
|