import { useMemo, type MouseEvent } from "react"; import { VESSEL_TYPES } from "../../entities/vessel/model/meta"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import { haversineNm } from "../../shared/lib/geo/haversineNm"; type Props = { selectedVessel: DerivedLegacyVessel | null; vessels: DerivedLegacyVessel[]; fleetVessels: DerivedLegacyVessel[]; onSelectMmsi: (mmsi: number) => void; onToggleHighlightMmsi: (mmsi: number) => void; onHoverMmsi: (mmsis: number[]) => void; onClearHover: () => void; onHoverPair: (mmsis: number[]) => void; onClearPairHover: () => void; onHoverFleet: (ownerKey: string | null, mmsis: number[]) => void; onClearFleetHover: () => void; hoveredFleetOwnerKey?: string | null; hoveredFleetMmsiSet?: number[]; onContextMenuFleet?: (ownerKey: string, mmsis: number[]) => void; }; export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelectMmsi, onToggleHighlightMmsi, onHoverMmsi, onClearHover, onHoverPair, onClearPairHover, onHoverFleet, onClearFleetHover, hoveredFleetOwnerKey, hoveredFleetMmsiSet, onContextMenuFleet, }: Props) { const handlePrimaryAction = (e: MouseEvent, mmsi: number) => { if (e.shiftKey || e.ctrlKey || e.metaKey) { onToggleHighlightMmsi(mmsi); return; } onSelectMmsi(mmsi); }; const clearAllHovers = () => { onClearHover(); onClearPairHover(); onClearFleetHover(); }; const hoveredFleetMmsis = useMemo(() => new Set(hoveredFleetMmsiSet ?? []), [hoveredFleetMmsiSet]); const isFleetHighlightByOwner = (ownerKey: string | null) => hoveredFleetOwnerKey != null && ownerKey != null && hoveredFleetOwnerKey === ownerKey; const isVesselHighlight = (mmsi: number, ownerKey: string | null) => hoveredFleetMmsis.has(mmsi) || isFleetHighlightByOwner(ownerKey); if (selectedVessel) { const v = selectedVessel; const meta = VESSEL_TYPES[v.shipCode]; const ownerLabel = v.ownerCn || v.ownerRoman || v.ownerKey || "-"; const sameOwner = v.ownerKey ? vessels.filter((v2) => v2.ownerKey === v.ownerKey && v2.mmsi !== v.mmsi) : []; const pair = v.pairPermitNo ? vessels.find((v2) => v2.permitNo === v.pairPermitNo) ?? null : null; const fcNearby = vessels.filter((fc) => fc.shipCode === "FC" && fc.mmsi !== v.mmsi && haversineNm(fc.lat, fc.lon, v.lat, v.lon) < 5); return (
{meta.icon} {v.permitNo} {v.shipCode}
소유주:{" "} {ownerLabel}
{pair ? ( <>
⛓ 쌍끌이 쌍
{(() => { const dist = haversineNm(v.lat, v.lon, pair.lat, pair.lon); const warn = dist > 3; const pairMeta = VESSEL_TYPES[pair.shipCode]; return ( <>
onHoverPair([v.mmsi, pair.mmsi])} onMouseLeave={onClearPairHover} onClick={(e) => handlePrimaryAction(e, v.mmsi)} > {v.permitNo}
{warn ? "⚠" : "⟷"}
onHoverPair([v.mmsi, pair.mmsi])} onMouseLeave={onClearPairHover} onClick={(e) => handlePrimaryAction(e, pair.mmsi)} > {pair.permitNo} {dist.toFixed(2)}NM
정상 범위: 0.3~1.0NM | {warn ? "⚠ 이격 경고" : "✓ 정상 동기화"}
); })()} ) : null} {fcNearby.length && v.shipCode !== "FC" ? ( <>
🚛 근접 운반선
{fcNearby.slice(0, 6).map((fc) => { const dist = haversineNm(v.lat, v.lon, fc.lat, fc.lon); const isSameOwner = !!v.ownerKey && v.ownerKey === fc.ownerKey; const warn = dist < 0.5; return (
onHoverPair([v.mmsi, fc.mmsi])} onMouseLeave={onClearPairHover} onClick={(e) => handlePrimaryAction(e, fc.mmsi)} > {fc.permitNo} {dist.toFixed(1)}NM {isSameOwner ? ( 동일소유주 ) : null} {warn ? ( 환적의심 ) : null}
); })} ) : null} {sameOwner.length ? ( <>
🏢 동일 소유주 선단 ({sameOwner.length + 1}척)
{sameOwner.slice(0, 8).map((sv) => { const m = VESSEL_TYPES[sv.shipCode]; return (
onHoverFleet(v.ownerKey, [v.mmsi, ...sameOwner.map((x) => x.mmsi), sv.mmsi])} onMouseLeave={onClearFleetHover} onClick={(e) => handlePrimaryAction(e, sv.mmsi)} style={{ cursor: "pointer" }} >
{sv.shipCode} {sv.permitNo} {sv.sog ?? "?"}kt {sv.state.label}
); })} {sameOwner.length > 8 ?
... +{sameOwner.length - 8}척
: null} ) : null}
); } if (fleetVessels.length === 0) { return
(현재 지도에 표시중인 대상 선박이 없습니다)
; } const group = new Map(); for (const v of fleetVessels) { if (!v.ownerKey) continue; const list = group.get(v.ownerKey); if (list) list.push(v); else group.set(v.ownerKey, [v]); } const topFleets = Array.from(group.entries()) .map(([ownerKey, vs]) => ({ ownerKey, vs })) .filter((x) => x.vs.length >= 3) .sort((a, b) => b.vs.length - a.vs.length); if (topFleets.length === 0) { return
(표시 중인 선단(3척 이상) 없음)
; } return (
{topFleets.map(({ ownerKey, vs }) => { const displayOwner = vs.find((v) => v.ownerCn)?.ownerCn || vs.find((v) => v.ownerRoman)?.ownerRoman || ownerKey; const displayTitle = ownerKey && displayOwner !== ownerKey ? `${displayOwner} (${ownerKey})` : displayOwner; const isHighlightedFleetRow = isFleetHighlightByOwner(ownerKey) || vs.some((v) => hoveredFleetMmsis.has(v.mmsi)); const codes: Record = {}; for (const v of vs) codes[v.shipCode] = (codes[v.shipCode] ?? 0) + 1; return (
{ e.preventDefault(); onContextMenuFleet?.(ownerKey, vs.map((x) => x.mmsi)); }} onMouseEnter={() => onHoverFleet(ownerKey, vs.map((v) => v.mmsi))} onMouseLeave={onClearFleetHover} >
🏢{" "} {displayTitle} {" "} {vs.length}척
{Object.entries(codes).map(([c, n]) => { const meta = VESSEL_TYPES[c as keyof typeof VESSEL_TYPES]; return ( {c}×{n} ); })}
{vs.slice(0, 18).map((v) => { const m = VESSEL_TYPES[v.shipCode]; const text = v.shipCode === "FC" ? "F" : v.shipCode === "PT" ? "M" : v.shipCode === "PT-S" ? "S" : v.shipCode[0]; return (
onHoverMmsi([v.mmsi])} onMouseLeave={onClearHover} onClick={(e) => handlePrimaryAction(e, v.mmsi)} style={{ cursor: "pointer", width: 16, height: 16, borderRadius: v.shipCode === "FC" ? 2 : "50%", background: m.color, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 6, color: "#fff", border: "1px solid rgba(255,255,255,.2)", }} title={`${v.permitNo} ${v.shipCode} ${v.sog ?? "?"}kt`} > {text}
{v.pairPermitNo && (v.shipCode === "PT" || v.shipCode === "PT-S") ? : null}
); })} {vs.length > 18 ? +{vs.length - 18} : null}
); })}
); }