gc-wing/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts
htlee 168bea0621 feat(map): 오버레이 가시성 개선 + 경고 선박 강조
Part A — 오버레이 라인 가시성:
- Globe/Mercator 쌍끌이·FC·선단 라인 굵기 ~2x 증가
- Globe 범위 원·선단 원 대시 패턴 추가
- Globe 오버레이 호버 시 브리딩(breathing) 맥동 rAF

Part B — 경고 선박 강조:
- DashboardPage → Map3D alarmMmsiMap 전달
- Globe: 경고 맥동 레이어(ships-globe-alarm-pulse) + 배지(ships-globe-alarm-badge)
- Mercator: Deck.gl ScatterplotLayer 맥동 + TextLayer 배지
- 경고 종류별 배지(이/수/환/휴/A), z-index 상향, 호버 스케일 확대
- 경고 필터 OFF 시 맥동/배지 자동 비표시

Part C — Mercator 선명 라벨:
- MapLibre 라벨 → Deck.gl TextLayer 교체 (z-order 안정)

Part D — 프로젝션 전환 안정화:
- Globe→Mercator 전환 시 Globe custom layer 맵에서 분리
- alarm rAF에 projectionBusyRef 가드 추가
- overlay.setProps() stale WebGL 자원 참조 방지

Part E — 김개발(DEV) 모드 더미 데이터:
- mockOverlayData.ts: 서해 12척 가상 선박 (5종 경고 시나리오)
- 김개발 로그인 시 자동 주입, 일반 계정 미노출

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:52:51 +09:00

589 lines
29 KiB
TypeScript

