diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 453a8e2..e194216 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -1630,6 +1630,7 @@ export function Map3D({ zoneLabelExpr.push(["coalesce", ["get", "zoneName"], ["get", "zoneLabel"], ["get", "NAME"], "수역"]); const ensure = () => { + if (projectionBusyRef.current) return; // Always update visibility if the layers exist. const visibility = overlays.zones ? "visible" : "none"; try { @@ -1913,6 +1914,7 @@ export function Map3D({ }; const ensure = () => { + if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== "globe" || !settings.showShips) { @@ -2414,6 +2416,7 @@ export function Map3D({ }; const ensure = () => { + if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== "globe" || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) { remove(); @@ -2515,6 +2518,7 @@ export function Map3D({ }; const ensure = () => { + if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== "globe" || !overlays.fcLines) { remove(); @@ -2630,6 +2634,7 @@ export function Map3D({ }; const ensure = () => { + if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== "globe" || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) { remove(); @@ -2790,6 +2795,7 @@ export function Map3D({ }; const ensure = () => { + if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== "globe" || !overlays.pairRange) { remove(); @@ -3028,6 +3034,11 @@ export function Map3D({ resetGlobeHoverStates(); return; } + if (projectionBusyRef.current) { + resetGlobeHoverStates(); + clearGlobeTooltip(); + return; + } if (!map.isStyleLoaded()) { clearDeckGlobeHoverState(); clearGlobeTooltip(); @@ -3248,6 +3259,8 @@ export function Map3D({ useEffect(() => { const map = mapRef.current; if (!map) return; + if (projectionBusyRef.current) return; + let deckTarget = projection === "globe" ? globeDeckLayerRef.current : overlayRef.current; if (projection === "mercator") { diff --git a/apps/web/src/widgets/relations/RelationsPanel.tsx b/apps/web/src/widgets/relations/RelationsPanel.tsx index 4cbc3cc..1572940 100644 --- a/apps/web/src/widgets/relations/RelationsPanel.tsx +++ b/apps/web/src/widgets/relations/RelationsPanel.tsx @@ -1,3 +1,4 @@ +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"; @@ -7,9 +8,56 @@ type Props = { 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 }: Props) { +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]; @@ -19,7 +67,7 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect 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} @@ -59,12 +107,22 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect <>
- onSelectMmsi(v.mmsi)}> + onHoverPair([v.mmsi, pair.mmsi])} + onMouseLeave={onClearPairHover} + onClick={(e) => handlePrimaryAction(e, v.mmsi)} + > {v.permitNo}
{warn ? "⚠" : "⟷"}
- onSelectMmsi(pair.mmsi)}> + onHoverPair([v.mmsi, pair.mmsi])} + onMouseLeave={onClearPairHover} + onClick={(e) => handlePrimaryAction(e, pair.mmsi)} + > {pair.permitNo}
- onSelectMmsi(fc.mmsi)}> + onHoverPair([v.mmsi, fc.mmsi])} + onMouseLeave={onClearPairHover} + onClick={(e) => handlePrimaryAction(e, fc.mmsi)} + > {fc.permitNo} @@ -144,7 +207,14 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect {sameOwner.slice(0, 8).map((sv) => { const m = VESSEL_TYPES[sv.shipCode]; return ( -
onSelectMmsi(sv.mmsi)} style={{ cursor: "pointer" }}> +
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} @@ -160,7 +230,6 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect ); } - // No vessel selected: show top fleets if (fleetVessels.length === 0) { return
(현재 지도에 표시중인 대상 선박이 없습니다)
; } @@ -176,40 +245,51 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect 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) - .slice(0, 5); + .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} + > +
🏢{" "} - - {displayOwner} - {" "} - {vs.length}척 -
+ + {displayTitle} + {" "} + {vs.length}척 +
{Object.entries(codes).map(([c, n]) => { const meta = VESSEL_TYPES[c as keyof typeof VESSEL_TYPES]; @@ -234,11 +314,15 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect
{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]; + const text = + v.shipCode === "FC" ? "F" : v.shipCode === "PT" ? "M" : v.shipCode === "PT-S" ? "S" : v.shipCode[0]; return (
onSelectMmsi(v.mmsi)} + 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,