gc-wing/apps/web/src/widgets/relations/RelationsPanel.tsx

375 lines
15 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 FleetSortMode = "count" | "range";
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;
fleetSortMode?: FleetSortMode;
};
export function RelationsPanel({
selectedVessel,
vessels,
fleetVessels,
onSelectMmsi,
onToggleHighlightMmsi,
onHoverMmsi,
onClearHover,
onHoverPair,
onClearPairHover,
onHoverFleet,
onClearFleetHover,
hoveredFleetOwnerKey,
hoveredFleetMmsiSet,
onContextMenuFleet,
fleetSortMode = "count",
}: 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);
const topFleets = useMemo(() => {
const group = new Map<string, DerivedLegacyVessel[]>();
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]);
}
return Array.from(group.entries())
.map(([ownerKey, vs]) => {
const lon = vs.reduce((sum, v) => sum + v.lon, 0) / vs.length;
const lat = vs.reduce((sum, v) => sum + v.lat, 0) / vs.length;
const radiusNm = vs.reduce((max, v) => {
const d = haversineNm(lat, lon, v.lat, v.lon);
return Math.max(max, d);
}, 0);
return { ownerKey, vs, radiusNm };
})
.filter((x) => x.vs.length >= 3)
.sort((a, b) => {
if (fleetSortMode === "range") {
return b.radiusNm - a.radiusNm || b.vs.length - a.vs.length;
}
return b.vs.length - a.vs.length || b.radiusNm - a.radiusNm;
});
}, [fleetVessels, fleetSortMode]);
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 (
<div className="rel-panel" onMouseLeave={clearAllHovers}>
<div className="rel-header">
<span style={{ fontSize: 14 }}>{meta.icon}</span>
<span style={{ fontSize: 11, fontWeight: 800, color: meta.color }}>{v.permitNo}</span>
<span className="rel-badge" style={{ background: `${meta.color}22`, color: meta.color }}>
{v.shipCode}
</span>
</div>
<div style={{ fontSize: 9, color: "var(--muted)", marginBottom: 6 }}>
:{" "}
<b
style={{
color: "var(--text)",
display: "inline-block",
maxWidth: 230,
verticalAlign: "bottom",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
title={ownerLabel}
>
{ownerLabel}
</b>
</div>
{pair ? (
<>
<div style={{ fontSize: 8, fontWeight: 700, color: "var(--muted)", margin: "6px 0 3px", letterSpacing: 1 }}>
</div>
{(() => {
const dist = haversineNm(v.lat, v.lon, pair.lat, pair.lon);
const warn = dist > 3;
const pairMeta = VESSEL_TYPES[pair.shipCode];
return (
<>
<div className="rel-line">
<div className="dot" style={{ background: meta.color }} />
<span
style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }}
onMouseEnter={() => onHoverPair([v.mmsi, pair.mmsi])}
onMouseLeave={onClearPairHover}
onClick={(e) => handlePrimaryAction(e, v.mmsi)}
>
{v.permitNo}
</span>
<div className="rel-link">{warn ? "⚠" : "⟷"}</div>
<div className="dot" style={{ background: pairMeta.color }} />
<span
style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }}
onMouseEnter={() => onHoverPair([v.mmsi, pair.mmsi])}
onMouseLeave={onClearPairHover}
onClick={(e) => handlePrimaryAction(e, pair.mmsi)}
>
{pair.permitNo}
</span>
<span
className="rel-dist"
style={{
background: warn ? "#F59E0B22" : "#22C55E22",
color: warn ? "#F59E0B" : "#22C55E",
}}
>
{dist.toFixed(2)}NM
</span>
</div>
<div style={{ fontSize: 8, color: "var(--muted)", marginLeft: 10 }}>
범위: 0.3~1.0NM | {warn ? "⚠ 이격 경고" : "✓ 정상 동기화"}
</div>
</>
);
})()}
</>
) : null}
{fcNearby.length && v.shipCode !== "FC" ? (
<>
<div style={{ fontSize: 8, fontWeight: 700, color: "var(--muted)", margin: "6px 0 3px", letterSpacing: 1 }}>
🚛
</div>
{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 (
<div key={fc.mmsi} className="rel-line">
<div className="dot" style={{ background: VESSEL_TYPES.FC.color }} />
<span
style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }}
onMouseEnter={() => onHoverPair([v.mmsi, fc.mmsi])}
onMouseLeave={onClearPairHover}
onClick={(e) => handlePrimaryAction(e, fc.mmsi)}
>
{fc.permitNo}
</span>
<span className="rel-dist" style={{ background: "#D9770622", color: "#D97706" }}>
{dist.toFixed(1)}NM
</span>
{isSameOwner ? (
<span
style={{
fontSize: 7,
background: "#F59E0B22",
color: "#F59E0B",
padding: "1px 3px",
borderRadius: 2,
}}
>
</span>
) : null}
{warn ? (
<span
style={{
fontSize: 7,
background: "#EF444422",
color: "#EF4444",
padding: "1px 3px",
borderRadius: 2,
}}
>
</span>
) : null}
</div>
);
})}
</>
) : null}
{sameOwner.length ? (
<>
<div style={{ fontSize: 8, fontWeight: 700, color: "var(--muted)", margin: "6px 0 3px", letterSpacing: 1 }}>
🏢 ({sameOwner.length + 1})
</div>
{sameOwner.slice(0, 8).map((sv) => {
const m = VESSEL_TYPES[sv.shipCode];
return (
<div
key={sv.mmsi}
className={`fleet-vessel ${isVesselHighlight(sv.mmsi, v.ownerKey) ? "hl" : ""}`}
onMouseEnter={() => onHoverFleet(v.ownerKey, [v.mmsi, ...sameOwner.map((x) => x.mmsi), sv.mmsi])}
onMouseLeave={onClearFleetHover}
onClick={(e) => handlePrimaryAction(e, sv.mmsi)}
style={{ cursor: "pointer" }}
>
<div className="dot" style={{ background: m.color }} />
<span style={{ color: m.color, fontWeight: 600 }}>{sv.shipCode}</span> {sv.permitNo}
<span style={{ color: "var(--muted)" }}>
{sv.sog ?? "?"}kt {sv.state.label}
</span>
</div>
);
})}
{sameOwner.length > 8 ? <div style={{ fontSize: 8, color: "var(--muted)" }}>... +{sameOwner.length - 8}</div> : null}
</>
) : null}
</div>
);
}
if (fleetVessels.length === 0) {
return <div style={{ fontSize: 11, color: "var(--muted)" }}>( )</div>;
}
if (topFleets.length === 0) {
return <div style={{ fontSize: 11, color: "var(--muted)" }}>( (3 ) )</div>;
}
return (
<div onMouseLeave={clearAllHovers}>
{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<string, number> = {};
for (const v of vs) codes[v.shipCode] = (codes[v.shipCode] ?? 0) + 1;
return (
<div
key={ownerKey}
className={`fleet-card ${isHighlightedFleetRow ? "hl" : ""}`}
onContextMenu={(e) => {
e.preventDefault();
onContextMenuFleet?.(ownerKey, vs.map((x) => x.mmsi));
}}
onMouseEnter={() => onHoverFleet(ownerKey, vs.map((v) => v.mmsi))}
onMouseLeave={onClearFleetHover}
>
<div className={`fleet-owner ${isHighlightedFleetRow ? "hl" : ""}`}>
🏢{" "}
<span
style={{
display: "inline-block",
maxWidth: 240,
verticalAlign: "bottom",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
color: "var(--text)",
marginRight: 4,
}}
title={displayTitle}
>
{displayTitle}
</span>{" "}
<span style={{ fontSize: 8, color: "var(--muted)" }}>{vs.length}</span>
</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap", marginBottom: 3 }}>
{Object.entries(codes).map(([c, n]) => {
const meta = VESSEL_TYPES[c as keyof typeof VESSEL_TYPES];
return (
<span
key={c}
style={{
fontSize: 8,
background: `${meta.color}22`,
color: meta.color,
padding: "1px 4px",
borderRadius: 2,
fontWeight: 600,
}}
>
{c}×{n}
</span>
);
})}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
{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 (
<span key={v.mmsi} style={{ display: "inline-flex", alignItems: "center", gap: 2 }}>
<div
className={`fleet-dot ${isVesselHighlight(v.mmsi, ownerKey) ? "hl" : ""}`}
onMouseEnter={() => 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}
</div>
{v.pairPermitNo && (v.shipCode === "PT" || v.shipCode === "PT-S") ? <span style={{ fontSize: 8, color: "var(--muted)" }}></span> : null}
</span>
);
})}
{vs.length > 18 ? <span style={{ fontSize: 8, color: "var(--muted)" }}>+{vs.length - 18}</span> : null}
</div>
</div>
);
})}
</div>
);
}