- 대상선박 우클릭 컨텍스트 메뉴로 항적 조회 (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>
183 lines
6.8 KiB
TypeScript
183 lines
6.8 KiB
TypeScript
import { useEffect, useRef, type MutableRefObject } from 'react';
|
|
import maplibregl from 'maplibre-gl';
|
|
import type { MapStyleSettings, MapLabelLanguage, DepthColorStop, DepthFontSize } from '../../../features/mapSettings/types';
|
|
import type { BaseMapId } from '../types';
|
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
|
|
|
/* ── Depth font size presets ──────────────────────────────────────── */
|
|
const DEPTH_FONT_SIZE_MAP: Record<DepthFontSize, unknown[]> = {
|
|
small: ['interpolate', ['linear'], ['zoom'], 7, 8, 9, 9, 11, 11, 13, 13],
|
|
medium: ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16],
|
|
large: ['interpolate', ['linear'], ['zoom'], 7, 12, 9, 15, 11, 18, 13, 20],
|
|
};
|
|
|
|
/* ── Helpers ──────────────────────────────────────────────────────── */
|
|
function darkenHex(hex: string, factor = 0.85): string {
|
|
const r = parseInt(hex.slice(1, 3), 16);
|
|
const g = parseInt(hex.slice(3, 5), 16);
|
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
return `#${[r, g, b].map((c) => Math.round(c * factor).toString(16).padStart(2, '0')).join('')}`;
|
|
}
|
|
|
|
function lightenHex(hex: string, factor = 1.3): string {
|
|
const r = parseInt(hex.slice(1, 3), 16);
|
|
const g = parseInt(hex.slice(3, 5), 16);
|
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
return `#${[r, g, b].map((c) => Math.min(255, Math.round(c * factor)).toString(16).padStart(2, '0')).join('')}`;
|
|
}
|
|
|
|
/* ── Apply functions ──────────────────────────────────────────────── */
|
|
|
|
function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) {
|
|
const style = map.getStyle();
|
|
if (!style?.layers) return;
|
|
for (const layer of style.layers) {
|
|
if (layer.type !== 'symbol') continue;
|
|
const layout = (layer as { layout?: Record<string, unknown> }).layout;
|
|
if (!layout?.['text-field']) continue;
|
|
if (layer.id.startsWith('bathymetry-labels')) continue;
|
|
const textField =
|
|
lang === 'local'
|
|
? ['get', 'name']
|
|
: ['coalesce', ['get', `name:${lang}`], ['get', 'name']];
|
|
try {
|
|
map.setLayoutProperty(layer.id, 'text-field', textField);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyLandColor(map: maplibregl.Map, color: string) {
|
|
const style = map.getStyle();
|
|
if (!style?.layers) return;
|
|
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
|
const darkVariant = darkenHex(color, 0.8);
|
|
for (const layer of style.layers) {
|
|
const id = layer.id;
|
|
if (id.startsWith('bathymetry-')) continue;
|
|
if (id.startsWith('subcables-')) continue;
|
|
if (id.startsWith('zones-')) continue;
|
|
if (id.startsWith('ships-')) continue;
|
|
if (id.startsWith('pair-')) continue;
|
|
if (id.startsWith('fc-')) continue;
|
|
if (id.startsWith('fleet-')) continue;
|
|
if (id.startsWith('predict-')) continue;
|
|
if (id.startsWith('vessel-track-')) continue;
|
|
if (id === 'deck-globe') continue;
|
|
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
|
|
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
|
|
if (isWater) continue;
|
|
try {
|
|
if (layer.type === 'background') {
|
|
map.setPaintProperty(id, 'background-color', color);
|
|
} else if (layer.type === 'fill') {
|
|
map.setPaintProperty(id, 'fill-color', darkVariant);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyWaterBaseColor(map: maplibregl.Map, fillColor: string) {
|
|
const style = map.getStyle();
|
|
if (!style?.layers) return;
|
|
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
|
const lineColor = darkenHex(fillColor, 0.85);
|
|
for (const layer of style.layers) {
|
|
const id = layer.id;
|
|
if (id.startsWith('bathymetry-')) continue;
|
|
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
|
|
if (!waterRegex.test(id) && !waterRegex.test(sourceLayer)) continue;
|
|
try {
|
|
if (layer.type === 'fill') {
|
|
map.setPaintProperty(id, 'fill-color', fillColor);
|
|
} else if (layer.type === 'line') {
|
|
map.setPaintProperty(id, 'line-color', lineColor);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
|
|
const depth = ['to-number', ['get', 'depth']];
|
|
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
|
|
if (sorted.length === 0) return;
|
|
const expr: unknown[] = ['interpolate', ['linear'], depth];
|
|
for (const s of sorted) {
|
|
expr.push(s.depth, s.color);
|
|
}
|
|
// 0m까지 확장 (최천층 stop이 0보다 깊으면)
|
|
const shallowest = sorted[sorted.length - 1];
|
|
if (shallowest.depth < 0) {
|
|
expr.push(0, lightenHex(shallowest.color, 1.8));
|
|
}
|
|
if (!map.getLayer('bathymetry-fill')) return;
|
|
try {
|
|
map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
|
|
const expr = DEPTH_FONT_SIZE_MAP[size];
|
|
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
|
|
if (!map.getLayer(layerId)) continue;
|
|
try {
|
|
map.setLayoutProperty(layerId, 'text-size', expr);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyDepthFontColor(map: maplibregl.Map, color: string) {
|
|
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
|
|
if (!map.getLayer(layerId)) continue;
|
|
try {
|
|
map.setPaintProperty(layerId, 'text-color', color);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── Hook ──────────────────────────────────────────────────────────── */
|
|
export function useMapStyleSettings(
|
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
|
settings: MapStyleSettings | undefined,
|
|
opts: { baseMap: BaseMapId; mapSyncEpoch: number },
|
|
) {
|
|
const settingsRef = useRef(settings);
|
|
useEffect(() => {
|
|
settingsRef.current = settings;
|
|
});
|
|
|
|
const { baseMap, mapSyncEpoch } = opts;
|
|
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
const s = settingsRef.current;
|
|
if (!map || !s) return;
|
|
|
|
const stop = onMapStyleReady(map, () => {
|
|
applyLabelLanguage(map, s.labelLanguage);
|
|
applyLandColor(map, s.landColor);
|
|
applyWaterBaseColor(map, s.waterBaseColor);
|
|
if (baseMap === 'enhanced') {
|
|
applyDepthGradient(map, s.depthStops);
|
|
applyDepthFontSize(map, s.depthFontSize);
|
|
applyDepthFontColor(map, s.depthFontColor);
|
|
}
|
|
kickRepaint(map);
|
|
});
|
|
|
|
return () => stop();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [settings, baseMap, mapSyncEpoch]);
|
|
}
|