feat(shipIcon): 선종별 SVG 아이콘 시스템 도입 + 대상 선박 브리딩 링
gc-wing-simple의 SVG 기반 선종별 아이콘 시스템을 도입하여 기타 AIS 선박을 8종 선종별 색상+형태(이동:화살표/정지:원형)로 구분하고, 대상 선박에는 legacy code 색상 + 브리딩 링 강조 효과를 적용한다. - shipKind.ts: 선종별 SVG 생성기 + 아이콘 스펙 사전 생성 - Mercator: 기타 AIS 20px SVG IconLayer, 대상 선박 26px SVG IconLayer - Globe: signalKindCode 기반 색상, 대상 선박 1.3x 크기 - 브리딩 rAF: 시안(선택)/주황(강조) 링, 2000ms 주기 - 범례: "기타 AIS(선종)" 7항목으로 변경 - shipIconCache.ts, SHIP_ICON_MAPPING 삭제 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
5c5af7e856
커밋
81fb4a2bca
@ -1,18 +1,4 @@
|
|||||||
// ── Shared map constants ──
|
// ── 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 = {
|
export const DEPTH_DISABLED_PARAMS = {
|
||||||
depthCompare: 'always',
|
depthCompare: 'always',
|
||||||
|
|||||||
187
apps/web/src/shared/lib/map/shipKind.ts
Normal file
187
apps/web/src/shared/lib/map/shipKind.ts
Normal file
@ -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 { useState } from 'react';
|
||||||
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
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() {
|
export function MapLegend() {
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
@ -25,23 +26,13 @@ export function MapLegend() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>기타 AIS 선박(속도)</div>
|
<div className="lt" style={{ marginTop: 8 }}>기타 AIS 선박(선종)</div>
|
||||||
<div className="li">
|
{SHIP_KIND_ORDER.filter((c) => c !== '000028').map((code) => (
|
||||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
|
<div key={code} className="li">
|
||||||
SOG ≥ 10 kt
|
<div className="ls" style={{ background: SHIP_KIND_COLORS[code], borderRadius: 999 }} />
|
||||||
</div>
|
{SHIP_KIND_LABELS[code]}
|
||||||
<div className="li">
|
</div>
|
||||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
|
))}
|
||||||
1 ≤ SOG < 10 kt
|
|
||||||
</div>
|
|
||||||
<div className="li">
|
|
||||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
|
|
||||||
SOG < 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 }}>CN Permit(업종)</div>
|
<div className="lt" style={{ marginTop: 8 }}>CN Permit(업종)</div>
|
||||||
<div className="li">
|
<div className="li">
|
||||||
|
|||||||
@ -624,7 +624,7 @@ export function Map3D({
|
|||||||
useDeckLayers(
|
useDeckLayers(
|
||||||
mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef,
|
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,
|
legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges,
|
||||||
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
||||||
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
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_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
|
||||||
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
|
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
|
||||||
|
|
||||||
// ── Ship icon mapping (Deck.gl IconLayer) ──
|
// Ship icon mapping removed — now using shipKind.ts SVG-based icons
|
||||||
// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage)
|
|
||||||
|
|
||||||
export { SHIP_ICON_MAPPING } from '../../shared/lib/map/mapConstants';
|
|
||||||
|
|
||||||
// ── Ship constants ──
|
// ── Ship constants ──
|
||||||
|
|
||||||
@ -47,14 +44,20 @@ export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.55)';
|
|||||||
|
|
||||||
// ── Flat map icon sizes ──
|
// ── Flat map icon sizes ──
|
||||||
|
|
||||||
export const FLAT_SHIP_ICON_SIZE = 19;
|
export const FLAT_OTHER_SHIP_SIZE = 20;
|
||||||
export const FLAT_SHIP_ICON_SIZE_SELECTED = 28;
|
export const FLAT_TARGET_SHIP_SIZE = 26;
|
||||||
export const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25;
|
|
||||||
export const FLAT_LEGACY_HALO_RADIUS = 14;
|
export const FLAT_LEGACY_HALO_RADIUS = 14;
|
||||||
export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18;
|
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 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 ──
|
// ── Deck.gl view ID ──
|
||||||
|
|
||||||
export const DECK_VIEW_ID = 'mapbox';
|
export const DECK_VIEW_ID = 'mapbox';
|
||||||
|
|||||||
@ -19,6 +19,13 @@ import {
|
|||||||
} from '../lib/tooltips';
|
} from '../lib/tooltips';
|
||||||
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
||||||
import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories';
|
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:
|
// NOTE:
|
||||||
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
||||||
// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts.
|
// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts.
|
||||||
@ -35,7 +42,6 @@ export function useDeckLayers(
|
|||||||
settings: Map3DSettings;
|
settings: Map3DSettings;
|
||||||
trackReplayDeckLayers: unknown[];
|
trackReplayDeckLayers: unknown[];
|
||||||
shipLayerData: AisTarget[];
|
shipLayerData: AisTarget[];
|
||||||
shipOverlayLayerData: AisTarget[];
|
|
||||||
shipData: AisTarget[];
|
shipData: AisTarget[];
|
||||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
pairLinks: PairLink[] | undefined;
|
pairLinks: PairLink[] | undefined;
|
||||||
@ -72,7 +78,7 @@ export function useDeckLayers(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
|
projection, settings, trackReplayDeckLayers, shipLayerData, shipData,
|
||||||
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
|
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
|
||||||
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
||||||
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
||||||
@ -98,9 +104,12 @@ export function useDeckLayers(
|
|||||||
}, [legacyTargets]);
|
}, [legacyTargets]);
|
||||||
|
|
||||||
const legacyOverlayTargets = useMemo(() => {
|
const legacyOverlayTargets = useMemo(() => {
|
||||||
if (shipHighlightSet.size === 0) return [];
|
if (shipHighlightSet.size === 0 && selectedMmsi == null) return [];
|
||||||
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
|
return legacyTargets.filter((target) =>
|
||||||
}, [legacyTargets, shipHighlightSet]);
|
shipHighlightSet.has(target.mmsi) ||
|
||||||
|
(selectedMmsi != null && target.mmsi === selectedMmsi),
|
||||||
|
);
|
||||||
|
}, [legacyTargets, shipHighlightSet, selectedMmsi]);
|
||||||
|
|
||||||
const alarmTargets = useMemo(() => {
|
const alarmTargets = useMemo(() => {
|
||||||
if (!alarmMmsiMap || alarmMmsiMap.size === 0) return [];
|
if (!alarmMmsiMap || alarmMmsiMap.size === 0) return [];
|
||||||
@ -134,7 +143,6 @@ export function useDeckLayers(
|
|||||||
|
|
||||||
const layers = buildMercatorDeckLayers({
|
const layers = buildMercatorDeckLayers({
|
||||||
shipLayerData,
|
shipLayerData,
|
||||||
shipOverlayLayerData,
|
|
||||||
legacyTargetsOrdered,
|
legacyTargetsOrdered,
|
||||||
legacyOverlayTargets,
|
legacyOverlayTargets,
|
||||||
legacyHits,
|
legacyHits,
|
||||||
@ -246,7 +254,6 @@ export function useDeckLayers(
|
|||||||
legacyTargetsOrdered,
|
legacyTargetsOrdered,
|
||||||
legacyHits,
|
legacyHits,
|
||||||
legacyOverlayTargets,
|
legacyOverlayTargets,
|
||||||
shipOverlayLayerData,
|
|
||||||
pairRangesInteractive,
|
pairRangesInteractive,
|
||||||
pairLinksInteractive,
|
pairLinksInteractive,
|
||||||
fcLinesInteractive,
|
fcLinesInteractive,
|
||||||
@ -275,9 +282,12 @@ export function useDeckLayers(
|
|||||||
onClickShipPhoto,
|
onClickShipPhoto,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Mercator alarm pulse breathing animation (rAF)
|
// Mercator 브리딩 애니메이션 (rAF) — 알람 맥동 + 대상 선박 브리딩 합류
|
||||||
|
const hasAlarms = alarmMmsiMap && alarmMmsiMap.size > 0 && alarmTargets.length > 0;
|
||||||
|
const hasTargetOverlays = legacyOverlayTargets.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projection !== 'mercator' || !alarmMmsiMap || alarmMmsiMap.size === 0 || alarmTargets.length === 0) {
|
if (projection !== 'mercator' || (!hasAlarms && !hasTargetOverlays)) {
|
||||||
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
||||||
alarmRafRef.current = 0;
|
alarmRafRef.current = 0;
|
||||||
return;
|
return;
|
||||||
@ -295,34 +305,70 @@ export function useDeckLayers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = (Math.sin(Date.now() / 1500 * Math.PI * 2) + 1) / 2;
|
const now = Date.now();
|
||||||
const normalR = 8 + t * 6;
|
let updated = mercatorLayersRef.current;
|
||||||
const hoverR = 12 + t * 6;
|
|
||||||
|
|
||||||
const pulseLyr = new ScatterplotLayer<AisTarget>({
|
// 1. 알람 맥동 (기존)
|
||||||
id: 'alarm-pulse',
|
if (hasAlarms) {
|
||||||
data: alarmTargets,
|
const tA = (Math.sin(now / 1500 * Math.PI * 2) + 1) / 2;
|
||||||
pickable: false,
|
const normalR = 8 + tA * 6;
|
||||||
billboard: false,
|
const hoverR = 12 + tA * 6;
|
||||||
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] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const updated = mercatorLayersRef.current.map((l) =>
|
const pulseLyr = new ScatterplotLayer<AisTarget>({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
id: 'alarm-pulse',
|
||||||
(l as any)?.id === 'alarm-pulse' ? pulseLyr : l,
|
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 {
|
try {
|
||||||
currentOverlay.setProps({ layers: updated } as never);
|
currentOverlay.setProps({ layers: updated } as never);
|
||||||
@ -336,7 +382,7 @@ export function useDeckLayers(
|
|||||||
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
||||||
alarmRafRef.current = 0;
|
alarmRafRef.current = 0;
|
||||||
};
|
};
|
||||||
}, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet]);
|
}, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet, hasAlarms, hasTargetOverlays, legacyOverlayTargets]);
|
||||||
|
|
||||||
// Globe Deck overlay
|
// Globe Deck overlay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -123,7 +123,7 @@ export function useGlobeShipHover(
|
|||||||
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
||||||
shipColor: getGlobeBaseShipColor({
|
shipColor: getGlobeBaseShipColor({
|
||||||
legacy: legacy?.shipCode || null,
|
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),
|
iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45),
|
||||||
iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7),
|
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,
|
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
|
||||||
50, 420,
|
50, 420,
|
||||||
);
|
);
|
||||||
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
const baseSizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
||||||
// 상호작용 스케일링 제거 — selected/highlighted는 feature-state로 처리
|
// 대상 선박은 1.3x 배율 적용
|
||||||
// hover overlay 레이어가 확대 + z-priority를 담당
|
const sizeScale = legacy ? baseSizeScale * 1.3 : baseSizeScale;
|
||||||
const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3);
|
const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3);
|
||||||
const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45);
|
const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45);
|
||||||
const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8);
|
const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8);
|
||||||
@ -106,7 +106,7 @@ export function useGlobeShipLayers(
|
|||||||
isAnchored: isAnchored ? 1 : 0,
|
isAnchored: isAnchored ? 1 : 0,
|
||||||
shipColor: getGlobeBaseShipColor({
|
shipColor: getGlobeBaseShipColor({
|
||||||
legacy: legacy?.shipCode || null,
|
legacy: legacy?.shipCode || null,
|
||||||
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
signalKindCode: t.signalKindCode || t.shipKindCode || '000027',
|
||||||
}),
|
}),
|
||||||
iconSize3,
|
iconSize3,
|
||||||
iconSize7,
|
iconSize7,
|
||||||
|
|||||||
@ -8,19 +8,14 @@ import type { FleetCircle, PairLink } from '../../../features/legacyDashboard/mo
|
|||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
import type { DashSeg, PairRangeCircle } from '../types';
|
import type { DashSeg, PairRangeCircle } from '../types';
|
||||||
import {
|
import {
|
||||||
SHIP_ICON_MAPPING,
|
FLAT_OTHER_SHIP_SIZE,
|
||||||
FLAT_SHIP_ICON_SIZE,
|
FLAT_TARGET_SHIP_SIZE,
|
||||||
FLAT_SHIP_ICON_SIZE_SELECTED,
|
|
||||||
FLAT_SHIP_ICON_SIZE_HIGHLIGHTED,
|
|
||||||
FLAT_LEGACY_HALO_RADIUS,
|
FLAT_LEGACY_HALO_RADIUS,
|
||||||
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
||||||
FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED,
|
|
||||||
EMPTY_MMSI_SET,
|
|
||||||
DEPTH_DISABLED_PARAMS,
|
DEPTH_DISABLED_PARAMS,
|
||||||
GLOBE_OVERLAY_PARAMS,
|
GLOBE_OVERLAY_PARAMS,
|
||||||
HALO_OUTLINE_COLOR,
|
HALO_OUTLINE_COLOR,
|
||||||
HALO_OUTLINE_COLOR_SELECTED,
|
HALO_OUTLINE_COLOR_SELECTED,
|
||||||
HALO_OUTLINE_COLOR_HIGHLIGHTED,
|
|
||||||
PAIR_RANGE_NORMAL_DECK,
|
PAIR_RANGE_NORMAL_DECK,
|
||||||
PAIR_RANGE_WARN_DECK,
|
PAIR_RANGE_WARN_DECK,
|
||||||
PAIR_LINE_NORMAL_DECK,
|
PAIR_LINE_NORMAL_DECK,
|
||||||
@ -38,8 +33,13 @@ import {
|
|||||||
FLEET_RANGE_LINE_DECK_HL,
|
FLEET_RANGE_LINE_DECK_HL,
|
||||||
FLEET_RANGE_FILL_DECK_HL,
|
FLEET_RANGE_FILL_DECK_HL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { getDisplayHeading, getShipColor } from './shipUtils';
|
import { getDisplayHeading } from './shipUtils';
|
||||||
import { getCachedShipIcon } from './shipIconCache';
|
import {
|
||||||
|
getShipIconSpec,
|
||||||
|
getTargetShipIconSpec,
|
||||||
|
getShipIconAngle,
|
||||||
|
SPEED_THRESHOLD_KN,
|
||||||
|
} from '../../../shared/lib/map/shipKind';
|
||||||
|
|
||||||
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */
|
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */
|
||||||
|
|
||||||
@ -64,7 +64,6 @@ interface DeckSelectCallbacks {
|
|||||||
|
|
||||||
export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks {
|
export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks {
|
||||||
shipLayerData: AisTarget[];
|
shipLayerData: AisTarget[];
|
||||||
shipOverlayLayerData: AisTarget[];
|
|
||||||
legacyTargetsOrdered: AisTarget[];
|
legacyTargetsOrdered: AisTarget[];
|
||||||
legacyOverlayTargets: AisTarget[];
|
legacyOverlayTargets: AisTarget[];
|
||||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
@ -101,10 +100,6 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
|
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
|
||||||
else shipOtherData.push(t);
|
else shipOtherData.push(t);
|
||||||
}
|
}
|
||||||
const shipOverlayOtherData: AisTarget[] = [];
|
|
||||||
for (const t of ctx.shipOverlayLayerData) {
|
|
||||||
if (!isTargetShip(t.mmsi)) shipOverlayOtherData.push(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─ density ─ */
|
/* ─ density ─ */
|
||||||
if (ctx.showDensity) {
|
if (ctx.showDensity) {
|
||||||
@ -318,26 +313,6 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (shipOtherData.length > 0) {
|
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(
|
layers.push(
|
||||||
new IconLayer<AisTarget>({
|
new IconLayer<AisTarget>({
|
||||||
id: 'ships-other',
|
id: 'ships-other',
|
||||||
@ -345,14 +320,11 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
pickable: true,
|
pickable: true,
|
||||||
billboard: false,
|
billboard: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
iconAtlas: getCachedShipIcon(),
|
getIcon: (d) => getShipIconSpec(d.signalKindCode ?? d.shipKindCode, d.sog),
|
||||||
iconMapping: SHIP_ICON_MAPPING,
|
|
||||||
getIcon: () => 'ship',
|
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
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',
|
sizeUnits: 'pixels',
|
||||||
getSize: () => FLAT_SHIP_ICON_SIZE,
|
getSize: FLAT_OTHER_SHIP_SIZE,
|
||||||
getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET),
|
|
||||||
onHover: shipOnHover,
|
onHover: shipOnHover,
|
||||||
onClick: shipOnClick,
|
onClick: shipOnClick,
|
||||||
alphaCutoff: 0.05,
|
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) {
|
if (ctx.legacyTargetsOrdered.length > 0) {
|
||||||
layers.push(
|
layers.push(
|
||||||
new ScatterplotLayer<AisTarget>({
|
new ScatterplotLayer<AisTarget>({
|
||||||
@ -413,14 +360,14 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
pickable: true,
|
pickable: true,
|
||||||
billboard: false,
|
billboard: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
iconAtlas: getCachedShipIcon(),
|
getIcon: (d) => getTargetShipIconSpec(ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, d.sog),
|
||||||
iconMapping: SHIP_ICON_MAPPING,
|
|
||||||
getIcon: () => 'ship',
|
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
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',
|
sizeUnits: 'pixels',
|
||||||
getSize: () => FLAT_SHIP_ICON_SIZE,
|
getSize: FLAT_TARGET_SHIP_SIZE,
|
||||||
getColor: (d) => getShipColor(d, null, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
|
|
||||||
onHover: shipOnHover,
|
onHover: shipOnHover,
|
||||||
onClick: shipOnClick,
|
onClick: shipOnClick,
|
||||||
alphaCutoff: 0.05,
|
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 }));
|
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) {
|
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] }));
|
layers.push(new ScatterplotLayer<AisTarget>({
|
||||||
}
|
id: 'legacy-halo-overlay',
|
||||||
|
data: ctx.legacyOverlayTargets,
|
||||||
if (ctx.showShips && ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)).length > 0) {
|
pickable: false,
|
||||||
const shipOverlayTargetData2 = ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi));
|
billboard: false,
|
||||||
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); } }));
|
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) ─ */
|
/* ─ 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 { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
import { rgbToHex } from '../../../shared/lib/map/palette';
|
import { rgbToHex } from '../../../shared/lib/map/palette';
|
||||||
|
import { SHIP_KIND_COLORS } from '../../../shared/lib/map/shipKind';
|
||||||
import {
|
import {
|
||||||
ANCHOR_SPEED_THRESHOLD_KN,
|
ANCHOR_SPEED_THRESHOLD_KN,
|
||||||
LEGACY_CODE_COLORS,
|
LEGACY_CODE_COLORS,
|
||||||
MAP_SELECTED_SHIP_RGB,
|
|
||||||
MAP_HIGHLIGHT_SHIP_RGB,
|
|
||||||
MAP_DEFAULT_SHIP_RGB,
|
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { isFiniteNumber } from './setUtils';
|
import { isFiniteNumber } from './setUtils';
|
||||||
import { normalizeAngleDeg } from './geometry';
|
import { normalizeAngleDeg } from './geometry';
|
||||||
@ -53,44 +51,21 @@ export function lightenColor(rgb: [number, number, number], ratio = 0.32) {
|
|||||||
|
|
||||||
export function getGlobeBaseShipColor({
|
export function getGlobeBaseShipColor({
|
||||||
legacy,
|
legacy,
|
||||||
sog,
|
signalKindCode,
|
||||||
}: {
|
}: {
|
||||||
legacy: string | null;
|
legacy: string | null;
|
||||||
sog: number | null;
|
signalKindCode?: string;
|
||||||
}) {
|
}) {
|
||||||
|
// 대상 선박: legacy code 색상 (밝게)
|
||||||
if (legacy) {
|
if (legacy) {
|
||||||
const rgb = LEGACY_CODE_COLORS[legacy];
|
const rgb = LEGACY_CODE_COLORS[legacy];
|
||||||
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
|
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep alpha control in icon-opacity only to avoid double-multiplying transparency.
|
// 기타 AIS: signalKindCode → 선종별 색상
|
||||||
if (!isFiniteNumber(sog)) return '#64748b';
|
const kindColor = SHIP_KIND_COLORS[signalKindCode || '000027'];
|
||||||
if (sog >= 10) return '#94a3b8';
|
if (kindColor) return kindColor;
|
||||||
if (sog >= 1) return '#64748b';
|
return '#607D8B';
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGlobeShipFeature(
|
export function buildGlobeShipFeature(
|
||||||
@ -108,7 +83,10 @@ export function buildGlobeShipFeature(
|
|||||||
mmsi: t.mmsi,
|
mmsi: t.mmsi,
|
||||||
heading: getDisplayHeading({ cog: t.cog, heading: t.heading, offset }),
|
heading: getDisplayHeading({ cog: t.cog, heading: t.heading, offset }),
|
||||||
anchored,
|
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,
|
selected: isSelected,
|
||||||
highlighted: isHighlighted,
|
highlighted: isHighlighted,
|
||||||
permitted: legacy ? 1 : 0,
|
permitted: legacy ? 1 : 0,
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user