release: 2026-03-08 (5건 커밋) #52

병합
htlee develop 에서 main 로 5 commits 를 머지했습니다 2026-03-08 13:03:40 +09:00
11개의 변경된 파일344개의 추가작업 그리고 224개의 파일을 삭제
Showing only changes of commit 81fb4a2bca - Show all commits

파일 보기

@ -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',

파일 보기

@ -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<string, string> = {
'000020': '어선',
'000021': '경비함정',
'000022': '여객선',
'000023': '화물선',
'000024': '유조선',
'000025': '관공선',
'000027': '일반',
'000028': '부이',
};
/** 선종별 범례/UI 색상 (hex) */
export const SHIP_KIND_COLORS: Record<string, string> = {
'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 xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48"><path d="M16 2 L8 13 L4 28 L7 45 L25 45 L28 28 L24 13 Z" fill="${fill}" stroke="${STROKE}" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
}
/** 정지 선박 SVG (원형, 16×16) */
export function makeStoppedShipSvg(fill: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.5" fill="${fill}" stroke="${STROKE}" stroke-width="1.2"/></svg>`;
}
/** 부이 SVG (다색, 32×44) */
export function makeBuoySvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="44" viewBox="0 0 32 44"><line x1="16" y1="10" x2="16" y2="38" stroke="#5D4037" stroke-width="2.5" stroke-linecap="round"/><line x1="10" y1="38" x2="22" y2="38" stroke="#5D4037" stroke-width="2" stroke-linecap="round"/><ellipse cx="16" cy="24" rx="9" ry="7" fill="#E53935" stroke="#333" stroke-width="1"/><rect x="8" y="22" width="16" height="4" rx="1" fill="#FDD835" opacity="0.85"/><rect x="14.5" y="8" width="3" height="10" fill="#666"/><circle cx="16" cy="7" r="3.5" fill="#FFC107" stroke="#444" stroke-width="0.8"/></svg>`;
}
/** 대상 선박 이동 SVG (36×52, 흰색 반투명 테두리) */
export function makeTargetMovingShipSvg(fill: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="52" viewBox="0 0 36 52"><path d="M18 2 L9 14 L5 30 L8 49 L28 49 L31 30 L27 14 Z" fill="${fill}" stroke="${TARGET_STROKE}" stroke-width="2" stroke-linejoin="round"/></svg>`;
}
/** 대상 선박 정지 SVG (20×20, 흰색 반투명 테두리) */
export function makeTargetStoppedShipSvg(fill: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="${fill}" stroke="${TARGET_STROKE}" stroke-width="1.5"/></svg>`;
}
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<string, ShipIconSpec> = {};
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<string, ShipIconSpec> = {};
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);
}

파일 보기

@ -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() {
</div>
))}
<div className="lt" style={{ marginTop: 8 }}> AIS ()</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
SOG 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
1 SOG &lt; 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
SOG &lt; 1 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
SOG unknown
</div>
<div className="lt" style={{ marginTop: 8 }}> AIS ()</div>
{SHIP_KIND_ORDER.filter((c) => c !== '000028').map((code) => (
<div key={code} className="li">
<div className="ls" style={{ background: SHIP_KIND_COLORS[code], borderRadius: 999 }} />
{SHIP_KIND_LABELS[code]}
</div>
))}
<div className="lt" style={{ marginTop: 8 }}>CN Permit()</div>
<div className="li">

파일 보기

@ -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,

파일 보기

@ -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<number>();
// ── 대상 선박 브리딩 애니메이션 ──
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';

파일 보기

@ -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<number, LegacyVesselInfo> | 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<AisTarget>({
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<AisTarget>({
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<AisTarget>({
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(() => {

파일 보기

@ -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),

파일 보기

@ -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,

파일 보기

@ -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<number, LegacyVesselInfo> | 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<AisTarget>({
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<AisTarget>({
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<AisTarget>({
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<AisTarget>({
@ -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<FleetCircle>({ 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<AisTarget>({ 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<AisTarget>({ 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<AisTarget>({
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) ─ */

파일 보기

@ -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<string> | null = null;
function preloadShipIcon(): Promise<string> {
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();

파일 보기

@ -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, 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,