import { HexagonLayer } from '@deck.gl/aggregation-layers';
import { IconLayer, LineLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
import type { FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
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_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,
PAIR_LINE_WARN_DECK,
FC_LINE_NORMAL_DECK,
FC_LINE_SUSPICIOUS_DECK,
FLEET_RANGE_LINE_DECK,
FLEET_RANGE_FILL_DECK,
PAIR_RANGE_NORMAL_DECK_HL,
PAIR_RANGE_WARN_DECK_HL,
PAIR_LINE_NORMAL_DECK_HL,
PAIR_LINE_WARN_DECK_HL,
FC_LINE_NORMAL_DECK_HL,
FC_LINE_SUSPICIOUS_DECK_HL,
FLEET_RANGE_LINE_DECK_HL,
FLEET_RANGE_FILL_DECK_HL,
} from '../constants';
import { getDisplayHeading, getShipColor } from './shipUtils';
import { getCachedShipIcon } from './shipIconCache';
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */
interface DeckHoverCallbacks {
touchDeckHoverState: (isHover: boolean) => void;
setDeckHoverPairs: (next: number[]) => void;
setDeckHoverMmsi: (next: number[]) => void;
clearDeckHoverPairs: () => void;
clearMapFleetHoverState: () => void;
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
toFleetMmsiList: (value: unknown) => number[];
}
interface DeckSelectCallbacks {
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
onSelectMmsi: (mmsi: number | null) => void;
onToggleHighlightMmsi?: (mmsi: number) => void;
onDeckSelectOrHighlight: (info: unknown, allowMultiSelect?: boolean) => void;
}
/* ── Mercator Deck 레이어 ─────────────────────────────── */
export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks {
shipLayerData: AisTarget[];
shipOverlayLayerData: AisTarget[];
legacyTargetsOrdered: AisTarget[];
legacyOverlayTargets: AisTarget[];
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
pairLinks: PairLink[] | undefined;
fcDashed: DashSeg[];
fleetCircles: FleetCircle[] | undefined;
pairRanges: PairRangeCircle[];
pairLinksInteractive: PairLink[];
pairRangesInteractive: PairRangeCircle[];
fcLinesInteractive: DashSeg[];
fleetCirclesInteractive: FleetCircle[];
overlays: MapToggleState;
showDensity: boolean;
showShips: boolean;
selectedMmsi: number | null;
shipHighlightSet: Set<number>;
alarmTargets?: AisTarget[];
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
alarmPulseRadius?: number;
alarmPulseHoverRadius?: number;
}
export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] {
const layers: unknown[] = [];
const overlayParams = DEPTH_DISABLED_PARAMS;
const clearDeckHover = () => { ctx.touchDeckHoverState(false); };
const isTargetShip = (mmsi: number) => (ctx.legacyHits ? ctx.legacyHits.has(mmsi) : false);
const shipOtherData: AisTarget[] = [];
const shipTargetData: AisTarget[] = [];
for (const t of ctx.shipLayerData) {
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) {
layers.push(
new HexagonLayer<AisTarget>({
id: 'density',
data: ctx.shipLayerData,
pickable: true,
extruded: true,
radius: 2500,
elevationScale: 35,
coverage: 0.92,
opacity: 0.35,
getPosition: (d) => [d.lon, d.lat],
}),
);
}
/* ─ pair range ─ */
if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) {
const validRanges = ctx.pairRanges.filter((d) => d.center && isFinite(d.center[0]) && isFinite(d.center[1]) && isFinite(d.radiusNm));
if (validRanges.length > 0) layers.push(
new ScatterplotLayer<PairRangeCircle>({
id: 'pair-range',
data: validRanges,
pickable: true,
billboard: false,
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: 'meters',
getRadius: (d) => d.radiusNm * 1852,
radiusMinPixels: 10,
lineWidthUnits: 'pixels',
getLineWidth: () => 1.8,
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
getPosition: (d) => d.center,
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
ctx.touchDeckHoverState(true);
const p = info.object as PairRangeCircle;
ctx.setDeckHoverPairs([p.aMmsi, p.bMmsi]);
ctx.setDeckHoverMmsi([p.aMmsi, p.bMmsi]);
ctx.clearMapFleetHoverState();
},
onClick: (info) => {
if (!info.object) { ctx.onSelectMmsi(null); return; }
const obj = info.object as PairRangeCircle;
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
ctx.onToggleHighlightMmsi?.(obj.aMmsi);
ctx.onToggleHighlightMmsi?.(obj.bMmsi);
return;
}
ctx.onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
},
}),
);
}
/* ─ pair lines ─ */
if (ctx.overlays.pairLines && (ctx.pairLinks?.length ?? 0) > 0) {
layers.push(
new LineLayer<PairLink>({
id: 'pair-lines',
data: ctx.pairLinks,
pickable: true,
parameters: overlayParams,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK),
getWidth: (d) => (d.warn ? 3.5 : 2.5),
widthUnits: 'pixels',
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
ctx.touchDeckHoverState(true);
const obj = info.object as PairLink;
ctx.setDeckHoverPairs([obj.aMmsi, obj.bMmsi]);
ctx.setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]);
ctx.clearMapFleetHoverState();
},
onClick: (info) => {
if (!info.object) return;
const obj = info.object as PairLink;
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
ctx.onToggleHighlightMmsi?.(obj.aMmsi);
ctx.onToggleHighlightMmsi?.(obj.bMmsi);
return;
}
ctx.onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
},
}),
);
}
/* ─ fc lines ─ */
if (ctx.overlays.fcLines && ctx.fcDashed.length > 0) {
layers.push(
new LineLayer<DashSeg>({
id: 'fc-lines',
data: ctx.fcDashed,
pickable: true,
parameters: overlayParams,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
getWidth: () => 2.2,
widthUnits: 'pixels',
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
ctx.touchDeckHoverState(true);
const obj = info.object as DashSeg;
if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; }
ctx.setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]);
ctx.setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]);
ctx.clearMapFleetHoverState();
},
onClick: (info) => {
if (!info.object) return;
const obj = info.object as DashSeg;
if (obj.fromMmsi == null || obj.toMmsi == null) return;
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
ctx.onToggleHighlightMmsi?.(obj.fromMmsi);
ctx.onToggleHighlightMmsi?.(obj.toMmsi);
return;
}
ctx.onDeckSelectOrHighlight({ mmsi: obj.fromMmsi });
},
}),
);
}
/* ─ fleet circles ─ */
if (ctx.overlays.fleetCircles && (ctx.fleetCircles?.length ?? 0) > 0) {
layers.push(
new ScatterplotLayer<FleetCircle>({
id: 'fleet-circles',
data: ctx.fleetCircles,
pickable: true,
billboard: false,
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: 'meters',
getRadius: (d) => d.radiusNm * 1852,
lineWidthUnits: 'pixels',
getLineWidth: () => 2.0,
getLineColor: () => FLEET_RANGE_LINE_DECK,
getPosition: (d) => d.center,
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
ctx.touchDeckHoverState(true);
const obj = info.object as FleetCircle;
const list = ctx.toFleetMmsiList(obj.vesselMmsis);
ctx.setMapFleetHoverState(obj.ownerKey || null, list);
ctx.setDeckHoverMmsi(list);
ctx.clearDeckHoverPairs();
},
onClick: (info) => {
if (!info.object) return;
const obj = info.object as FleetCircle;
const list = ctx.toFleetMmsiList(obj.vesselMmsis);
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
for (const mmsi of list) ctx.onToggleHighlightMmsi?.(mmsi);
return;
}
const first = list[0];
if (first != null) ctx.onDeckSelectOrHighlight({ mmsi: first });
},
}),
);
layers.push(
new ScatterplotLayer<FleetCircle>({
id: 'fleet-circles-fill',
data: ctx.fleetCircles,
pickable: false,
billboard: false,
parameters: overlayParams,
filled: true,
stroked: false,
radiusUnits: 'meters',
getRadius: (d) => d.radiusNm * 1852,
getFillColor: () => FLEET_RANGE_FILL_DECK,
getPosition: (d) => d.center,
}),
);
}
/* ─ ships ─ */
if (ctx.showShips) {
const shipOnHover = (info: PickingInfo) => {
if (!info.object) { clearDeckHover(); return; }
ctx.touchDeckHoverState(true);
const obj = info.object as AisTarget;
ctx.setDeckHoverMmsi([obj.mmsi]);
ctx.clearDeckHoverPairs();
ctx.clearMapFleetHoverState();
};
const shipOnClick = (info: PickingInfo) => {
if (!info.object) { ctx.onSelectMmsi(null); return; }
ctx.onDeckSelectOrHighlight(
{
mmsi: (info.object as AisTarget).mmsi,
srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent,
},
true,
);
};
if (shipOtherData.length > 0) {
layers.push(
new IconLayer<AisTarget>({
id: 'ships-other',
data: shipOtherData,
pickable: true,
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: () => FLAT_SHIP_ICON_SIZE,
getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET),
onHover: shipOnHover,
onClick: shipOnClick,
alphaCutoff: 0.05,
}),
);
}
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>({
id: 'legacy-halo',
data: ctx.legacyTargetsOrdered,
pickable: false,
billboard: false,
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: 'pixels',
getRadius: () => FLAT_LEGACY_HALO_RADIUS,
lineWidthUnits: 'pixels',
getLineWidth: () => 2,
getLineColor: () => HALO_OUTLINE_COLOR,
getPosition: (d) => [d.lon, d.lat] as [number, number],
}),
);
}
if (shipTargetData.length > 0) {
layers.push(
new IconLayer<AisTarget>({
id: 'ships-target',
data: shipTargetData,
pickable: true,
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: () => FLAT_SHIP_ICON_SIZE,
getColor: (d) => getShipColor(d, null, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
onHover: shipOnHover,
onClick: shipOnClick,
alphaCutoff: 0.05,
}),
);
}
}
/* ─ interactive overlays ─ */
if (ctx.pairRangesInteractive.length > 0) {
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: ctx.pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.8, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
}
if (ctx.pairLinksInteractive.length > 0) {
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: ctx.pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 4.5, widthUnits: 'pixels' }));
}
if (ctx.fcLinesInteractive.length > 0) {
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: ctx.fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 3.2, widthUnits: 'pixels' }));
}
if (ctx.fleetCirclesInteractive.length > 0) {
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
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) ─ */
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); } }));
}
/* ─ ship name labels (Mercator) ─ */
if (ctx.showShips && ctx.overlays.shipLabels) {
const labelData: AisTarget[] = [];
for (const t of ctx.shipLayerData) {
const isTarget = ctx.legacyHits?.has(t.mmsi) ?? false;
const isSelected = ctx.selectedMmsi != null && t.mmsi === ctx.selectedMmsi;
const isHighlighted = ctx.shipHighlightSet.has(t.mmsi);
if (isTarget || isSelected || isHighlighted) labelData.push(t);
}
if (labelData.length > 0) {
layers.push(
new TextLayer<AisTarget>({
id: 'ship-labels',
data: labelData,
pickable: false,
billboard: true,
getText: (d) => {
const legacy = ctx.legacyHits?.get(d.mmsi);
const baseName = (legacy?.shipNameCn || legacy?.shipNameRoman || d.name || '').trim();
if (!baseName) return '';
const alarmKind = ctx.alarmMmsiMap?.get(d.mmsi) ?? null;
return alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
},
getPosition: (d) => [d.lon, d.lat] as [number, number],
getColor: (d) => {
if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 242];
if (ctx.shipHighlightSet.has(d.mmsi)) return [245, 158, 11, 242];
return [226, 232, 240, 234];
},
getSize: 11,
sizeUnits: 'pixels',
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, Noto Sans KR, sans-serif',
characterSet: 'auto',
getPixelOffset: [0, 16],
getTextAnchor: 'middle',
outlineWidth: 2,
outlineColor: [2, 6, 23, 217],
}),
);
}
}
/* ─ alarm pulse + badge ─ */
const alarmTargets = ctx.alarmTargets ?? [];
const alarmMap = ctx.alarmMmsiMap;
if (ctx.showShips && alarmMap && alarmMap.size > 0 && alarmTargets.length > 0) {
const pulseR = ctx.alarmPulseRadius ?? 8;
const pulseHR = ctx.alarmPulseHoverRadius ?? 12;
layers.push(
new ScatterplotLayer<AisTarget>({
id: 'alarm-pulse',
data: alarmTargets,
pickable: false,
billboard: false,
parameters: overlayParams,
filled: true,
stroked: false,
radiusUnits: 'pixels',
getRadius: (d) => {
const isHover = (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) || ctx.shipHighlightSet.has(d.mmsi);
return isHover ? pulseHR : pulseR;
},
getFillColor: (d) => {
const kind = alarmMap.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: [pulseR, pulseHR, ctx.selectedMmsi, ctx.shipHighlightSet] },
}),
);
layers.push(
new TextLayer<AisTarget>({
id: 'alarm-badge',
data: alarmTargets,
pickable: false,
parameters: overlayParams,
getText: (d) => {
const kind = alarmMap.get(d.mmsi);
return kind ? ALARM_BADGE[kind].label : '';
},
getPosition: (d) => [d.lon, d.lat] as [number, number],
getColor: [255, 255, 255, 255],
getBackgroundColor: (d) => {
const kind = alarmMap.get(d.mmsi);
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 200] as [number, number, number, number];
},
background: true,
backgroundPadding: [3, 1],
getPixelOffset: [14, -14],
sizeUnits: 'pixels',
getSize: 10,
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, Noto Sans KR, sans-serif',
characterSet: 'auto',
}),
);
}
return layers;
}
/* ── Globe Deck 오버레이 레이어 ────────────────────────── */
export interface GlobeDeckLayerContext {
pairRanges: PairRangeCircle[];
pairLinks: PairLink[] | undefined;
fcDashed: DashSeg[];
fleetCircles: FleetCircle[] | undefined;
legacyTargetsOrdered: AisTarget[];
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
overlays: MapToggleState;
showShips: boolean;
selectedMmsi: number | null;
isHighlightedFleet: (ownerKey: string, vesselMmsis: number[]) => boolean;
isHighlightedPair: (aMmsi: number, bMmsi: number) => boolean;
isHighlightedMmsi: (mmsi: number) => boolean;
touchDeckHoverState: (isHover: boolean) => void;
setDeckHoverPairs: (next: number[]) => void;
setDeckHoverMmsi: (next: number[]) => void;
clearDeckHoverPairs: () => void;
clearDeckHoverMmsi: () => void;
clearMapFleetHoverState: () => void;
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
toFleetMmsiList: (value: unknown) => number[];
}
export function buildGlobeDeckLayers(ctx: GlobeDeckLayerContext): unknown[] {
const overlayParams = GLOBE_OVERLAY_PARAMS;
const globeLayers: unknown[] = [];
if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) {
globeLayers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-globe', data: ctx.pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = ctx.isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const p = info.object as PairRangeCircle; ctx.setDeckHoverPairs([p.aMmsi, p.bMmsi]); ctx.setDeckHoverMmsi([p.aMmsi, p.bMmsi]); ctx.clearMapFleetHoverState(); } }));
}
if (ctx.overlays.pairLines && (ctx.pairLinks?.length ?? 0) > 0) {
const links = ctx.pairLinks || [];
globeLayers.push(new LineLayer<PairLink>({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = ctx.isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (ctx.isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const obj = info.object as PairLink; ctx.setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); ctx.setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); ctx.clearMapFleetHoverState(); } }));
}
if (ctx.overlays.fcLines && ctx.fcDashed.length > 0) {
globeLayers.push(new LineLayer<DashSeg>({ id: 'fc-lines-globe', data: ctx.fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => ctx.isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => ctx.isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); ctx.setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); ctx.clearMapFleetHoverState(); } }));
}
if (ctx.overlays.fleetCircles && (ctx.fleetCircles?.length ?? 0) > 0) {
const circles = ctx.fleetCircles || [];
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); ctx.clearMapFleetHoverState(); return; } ctx.touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = ctx.toFleetMmsiList(obj.vesselMmsis); ctx.setMapFleetHoverState(obj.ownerKey || null, list); ctx.setDeckHoverMmsi(list); ctx.clearDeckHoverPairs(); } }));
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center }));
}
if (ctx.showShips && ctx.legacyTargetsOrdered.length > 0) {
globeLayers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-globe', data: ctx.legacyTargetsOrdered, 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; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
}
return globeLayers;
}