diff --git a/apps/web/src/shared/lib/map/mapConstants.ts b/apps/web/src/shared/lib/map/mapConstants.ts index f17991a..6e2339d 100644 --- a/apps/web/src/shared/lib/map/mapConstants.ts +++ b/apps/web/src/shared/lib/map/mapConstants.ts @@ -1,18 +1,4 @@ // ── Shared map constants ── -// Moved from widgets/map3d/constants.ts to resolve FSD layer violation -// (features/ must not import from widgets/). - -export const SHIP_ICON_MAPPING = { - ship: { - x: 0, - y: 0, - width: 128, - height: 128, - anchorX: 64, - anchorY: 64, - mask: true, - }, -} as const; export const DEPTH_DISABLED_PARAMS = { depthCompare: 'always', diff --git a/apps/web/src/shared/lib/map/shipKind.ts b/apps/web/src/shared/lib/map/shipKind.ts new file mode 100644 index 0000000..af94f69 --- /dev/null +++ b/apps/web/src/shared/lib/map/shipKind.ts @@ -0,0 +1,187 @@ +// ── 선종(Ship Kind) 상수 + SVG 아이콘 생성 ── +// gc-wing-simple의 SVG 기반 선종별 아이콘 시스템을 도입. +// 기타 AIS: 선종별 색상 + 이동(화살표)/정지(원형) 분리. +// 대상 선박: legacy code 색상 + 약간 더 큰 SVG + 흰색 테두리. + +import { LEGACY_CODE_COLORS_RGB, rgbToHex, type Rgb } from './palette'; + +// ── 선종 상수 (8종) ── + +export const SIGNAL_KIND = { + FISHING: '000020', + KCGV: '000021', + PASSENGER: '000022', + CARGO: '000023', + TANKER: '000024', + GOV: '000025', + NORMAL: '000027', + BUOY: '000028', +} as const; + +export type SignalKindCode = (typeof SIGNAL_KIND)[keyof typeof SIGNAL_KIND] | string; + +/** 선종별 한글 라벨 */ +export const SHIP_KIND_LABELS: Record = { + '000020': '어선', + '000021': '경비함정', + '000022': '여객선', + '000023': '화물선', + '000024': '유조선', + '000025': '관공선', + '000027': '일반', + '000028': '부이', +}; + +/** 선종별 범례/UI 색상 (hex) */ +export const SHIP_KIND_COLORS: Record = { + '000020': '#00C853', + '000021': '#FF5722', + '000022': '#2196F3', + '000023': '#9C27B0', + '000024': '#F44336', + '000025': '#FF9800', + '000027': '#607D8B', + '000028': '#795548', +}; + +/** 정렬된 선종 코드 목록 (범례 표시 순서) */ +export const SHIP_KIND_ORDER = [ + '000020', '000021', '000022', '000023', + '000024', '000025', '000027', '000028', +] as const; + +// ── SVG 아이콘 생성기 ── + +const STROKE = 'rgba(0,0,0,0.6)'; +const TARGET_STROKE = 'rgba(255,255,255,0.7)'; + +/** 이동 중 선박 SVG (화살표 형태, 32×48) */ +export function makeMovingShipSvg(fill: string): string { + return ``; +} + +/** 정지 선박 SVG (원형, 16×16) */ +export function makeStoppedShipSvg(fill: string): string { + return ``; +} + +/** 부이 SVG (다색, 32×44) */ +export function makeBuoySvg(): string { + return ``; +} + +/** 대상 선박 이동 SVG (36×52, 흰색 반투명 테두리) */ +export function makeTargetMovingShipSvg(fill: string): string { + return ``; +} + +/** 대상 선박 정지 SVG (20×20, 흰색 반투명 테두리) */ +export function makeTargetStoppedShipSvg(fill: string): string { + return ``; +} + +function toDataUri(svg: string): string { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +/** Deck.gl IconLayer getIcon 반환 타입 */ +export interface ShipIconSpec { + url: string; + width: number; + height: number; + anchorX: number; + anchorY: number; +} + +// ── 기타 AIS 아이콘 스펙 사전 생성 (8종 × 2상태 + buoy) ── + +const OTHER_ICON_SPECS: Record = {}; + +for (const code of SHIP_KIND_ORDER) { + const color = SHIP_KIND_COLORS[code] || '#607D8B'; + + if (code === '000028') { + OTHER_ICON_SPECS[`${code}-buoy`] = { + url: toDataUri(makeBuoySvg()), + width: 32, height: 44, anchorX: 16, anchorY: 22, + }; + continue; + } + + OTHER_ICON_SPECS[`${code}-moving`] = { + url: toDataUri(makeMovingShipSvg(color)), + width: 32, height: 48, anchorX: 16, anchorY: 24, + }; + OTHER_ICON_SPECS[`${code}-stopped`] = { + url: toDataUri(makeStoppedShipSvg(color)), + width: 16, height: 16, anchorX: 8, anchorY: 8, + }; +} + +// fallback +const FALLBACK_MOVING: ShipIconSpec = OTHER_ICON_SPECS['000027-moving']; +const FALLBACK_STOPPED: ShipIconSpec = OTHER_ICON_SPECS['000027-stopped']; + +// ── 대상 선박 아이콘 스펙 사전 생성 (7 legacyCode × 2상태) ── + +const LEGACY_CODES = ['PT', 'PT-S', 'GN', 'OT', 'PS', 'FC', 'C21'] as const; + +const TARGET_ICON_SPECS: Record = {}; + +for (const code of LEGACY_CODES) { + const rgb: Rgb = LEGACY_CODE_COLORS_RGB[code] || [100, 116, 139]; + const hex = rgbToHex(rgb); + + TARGET_ICON_SPECS[`${code}-moving`] = { + url: toDataUri(makeTargetMovingShipSvg(hex)), + width: 36, height: 52, anchorX: 18, anchorY: 26, + }; + TARGET_ICON_SPECS[`${code}-stopped`] = { + url: toDataUri(makeTargetStoppedShipSvg(hex)), + width: 20, height: 20, anchorX: 10, anchorY: 10, + }; +} + +// fallback (FC 색상) +const TARGET_FALLBACK_MOVING: ShipIconSpec = TARGET_ICON_SPECS['FC-moving']; +const TARGET_FALLBACK_STOPPED: ShipIconSpec = TARGET_ICON_SPECS['FC-stopped']; + +// ── SOG 기준 이동/정지 판단 (kn) ── + +export const SPEED_THRESHOLD_KN = 1; + +// ── 조회 함수 ── + +/** 기타 AIS 아이콘 스펙 조회 */ +export function getShipIconSpec( + signalKindCode: string | undefined | null, + sog: number | undefined | null, +): ShipIconSpec { + const code = signalKindCode || '000027'; + if (code === '000028') return OTHER_ICON_SPECS['000028-buoy'] || FALLBACK_STOPPED; + + const isMoving = Number.isFinite(sog) && (sog as number) > SPEED_THRESHOLD_KN; + const key = `${code}-${isMoving ? 'moving' : 'stopped'}`; + return OTHER_ICON_SPECS[key] || (isMoving ? FALLBACK_MOVING : FALLBACK_STOPPED); +} + +/** 대상 선박 아이콘 스펙 조회 */ +export function getTargetShipIconSpec( + legacyShipCode: string | undefined | null, + sog: number | undefined | null, +): ShipIconSpec { + const code = legacyShipCode || 'FC'; + const isMoving = Number.isFinite(sog) && (sog as number) > SPEED_THRESHOLD_KN; + const key = `${code}-${isMoving ? 'moving' : 'stopped'}`; + return TARGET_ICON_SPECS[key] || (isMoving ? TARGET_FALLBACK_MOVING : TARGET_FALLBACK_STOPPED); +} + +/** 선박 아이콘 회전각 (부이는 0, 나머지는 -cog) */ +export function getShipIconAngle( + signalKindCode: string | undefined | null, + cog: number | undefined | null, +): number { + const code = signalKindCode || '000027'; + if (code === '000028') return 0; + return -(Number.isFinite(cog) ? (cog as number) : 0); +} diff --git a/apps/web/src/widgets/legend/MapLegend.tsx b/apps/web/src/widgets/legend/MapLegend.tsx index 3ae458f..e64d9de 100644 --- a/apps/web/src/widgets/legend/MapLegend.tsx +++ b/apps/web/src/widgets/legend/MapLegend.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta"; -import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette"; +import { LEGACY_CODE_COLORS_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette"; +import { SHIP_KIND_ORDER, SHIP_KIND_LABELS, SHIP_KIND_COLORS } from "../../shared/lib/map/shipKind"; export function MapLegend() { const [isOpen, setIsOpen] = useState(true); @@ -25,23 +26,13 @@ export function MapLegend() { ))} -
기타 AIS 선박(속도)
-
-
- SOG ≥ 10 kt -
-
-
- 1 ≤ SOG < 10 kt -
-
-
- SOG < 1 kt -
-
-
- SOG unknown -
+
기타 AIS 선박(선종)
+ {SHIP_KIND_ORDER.filter((c) => c !== '000028').map((code) => ( +
+
+ {SHIP_KIND_LABELS[code]} +
+ ))}
CN Permit(업종)
diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 1aeb1c8..4d889a1 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -624,7 +624,7 @@ export function Map3D({ useDeckLayers( mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef, { - projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData, + projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipData, legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges, pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive, overlays, shipByMmsi, selectedMmsi, shipHighlightSet, diff --git a/apps/web/src/widgets/map3d/constants.ts b/apps/web/src/widgets/map3d/constants.ts index 583ffbb..8a23d64 100644 --- a/apps/web/src/widgets/map3d/constants.ts +++ b/apps/web/src/widgets/map3d/constants.ts @@ -14,10 +14,7 @@ const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer; const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange; const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious; -// ── Ship icon mapping (Deck.gl IconLayer) ── -// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage) - -export { SHIP_ICON_MAPPING } from '../../shared/lib/map/mapConstants'; +// Ship icon mapping removed — now using shipKind.ts SVG-based icons // ── Ship constants ── @@ -47,14 +44,20 @@ export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.55)'; // ── Flat map icon sizes ── -export const FLAT_SHIP_ICON_SIZE = 19; -export const FLAT_SHIP_ICON_SIZE_SELECTED = 28; -export const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25; +export const FLAT_OTHER_SHIP_SIZE = 20; +export const FLAT_TARGET_SHIP_SIZE = 26; export const FLAT_LEGACY_HALO_RADIUS = 14; export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18; -export const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16; export const EMPTY_MMSI_SET = new Set(); +// ── 대상 선박 브리딩 애니메이션 ── + +export const HALO_BREATHE_PERIOD_MS = 2000; +export const HALO_BREATHE_SELECTED_R_MIN = 16; +export const HALO_BREATHE_SELECTED_R_MAX = 22; +export const HALO_BREATHE_HIGHLIGHTED_R_MIN = 14; +export const HALO_BREATHE_HIGHLIGHTED_R_MAX = 19; + // ── Deck.gl view ID ── export const DECK_VIEW_ID = 'mapbox'; diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index f8b1aca..4fe520d 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -19,6 +19,13 @@ import { } from '../lib/tooltips'; import { sanitizeDeckLayerList } from '../lib/mapCore'; import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories'; +import { + HALO_BREATHE_PERIOD_MS, + HALO_BREATHE_SELECTED_R_MIN, + HALO_BREATHE_SELECTED_R_MAX, + HALO_BREATHE_HIGHLIGHTED_R_MIN, + HALO_BREATHE_HIGHLIGHTED_R_MAX, +} from '../constants'; // NOTE: // Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips). // Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts. @@ -35,7 +42,6 @@ export function useDeckLayers( settings: Map3DSettings; trackReplayDeckLayers: unknown[]; shipLayerData: AisTarget[]; - shipOverlayLayerData: AisTarget[]; shipData: AisTarget[]; legacyHits: Map | null | undefined; pairLinks: PairLink[] | undefined; @@ -72,7 +78,7 @@ export function useDeckLayers( }, ) { const { - projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData, + projection, settings, trackReplayDeckLayers, shipLayerData, shipData, legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges, pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive, overlays, shipByMmsi, selectedMmsi, shipHighlightSet, @@ -98,9 +104,12 @@ export function useDeckLayers( }, [legacyTargets]); const legacyOverlayTargets = useMemo(() => { - if (shipHighlightSet.size === 0) return []; - return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi)); - }, [legacyTargets, shipHighlightSet]); + if (shipHighlightSet.size === 0 && selectedMmsi == null) return []; + return legacyTargets.filter((target) => + shipHighlightSet.has(target.mmsi) || + (selectedMmsi != null && target.mmsi === selectedMmsi), + ); + }, [legacyTargets, shipHighlightSet, selectedMmsi]); const alarmTargets = useMemo(() => { if (!alarmMmsiMap || alarmMmsiMap.size === 0) return []; @@ -134,7 +143,6 @@ export function useDeckLayers( const layers = buildMercatorDeckLayers({ shipLayerData, - shipOverlayLayerData, legacyTargetsOrdered, legacyOverlayTargets, legacyHits, @@ -246,7 +254,6 @@ export function useDeckLayers( legacyTargetsOrdered, legacyHits, legacyOverlayTargets, - shipOverlayLayerData, pairRangesInteractive, pairLinksInteractive, fcLinesInteractive, @@ -275,9 +282,12 @@ export function useDeckLayers( onClickShipPhoto, ]); - // Mercator alarm pulse breathing animation (rAF) + // Mercator 브리딩 애니메이션 (rAF) — 알람 맥동 + 대상 선박 브리딩 합류 + const hasAlarms = alarmMmsiMap && alarmMmsiMap.size > 0 && alarmTargets.length > 0; + const hasTargetOverlays = legacyOverlayTargets.length > 0; + useEffect(() => { - if (projection !== 'mercator' || !alarmMmsiMap || alarmMmsiMap.size === 0 || alarmTargets.length === 0) { + if (projection !== 'mercator' || (!hasAlarms && !hasTargetOverlays)) { if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current); alarmRafRef.current = 0; return; @@ -295,34 +305,70 @@ export function useDeckLayers( return; } - const t = (Math.sin(Date.now() / 1500 * Math.PI * 2) + 1) / 2; - const normalR = 8 + t * 6; - const hoverR = 12 + t * 6; + const now = Date.now(); + let updated = mercatorLayersRef.current; - const pulseLyr = new ScatterplotLayer({ - id: 'alarm-pulse', - data: alarmTargets, - pickable: false, - billboard: false, - filled: true, - stroked: false, - radiusUnits: 'pixels', - getRadius: (d) => { - const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi); - return isHover ? hoverR : normalR; - }, - getFillColor: (d) => { - const kind = alarmMmsiMap.get(d.mmsi); - return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number]; - }, - getPosition: (d) => [d.lon, d.lat] as [number, number], - updateTriggers: { getRadius: [normalR, hoverR] }, - }); + // 1. 알람 맥동 (기존) + if (hasAlarms) { + const tA = (Math.sin(now / 1500 * Math.PI * 2) + 1) / 2; + const normalR = 8 + tA * 6; + const hoverR = 12 + tA * 6; - const updated = mercatorLayersRef.current.map((l) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (l as any)?.id === 'alarm-pulse' ? pulseLyr : l, - ); + const pulseLyr = new ScatterplotLayer({ + id: 'alarm-pulse', + data: alarmTargets, + pickable: false, + billboard: false, + filled: true, + stroked: false, + radiusUnits: 'pixels', + getRadius: (d) => { + const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi); + return isHover ? hoverR : normalR; + }, + getFillColor: (d) => { + const kind = alarmMmsiMap!.get(d.mmsi); + return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + updateTriggers: { getRadius: [normalR, hoverR] }, + }); + updated = updated.map((l) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (l as any)?.id === 'alarm-pulse' ? pulseLyr : l, + ); + } + + // 2. 대상 선박 브리딩 링 + if (hasTargetOverlays) { + const tH = (Math.sin(now / HALO_BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2; + const selR = HALO_BREATHE_SELECTED_R_MIN + tH * (HALO_BREATHE_SELECTED_R_MAX - HALO_BREATHE_SELECTED_R_MIN); + const hlR = HALO_BREATHE_HIGHLIGHTED_R_MIN + tH * (HALO_BREATHE_HIGHLIGHTED_R_MAX - HALO_BREATHE_HIGHLIGHTED_R_MIN); + const alpha = Math.round(155 + tH * 100); + + const haloLyr = new ScatterplotLayer({ + id: 'legacy-halo-overlay', + data: legacyOverlayTargets, + pickable: false, + billboard: false, + filled: false, + stroked: true, + radiusUnits: 'pixels', + getRadius: (d) => (selectedMmsi != null && d.mmsi === selectedMmsi ? selR : hlR), + lineWidthUnits: 'pixels', + getLineWidth: 2.5, + getLineColor: (d) => { + if (selectedMmsi != null && d.mmsi === selectedMmsi) return [14, 234, 255, alpha] as [number, number, number, number]; + return [245, 158, 11, alpha] as [number, number, number, number]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + updateTriggers: { getRadius: [selR, hlR], getLineColor: [alpha, selectedMmsi] }, + }); + updated = updated.map((l) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (l as any)?.id === 'legacy-halo-overlay' ? haloLyr : l, + ); + } try { currentOverlay.setProps({ layers: updated } as never); @@ -336,7 +382,7 @@ export function useDeckLayers( if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current); alarmRafRef.current = 0; }; - }, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet]); + }, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet, hasAlarms, hasTargetOverlays, legacyOverlayTargets]); // Globe Deck overlay useEffect(() => { diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts index 52f629e..4c3b0a3 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts @@ -123,7 +123,7 @@ export function useGlobeShipHover( sog: isFiniteNumber(t.sog) ? t.sog : 0, shipColor: getGlobeBaseShipColor({ legacy: legacy?.shipCode || null, - sog: isFiniteNumber(t.sog) ? t.sog : null, + signalKindCode: t.signalKindCode || t.shipKindCode || '000027', }), iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45), iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7), diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts index eaaf29e..06fda54 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts @@ -84,9 +84,9 @@ export function useGlobeShipLayers( (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); - // 상호작용 스케일링 제거 — selected/highlighted는 feature-state로 처리 - // hover overlay 레이어가 확대 + z-priority를 담당 + const baseSizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); + // 대상 선박은 1.3x 배율 적용 + const sizeScale = legacy ? baseSizeScale * 1.3 : baseSizeScale; 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); @@ -106,7 +106,7 @@ export function useGlobeShipLayers( isAnchored: isAnchored ? 1 : 0, shipColor: getGlobeBaseShipColor({ legacy: legacy?.shipCode || null, - sog: isFiniteNumber(t.sog) ? t.sog : null, + signalKindCode: t.signalKindCode || t.shipKindCode || '000027', }), iconSize3, iconSize7, diff --git a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts index 7703556..ed11c1c 100644 --- a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts +++ b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts @@ -8,19 +8,14 @@ import type { FleetCircle, PairLink } from '../../../features/legacyDashboard/mo import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { DashSeg, PairRangeCircle } from '../types'; import { - SHIP_ICON_MAPPING, - FLAT_SHIP_ICON_SIZE, - FLAT_SHIP_ICON_SIZE_SELECTED, - FLAT_SHIP_ICON_SIZE_HIGHLIGHTED, + FLAT_OTHER_SHIP_SIZE, + FLAT_TARGET_SHIP_SIZE, FLAT_LEGACY_HALO_RADIUS, FLAT_LEGACY_HALO_RADIUS_SELECTED, - FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED, - EMPTY_MMSI_SET, DEPTH_DISABLED_PARAMS, GLOBE_OVERLAY_PARAMS, HALO_OUTLINE_COLOR, HALO_OUTLINE_COLOR_SELECTED, - HALO_OUTLINE_COLOR_HIGHLIGHTED, PAIR_RANGE_NORMAL_DECK, PAIR_RANGE_WARN_DECK, PAIR_LINE_NORMAL_DECK, @@ -38,8 +33,13 @@ import { FLEET_RANGE_LINE_DECK_HL, FLEET_RANGE_FILL_DECK_HL, } from '../constants'; -import { getDisplayHeading, getShipColor } from './shipUtils'; -import { getCachedShipIcon } from './shipIconCache'; +import { getDisplayHeading } from './shipUtils'; +import { + getShipIconSpec, + getTargetShipIconSpec, + getShipIconAngle, + SPEED_THRESHOLD_KN, +} from '../../../shared/lib/map/shipKind'; /* ── 공통 콜백 인터페이스 ─────────────────────────────── */ @@ -64,7 +64,6 @@ interface DeckSelectCallbacks { export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks { shipLayerData: AisTarget[]; - shipOverlayLayerData: AisTarget[]; legacyTargetsOrdered: AisTarget[]; legacyOverlayTargets: AisTarget[]; legacyHits: Map | null | undefined; @@ -101,10 +100,6 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ if (isTargetShip(t.mmsi)) shipTargetData.push(t); else shipOtherData.push(t); } - const shipOverlayOtherData: AisTarget[] = []; - for (const t of ctx.shipOverlayLayerData) { - if (!isTargetShip(t.mmsi)) shipOverlayOtherData.push(t); - } /* ─ density ─ */ if (ctx.showDensity) { @@ -318,26 +313,6 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ }; if (shipOtherData.length > 0) { - layers.push( - new ScatterplotLayer({ - id: 'ships-other-halo', - data: shipOtherData, - pickable: false, - billboard: false, - parameters: overlayParams, - getPosition: (d) => [d.lon, d.lat] as [number, number], - radiusUnits: 'pixels', - getRadius: 10, - getFillColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET).slice(0, 3).concat(40) as unknown as [number, number, number, number], - getLineColor: (d) => { - const c = getShipColor(d, null, null, EMPTY_MMSI_SET); - return [c[0], c[1], c[2], 100] as [number, number, number, number]; - }, - stroked: true, - lineWidthUnits: 'pixels', - getLineWidth: 1, - }), - ); layers.push( new IconLayer({ id: 'ships-other', @@ -345,14 +320,11 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ pickable: true, billboard: false, parameters: overlayParams, - iconAtlas: getCachedShipIcon(), - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => 'ship', + getIcon: (d) => getShipIconSpec(d.signalKindCode ?? d.shipKindCode, d.sog), getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), + getAngle: (d) => getShipIconAngle(d.signalKindCode ?? d.shipKindCode, d.cog), sizeUnits: 'pixels', - getSize: () => FLAT_SHIP_ICON_SIZE, - getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET), + getSize: FLAT_OTHER_SHIP_SIZE, onHover: shipOnHover, onClick: shipOnClick, alphaCutoff: 0.05, @@ -360,31 +332,6 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ ); } - if (shipOverlayOtherData.length > 0) { - layers.push( - new IconLayer({ - id: 'ships-overlay-other', - data: shipOverlayOtherData, - pickable: false, - billboard: false, - parameters: overlayParams, - iconAtlas: getCachedShipIcon(), - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => 'ship', - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), - sizeUnits: 'pixels', - getSize: (d) => { - if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return 0; - }, - getColor: (d) => getShipColor(d, ctx.selectedMmsi, null, ctx.shipHighlightSet), - alphaCutoff: 0.05, - }), - ); - } - if (ctx.legacyTargetsOrdered.length > 0) { layers.push( new ScatterplotLayer({ @@ -413,14 +360,14 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ pickable: true, billboard: false, parameters: overlayParams, - iconAtlas: getCachedShipIcon(), - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => 'ship', + getIcon: (d) => getTargetShipIconSpec(ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, d.sog), getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), + getAngle: (d) => { + const isMoving = Number.isFinite(d.sog) && (d.sog as number) > SPEED_THRESHOLD_KN; + return isMoving ? -getDisplayHeading({ cog: d.cog, heading: d.heading }) : 0; + }, sizeUnits: 'pixels', - getSize: () => FLAT_SHIP_ICON_SIZE, - getColor: (d) => getShipColor(d, null, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET), + getSize: FLAT_TARGET_SHIP_SIZE, onHover: shipOnHover, onClick: shipOnClick, alphaCutoff: 0.05, @@ -444,14 +391,26 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 3.0, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center })); } - /* ─ legacy overlay (highlight/selected) ─ */ + /* ─ legacy overlay (highlight/selected — breathing ring, no icon enlargement) ─ */ if (ctx.showShips && ctx.legacyOverlayTargets.length > 0) { - layers.push(new ScatterplotLayer({ id: 'legacy-halo-overlay', data: ctx.legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); - } - - if (ctx.showShips && ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)).length > 0) { - const shipOverlayTargetData2 = ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)); - layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!ctx.shipHighlightSet.has(d.mmsi) && !(ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, ctx.selectedMmsi, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, ctx.shipHighlightSet); } })); + layers.push(new ScatterplotLayer({ + id: 'legacy-halo-overlay', + data: ctx.legacyOverlayTargets, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: 'pixels', + getRadius: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 18 : 16), + lineWidthUnits: 'pixels', + getLineWidth: 2.5, + getLineColor: (d) => { + if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 200]; + return [245, 158, 11, 190]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + })); } /* ─ ship name labels (Mercator) ─ */ diff --git a/apps/web/src/widgets/map3d/lib/shipIconCache.ts b/apps/web/src/widgets/map3d/lib/shipIconCache.ts deleted file mode 100644 index b7bdd8e..0000000 --- a/apps/web/src/widgets/map3d/lib/shipIconCache.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Ship SVG 아이콘을 미리 fetch하여 data URL로 캐시. - * Deck.gl IconLayer가 매번 iconAtlas URL을 fetch하지 않도록 - * 인라인 data URL을 전달한다. - */ -const SHIP_SVG_URL = '/assets/ship.svg'; - -let _cachedDataUrl: string | null = null; -let _promise: Promise | null = null; - -function preloadShipIcon(): Promise { - if (_cachedDataUrl) return Promise.resolve(_cachedDataUrl); - if (_promise) return _promise; - _promise = fetch(SHIP_SVG_URL) - .then((res) => res.text()) - .then((svg) => { - _cachedDataUrl = `data:image/svg+xml;base64,${btoa(svg)}`; - return _cachedDataUrl; - }) - .catch(() => SHIP_SVG_URL); - return _promise; -} - -/** 캐시된 data URL 또는 폴백 URL 반환 */ -export function getCachedShipIcon(): string { - return _cachedDataUrl ?? SHIP_SVG_URL; -} - -// 모듈 임포트 시 즉시 로드 시작 -preloadShipIcon(); diff --git a/apps/web/src/widgets/map3d/lib/shipUtils.ts b/apps/web/src/widgets/map3d/lib/shipUtils.ts index eaab8bb..d18b4a1 100644 --- a/apps/web/src/widgets/map3d/lib/shipUtils.ts +++ b/apps/web/src/widgets/map3d/lib/shipUtils.ts @@ -1,12 +1,10 @@ import type { AisTarget } from '../../../entities/aisTarget/model/types'; import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; import { rgbToHex } from '../../../shared/lib/map/palette'; +import { SHIP_KIND_COLORS } from '../../../shared/lib/map/shipKind'; import { ANCHOR_SPEED_THRESHOLD_KN, LEGACY_CODE_COLORS, - MAP_SELECTED_SHIP_RGB, - MAP_HIGHLIGHT_SHIP_RGB, - MAP_DEFAULT_SHIP_RGB, } from '../constants'; import { isFiniteNumber } from './setUtils'; import { normalizeAngleDeg } from './geometry'; @@ -53,44 +51,21 @@ export function lightenColor(rgb: [number, number, number], ratio = 0.32) { export function getGlobeBaseShipColor({ legacy, - sog, + signalKindCode, }: { legacy: string | null; - sog: number | null; + signalKindCode?: string; }) { + // 대상 선박: legacy code 색상 (밝게) if (legacy) { const rgb = LEGACY_CODE_COLORS[legacy]; if (rgb) return rgbToHex(lightenColor(rgb, 0.38)); } - // Keep alpha control in icon-opacity only to avoid double-multiplying transparency. - if (!isFiniteNumber(sog)) return '#64748b'; - if (sog >= 10) return '#94a3b8'; - if (sog >= 1) return '#64748b'; - return '#475569'; -} - -export function getShipColor( - t: AisTarget, - selectedMmsi: number | null, - legacyShipCode: string | null, - highlightedMmsis: Set, -): [number, number, number, number] { - if (selectedMmsi && t.mmsi === selectedMmsi) { - return [MAP_SELECTED_SHIP_RGB[0], MAP_SELECTED_SHIP_RGB[1], MAP_SELECTED_SHIP_RGB[2], 255]; - } - if (highlightedMmsis.has(t.mmsi)) { - return [MAP_HIGHLIGHT_SHIP_RGB[0], MAP_HIGHLIGHT_SHIP_RGB[1], MAP_HIGHLIGHT_SHIP_RGB[2], 235]; - } - if (legacyShipCode) { - const rgb = LEGACY_CODE_COLORS[legacyShipCode]; - if (rgb) return [rgb[0], rgb[1], rgb[2], 235]; - return [245, 158, 11, 235]; - } - if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175]; - if (t.sog >= 10) return [148, 163, 184, 215]; - if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 210]; - return [71, 85, 105, 200]; + // 기타 AIS: signalKindCode → 선종별 색상 + const kindColor = SHIP_KIND_COLORS[signalKindCode || '000027']; + if (kindColor) return kindColor; + return '#607D8B'; } export function buildGlobeShipFeature( @@ -108,7 +83,10 @@ export function buildGlobeShipFeature( mmsi: t.mmsi, heading: getDisplayHeading({ cog: t.cog, heading: t.heading, offset }), anchored, - color: getGlobeBaseShipColor({ legacy: legacy?.shipCode ?? null, sog: t.sog ?? null }), + color: getGlobeBaseShipColor({ + legacy: legacy?.shipCode ?? null, + signalKindCode: t.signalKindCode || t.shipKindCode || '000027', + }), selected: isSelected, highlighted: isHighlighted, permitted: legacy ? 1 : 0,