feat(map3d): stabilize globe overlays and hover-highlight sync

This commit is contained in:
htlee 2026-02-15 16:09:21 +09:00
부모 b944887430
커밋 05b0c6b881
5개의 변경된 파일516개의 추가작업 그리고 163개의 파일을 삭제

파일 보기

@ -188,12 +188,17 @@ export function computeFleetCircles(vessels: DerivedLegacyVessel[]): FleetCircle
const out: FleetCircle[] = []; const out: FleetCircle[] = [];
for (const [ownerKey, vs] of groups.entries()) { for (const [ownerKey, vs] of groups.entries()) {
if (vs.length < 3) continue; if (vs.length < 3) continue;
const ownerLabel =
vs.find((v) => v.ownerCn)?.ownerCn ??
vs.find((v) => v.ownerRoman)?.ownerRoman ??
ownerKey;
const lon = vs.reduce((sum, v) => sum + v.lon, 0) / vs.length; 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 lat = vs.reduce((sum, v) => sum + v.lat, 0) / vs.length;
let radiusNm = 0; let radiusNm = 0;
for (const v of vs) radiusNm = Math.max(radiusNm, haversineNm(lat, lon, v.lat, v.lon)); for (const v of vs) radiusNm = Math.max(radiusNm, haversineNm(lat, lon, v.lat, v.lon));
out.push({ out.push({
ownerKey, ownerKey,
ownerLabel,
center: [lon, lat], center: [lon, lat],
radiusNm: Math.max(0.2, radiusNm), radiusNm: Math.max(0.2, radiusNm),
count: vs.length, count: vs.length,

파일 보기

@ -56,6 +56,7 @@ export type FcLink = {
export type FleetCircle = { export type FleetCircle = {
ownerKey: string; ownerKey: string;
ownerLabel: string;
center: [number, number]; center: [number, number];
radiusNm: number; radiusNm: number;
count: number; count: number;
@ -71,4 +72,3 @@ export type LegacyAlarm = {
text: string; text: string;
relatedMmsi: number[]; relatedMmsi: number[];
}; };

파일 보기

@ -610,6 +610,10 @@ export function DashboardPage() {
fleetCircles={fleetCirclesForMap} fleetCircles={fleetCirclesForMap}
fleetFocus={fleetFocus} fleetFocus={fleetFocus}
onProjectionLoadingChange={setIsProjectionLoading} onProjectionLoadingChange={setIsProjectionLoading}
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))}
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
onClearPairHover={() => setHoveredPairMmsiSet([])}
onHoverFleet={(ownerKey, fleetMmsis) => { onHoverFleet={(ownerKey, fleetMmsis) => {
setHoveredFleetOwnerKey(ownerKey); setHoveredFleetOwnerKey(ownerKey);
setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis)); setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis));

파일 보기

@ -56,6 +56,10 @@ type Props = {
}; };
onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void; onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void;
onClearFleetHover?: () => void; onClearFleetHover?: () => void;
onHoverMmsi?: (mmsiList: number[]) => void;
onClearMmsiHover?: () => void;
onHoverPair?: (mmsiList: number[]) => void;
onClearPairHover?: () => void;
}; };
function toNumberSet(values: number[] | undefined | null) { function toNumberSet(values: number[] | undefined | null) {
@ -141,6 +145,49 @@ function makeFleetCircleFeatureId(ownerKey: string) {
return `fleet-${ownerKey}`; return `fleet-${ownerKey}`;
} }
function makeMmsiPairHighlightExpr(aField: string, bField: string, hoveredMmsiList: number[]) {
if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length < 2) {
return false;
}
const inA = ["in", ["to-number", ["get", aField]], ["literal", hoveredMmsiList]] as unknown[];
const inB = ["in", ["to-number", ["get", bField]], ["literal", hoveredMmsiList]] as unknown[];
return ["all", inA, inB] as unknown[];
}
function makeMmsiAnyEndpointExpr(aField: string, bField: string, hoveredMmsiList: number[]) {
if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length === 0) {
return false;
}
const literal = ["literal", hoveredMmsiList] as unknown[];
return [
"any",
["in", ["to-number", ["get", aField]], literal],
["in", ["to-number", ["get", bField]], literal],
] as unknown[];
}
function makeFleetOwnerMatchExpr(hoveredOwnerKeys: string[]) {
if (!Array.isArray(hoveredOwnerKeys) || hoveredOwnerKeys.length === 0) {
return false;
}
const expr = ["match", ["to-string", ["coalesce", ["get", "ownerKey"], ""]]] as unknown[];
for (const ownerKey of hoveredOwnerKeys) {
expr.push(String(ownerKey), true);
}
expr.push(false);
return expr;
}
function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) {
if (!Array.isArray(hoveredFleetMmsiList) || hoveredFleetMmsiList.length === 0) {
return false;
}
const clauses = hoveredFleetMmsiList.map((mmsi) =>
["in", mmsi, ["coalesce", ["get", "vesselMmsis"], []]] as unknown[],
);
return ["any", ...clauses] as unknown[];
}
const SHIP_ICON_MAPPING = { const SHIP_ICON_MAPPING = {
ship: { ship: {
x: 0, x: 0,
@ -922,7 +969,37 @@ type PairRangeCircle = {
distanceNm: number; distanceNm: number;
}; };
const makeUniqueSorted = (values: number[]) => Array.from(new Set(values.filter((v) => Number.isFinite(v)))).sort((a, b) => a - b); const toNumberArray = (values: unknown): number[] => {
if (values == null) return [];
if (Array.isArray(values)) {
return values as unknown as number[];
}
if (typeof values === "number" && Number.isFinite(values)) {
return [values];
}
if (typeof values === "string") {
const value = toSafeNumber(Number(values));
return value == null ? [] : [value];
}
if (typeof values === "object") {
if (typeof (values as { [Symbol.iterator]?: unknown })?.[Symbol.iterator] === "function") {
try {
return Array.from(values as Iterable<unknown>) as number[];
} catch {
return [];
}
}
}
return [];
};
const makeUniqueSorted = (values: unknown) => {
const maybeArray = toNumberArray(values);
const normalized = Array.isArray(maybeArray) ? maybeArray : [];
const unique = Array.from(new Set(normalized.filter((value) => Number.isFinite(value))));
unique.sort((a, b) => a - b);
return unique;
};
const equalNumberArrays = (a: number[], b: number[]) => { const equalNumberArrays = (a: number[], b: number[]) => {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
@ -959,6 +1036,10 @@ export function Map3D({
fleetFocus, fleetFocus,
onHoverFleet, onHoverFleet,
onClearFleetHover, onClearFleetHover,
onHoverMmsi,
onClearMmsiHover,
onHoverPair,
onClearPairHover,
}: Props) { }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
@ -973,6 +1054,8 @@ export function Map3D({
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null); const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const projectionPrevRef = useRef<MapProjectionId>(projection); const projectionPrevRef = useRef<MapProjectionId>(projection);
const mapTooltipRef = useRef<maplibregl.Popup | null>(null); const mapTooltipRef = useRef<maplibregl.Popup | null>(null);
const deckHoverRafRef = useRef<number | null>(null);
const deckHoverHasHitRef = useRef(false);
const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState<number[]>([]); const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState<number[]>([]);
const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState<number[]>([]); const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState<number[]>([]);
const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState<string | null>(null); const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState<string | null>(null);
@ -991,13 +1074,16 @@ export function Map3D({
if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey); if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey);
return keys; return keys;
}, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]); }, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]);
const fleetFocusId = fleetFocus?.id;
const fleetFocusLon = fleetFocus?.center?.[0];
const fleetFocusLat = fleetFocus?.center?.[1];
const fleetFocusZoom = fleetFocus?.zoom;
const reorderGlobeFeatureLayers = useCallback((options?: { shipTop?: boolean }) => { const reorderGlobeFeatureLayers = useCallback(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map || projectionRef.current !== "globe") return; if (!map || projectionRef.current !== "globe") return;
if (projectionBusyRef.current) return; if (projectionBusyRef.current) return;
const shipTop = options?.shipTop === true;
const ordering = [ const ordering = [
"zones-fill", "zones-fill",
"zones-line", "zones-line",
@ -1020,17 +1106,6 @@ export function Map3D({
} }
} }
if (!shipTop) return;
const shipOrdering = ["ships-globe-halo", "ships-globe-outline", "ships-globe"];
for (const layerId of shipOrdering) {
try {
if (map.getLayer(layerId)) map.moveLayer(layerId);
} catch {
// ignore
}
}
kickRepaint(map); kickRepaint(map);
}, []); }, []);
@ -1078,6 +1153,9 @@ export function Map3D({
[effectiveHoveredFleetMmsiSet, hoveredFleetOwnerKeys], [effectiveHoveredFleetMmsiSet, hoveredFleetOwnerKeys],
); );
const hoveredPairSignature = useMemo(() => makeSetSignature(effectiveHoveredPairMmsiSet), [effectiveHoveredPairMmsiSet]); const hoveredPairSignature = useMemo(() => makeSetSignature(effectiveHoveredPairMmsiSet), [effectiveHoveredPairMmsiSet]);
const hoveredPairMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredPairMmsiSet)), [effectiveHoveredPairMmsiSet]);
const hoveredFleetOwnerKeyList = useMemo(() => Array.from(hoveredFleetOwnerKeys).sort(), [hoveredFleetOwnerKeys]);
const hoveredFleetMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredFleetMmsiSet)), [effectiveHoveredFleetMmsiSet]);
const isHighlightedMmsi = useCallback( const isHighlightedMmsi = useCallback(
(mmsi: number) => highlightedMmsiSetCombined.has(mmsi), (mmsi: number) => highlightedMmsiSetCombined.has(mmsi),
@ -1100,6 +1178,16 @@ export function Map3D({
[hoveredFleetOwnerKeys, isHighlightedMmsi], [hoveredFleetOwnerKeys, isHighlightedMmsi],
); );
const shipData = useMemo(() => {
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi));
}, [targets]);
const shipByMmsi = useMemo(() => {
const byMmsi = new Map<number, AisTarget>();
for (const t of shipData) byMmsi.set(t.mmsi, t);
return byMmsi;
}, [shipData]);
const hasAuxiliarySelectModifier = (ev?: { const hasAuxiliarySelectModifier = (ev?: {
shiftKey?: boolean; shiftKey?: boolean;
ctrlKey?: boolean; ctrlKey?: boolean;
@ -1109,21 +1197,6 @@ export function Map3D({
return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey); return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey);
}; };
const setHoveredMmsiList = useCallback((next: number[]) => {
const normalized = makeUniqueSorted(next);
setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
}, []);
const setHoveredDeckMmsiSingle = useCallback((mmsi: number | null) => {
const normalized = mmsi == null ? [] : [mmsi];
setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
}, []);
const setHoveredDeckPairs = useCallback((next: number[]) => {
const normalized = makeUniqueSorted(next);
setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
}, []);
const setHoveredDeckFleetMmsis = useCallback((next: number[]) => { const setHoveredDeckFleetMmsis = useCallback((next: number[]) => {
const normalized = makeUniqueSorted(next); const normalized = makeUniqueSorted(next);
setHoveredDeckFleetMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); setHoveredDeckFleetMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
@ -1135,6 +1208,12 @@ export function Map3D({
const onHoverFleetRef = useRef(onHoverFleet); const onHoverFleetRef = useRef(onHoverFleet);
const onClearFleetHoverRef = useRef(onClearFleetHover); const onClearFleetHoverRef = useRef(onClearFleetHover);
const onHoverMmsiRef = useRef(onHoverMmsi);
const onClearMmsiHoverRef = useRef(onClearMmsiHover);
const onHoverPairRef = useRef(onHoverPair);
const onClearPairHoverRef = useRef(onClearPairHover);
const mapDeckMmsiHoverRef = useRef<number[]>([]);
const mapDeckPairHoverRef = useRef<number[]>([]);
const mapFleetHoverStateRef = useRef<{ const mapFleetHoverStateRef = useRef<{
ownerKey: string | null; ownerKey: string | null;
vesselMmsis: number[]; vesselMmsis: number[];
@ -1143,22 +1222,11 @@ export function Map3D({
useEffect(() => { useEffect(() => {
onHoverFleetRef.current = onHoverFleet; onHoverFleetRef.current = onHoverFleet;
onClearFleetHoverRef.current = onClearFleetHover; onClearFleetHoverRef.current = onClearFleetHover;
}, [onHoverFleet, onClearFleetHover]); onHoverMmsiRef.current = onHoverMmsi;
onClearMmsiHoverRef.current = onClearMmsiHover;
const setMapFleetHoverState = useCallback( onHoverPairRef.current = onHoverPair;
(ownerKey: string | null, vesselMmsis: number[]) => { onClearPairHoverRef.current = onClearPairHover;
const normalized = makeUniqueSorted(vesselMmsis); }, [onHoverFleet, onClearFleetHover, onHoverMmsi, onClearMmsiHover, onHoverPair, onClearPairHover]);
const prev = mapFleetHoverStateRef.current;
if (prev.ownerKey === ownerKey && equalNumberArrays(prev.vesselMmsis, normalized)) {
return;
}
setHoveredDeckFleetOwner(ownerKey);
setHoveredDeckFleetMmsis(normalized);
mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized };
onHoverFleetRef.current?.(ownerKey, normalized);
},
[setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis],
);
const clearMapFleetHoverState = useCallback(() => { const clearMapFleetHoverState = useCallback(() => {
const nextOwner = null; const nextOwner = null;
@ -1172,6 +1240,97 @@ export function Map3D({
} }
}, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]); }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]);
const clearDeckHoverPairs = useCallback(() => {
const prev = mapDeckPairHoverRef.current;
mapDeckPairHoverRef.current = [];
setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
if (prev.length > 0) {
onClearPairHoverRef.current?.();
}
}, [setHoveredDeckPairMmsiSet]);
const clearDeckHoverMmsi = useCallback(() => {
const prev = mapDeckMmsiHoverRef.current;
mapDeckMmsiHoverRef.current = [];
setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
if (prev.length > 0) {
onClearMmsiHoverRef.current?.();
}
}, [setHoveredDeckMmsiSet]);
const scheduleDeckHoverResolve = useCallback(() => {
if (deckHoverRafRef.current != null) return;
deckHoverRafRef.current = window.requestAnimationFrame(() => {
deckHoverRafRef.current = null;
if (!deckHoverHasHitRef.current) {
clearDeckHoverMmsi();
clearDeckHoverPairs();
clearMapFleetHoverState();
}
deckHoverHasHitRef.current = false;
});
}, [clearDeckHoverMmsi, clearDeckHoverPairs, clearMapFleetHoverState]);
const touchDeckHoverState = useCallback(
(isHover: boolean) => {
if (isHover) deckHoverHasHitRef.current = true;
scheduleDeckHoverResolve();
},
[scheduleDeckHoverResolve],
);
const setDeckHoverMmsi = useCallback(
(next: number[]) => {
const normalized = makeUniqueSorted(next);
touchDeckHoverState(normalized.length > 0);
setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
if (!equalNumberArrays(mapDeckMmsiHoverRef.current, normalized)) {
mapDeckMmsiHoverRef.current = normalized;
onHoverMmsiRef.current?.(normalized);
}
},
[setHoveredDeckMmsiSet, touchDeckHoverState],
);
const setDeckHoverPairs = useCallback(
(next: number[]) => {
const normalized = makeUniqueSorted(next);
touchDeckHoverState(normalized.length > 0);
setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
if (!equalNumberArrays(mapDeckPairHoverRef.current, normalized)) {
mapDeckPairHoverRef.current = normalized;
onHoverPairRef.current?.(normalized);
}
},
[setHoveredDeckPairMmsiSet, touchDeckHoverState],
);
const setMapFleetHoverState = useCallback(
(ownerKey: string | null, vesselMmsis: number[]) => {
const normalized = makeUniqueSorted(vesselMmsis);
const prev = mapFleetHoverStateRef.current;
if (prev.ownerKey === ownerKey && equalNumberArrays(prev.vesselMmsis, normalized)) {
return;
}
touchDeckHoverState(!!ownerKey || normalized.length > 0);
setHoveredDeckFleetOwner(ownerKey);
setHoveredDeckFleetMmsis(normalized);
mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized };
onHoverFleetRef.current?.(ownerKey, normalized);
},
[setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState],
);
useEffect(() => {
return () => {
if (deckHoverRafRef.current != null) {
window.cancelAnimationFrame(deckHoverRafRef.current);
deckHoverRafRef.current = null;
}
deckHoverHasHitRef.current = false;
};
}, []);
useEffect(() => { useEffect(() => {
mapFleetHoverStateRef.current = { mapFleetHoverStateRef.current = {
ownerKey: hoveredFleetOwnerKey, ownerKey: hoveredFleetOwnerKey,
@ -2155,19 +2314,7 @@ export function Map3D({
const visibility = settings.showShips ? "visible" : "none"; const visibility = settings.showShips ? "visible" : "none";
// Put ships at the top so they're always visible (especially important under globe projection).
const before = undefined; const before = undefined;
const bringShipLayersToFront = () => {
const ids = [haloId, outlineId, symbolId];
for (const id of ids) {
if (!map.getLayer(id)) continue;
try {
map.moveLayer(id);
} catch {
// ignore
}
}
};
if (!map.getLayer(haloId)) { if (!map.getLayer(haloId)) {
try { try {
@ -2181,10 +2328,12 @@ export function Map3D({
"circle-sort-key": [ "circle-sort-key": [
"case", "case",
["==", ["get", "selected"], 1], ["==", ["get", "selected"], 1],
30, 90,
["==", ["get", "highlighted"], 1], ["==", ["get", "highlighted"], 1],
25, 80,
10, ["==", ["get", "permitted"], 1],
60,
20,
] as never, ] as never,
}, },
paint: { paint: {
@ -2208,7 +2357,20 @@ export function Map3D({
} else { } else {
try { try {
map.setLayoutProperty(haloId, "visibility", visibility); map.setLayoutProperty(haloId, "visibility", visibility);
map.setPaintProperty(haloId, "circle-color", ["case", ["==", ["get", "highlighted"], 1], ["coalesce", ["get", "shipColor"], "#64748b"], ["coalesce", ["get", "shipColor"], "#64748b"]] as never); map.setPaintProperty(
haloId,
"circle-color",
[
"case",
["==", ["get", "selected"], 1],
"rgba(14,234,255,1)",
["==", ["get", "highlighted"], 1],
"rgba(245,158,11,1)",
["==", ["get", "permitted"], 1],
"rgba(125,211,252,0.95)",
"rgba(59,130,246,1)",
] as never,
);
map.setPaintProperty(haloId, "circle-opacity", [ map.setPaintProperty(haloId, "circle-opacity", [
"case", "case",
["==", ["get", "selected"], 1], ["==", ["get", "selected"], 1],
@ -2239,6 +2401,8 @@ export function Map3D({
"rgba(14,234,255,0.95)", "rgba(14,234,255,0.95)",
["==", ["get", "highlighted"], 1], ["==", ["get", "highlighted"], 1],
"rgba(245,158,11,0.95)", "rgba(245,158,11,0.95)",
["==", ["get", "permitted"], 1],
"rgba(125,211,252,0.95)",
"rgba(59,130,246,0.75)", "rgba(59,130,246,0.75)",
] as never, ] as never,
"circle-stroke-width": [ "circle-stroke-width": [
@ -2247,6 +2411,8 @@ export function Map3D({
3.4, 3.4,
["==", ["get", "highlighted"], 1], ["==", ["get", "highlighted"], 1],
2.7, 2.7,
["==", ["get", "permitted"], 1],
1.8,
0.0, 0.0,
] as never, ] as never,
"circle-stroke-opacity": 0.85, "circle-stroke-opacity": 0.85,
@ -2256,10 +2422,12 @@ export function Map3D({
"circle-sort-key": [ "circle-sort-key": [
"case", "case",
["==", ["get", "selected"], 1], ["==", ["get", "selected"], 1],
40, 100,
["==", ["get", "highlighted"], 1], ["==", ["get", "highlighted"], 1],
35, 90,
15, ["==", ["get", "permitted"], 1],
70,
30,
] as never, ] as never,
}, },
} as unknown as LayerSpecification, } as unknown as LayerSpecification,
@ -2280,6 +2448,8 @@ export function Map3D({
"rgba(14,234,255,0.95)", "rgba(14,234,255,0.95)",
["==", ["get", "highlighted"], 1], ["==", ["get", "highlighted"], 1],
"rgba(245,158,11,0.95)", "rgba(245,158,11,0.95)",
["==", ["get", "permitted"], 1],
"rgba(125,211,252,0.95)",
"rgba(59,130,246,0.75)", "rgba(59,130,246,0.75)",
] as never, ] as never,
); );
@ -2292,6 +2462,8 @@ export function Map3D({
3.4, 3.4,
["==", ["get", "highlighted"], 1], ["==", ["get", "highlighted"], 1],
2.7, 2.7,
["==", ["get", "permitted"], 1],
1.8,
0.0, 0.0,
] as never, ] as never,
); );
@ -2312,10 +2484,12 @@ export function Map3D({
"symbol-sort-key": [ "symbol-sort-key": [
"case", "case",
["==", ["get", "selected"], 1], ["==", ["get", "selected"], 1],
50, 95,
["==", ["get", "highlighted"], 1], ["==", ["get", "highlighted"], 1],
85,
["==", ["get", "permitted"], 1],
65,
45, 45,
20,
] as never, ] as never,
"icon-image": imgId, "icon-image": imgId,
"icon-size": [ "icon-size": [
@ -2346,6 +2520,8 @@ export function Map3D({
"rgba(14,234,255,1)", "rgba(14,234,255,1)",
["==", ["get", "highlighted"], 1], ["==", ["get", "highlighted"], 1],
"rgba(245,158,11,1)", "rgba(245,158,11,1)",
["==", ["get", "permitted"], 1],
"rgba(125,211,252,1)",
"rgba(59,130,246,1)", "rgba(59,130,246,1)",
] as never, ] as never,
"icon-opacity": [ "icon-opacity": [
@ -2391,6 +2567,8 @@ export function Map3D({
"rgba(14,234,255,1)", "rgba(14,234,255,1)",
["==", ["get", "highlighted"], 1], ["==", ["get", "highlighted"], 1],
"rgba(245,158,11,1)", "rgba(245,158,11,1)",
["==", ["get", "permitted"], 1],
"rgba(125,211,252,1)",
["coalesce", ["get", "shipColor"], "#64748b"], ["coalesce", ["get", "shipColor"], "#64748b"],
] as never, ] as never,
); );
@ -2415,6 +2593,8 @@ export function Map3D({
"rgba(14,234,255,0.68)", "rgba(14,234,255,0.68)",
["==", ["get", "highlighted"], 1], ["==", ["get", "highlighted"], 1],
"rgba(245,158,11,0.72)", "rgba(245,158,11,0.72)",
["==", ["get", "permitted"], 1],
"rgba(125,211,252,0.58)",
"rgba(15,23,42,0.25)", "rgba(15,23,42,0.25)",
] as never, ] as never,
); );
@ -2436,8 +2616,7 @@ export function Map3D({
} }
// Selection and highlight are now source-data driven. // Selection and highlight are now source-data driven.
bringShipLayersToFront(); reorderGlobeFeatureLayers();
reorderGlobeFeatureLayers({ shipTop: true });
kickRepaint(map); kickRepaint(map);
}; };
@ -2448,7 +2627,7 @@ export function Map3D({
}, [ }, [
projection, projection,
settings.showShips, settings.showShips,
targets, shipData,
legacyHits, legacyHits,
selectedMmsi, selectedMmsi,
hoveredMmsiSetRef, hoveredMmsiSetRef,
@ -2572,7 +2751,6 @@ export function Map3D({
bMmsi: p.bMmsi, bMmsi: p.bMmsi,
distanceNm: p.distanceNm, distanceNm: p.distanceNm,
warn: p.warn, warn: p.warn,
highlighted: isHighlightedPair(p.aMmsi, p.bMmsi) ? 1 : 0,
}, },
})), })),
}; };
@ -2643,9 +2821,6 @@ export function Map3D({
overlays.pairLines, overlays.pairLines,
pairLinks, pairLinks,
mapSyncEpoch, mapSyncEpoch,
hoveredShipSignature,
hoveredPairSignature,
isHighlightedPair,
reorderGlobeFeatureLayers, reorderGlobeFeatureLayers,
]); ]);
@ -2693,7 +2868,6 @@ export function Map3D({
distanceNm: s.distanceNm, distanceNm: s.distanceNm,
fcMmsi: s.fromMmsi ?? -1, fcMmsi: s.fromMmsi ?? -1,
otherMmsi: s.toMmsi ?? -1, otherMmsi: s.toMmsi ?? -1,
highlighted: s.fromMmsi != null && isHighlightedMmsi(s.fromMmsi) ? 1 : 0,
}, },
})), })),
}; };
@ -2757,8 +2931,6 @@ export function Map3D({
overlays.fcLines, overlays.fcLines,
fcLinks, fcLinks,
mapSyncEpoch, mapSyncEpoch,
hoveredShipSignature,
isHighlightedMmsi,
reorderGlobeFeatureLayers, reorderGlobeFeatureLayers,
]); ]);
@ -2806,10 +2978,10 @@ export function Map3D({
ownerLabel: c.ownerLabel, ownerLabel: c.ownerLabel,
count: c.count, count: c.count,
vesselMmsis: c.vesselMmsis, vesselMmsis: c.vesselMmsis,
highlighted: // Kept for backward compatibility with existing paint expressions.
isHighlightedFleet(c.ownerKey, c.vesselMmsis) || c.vesselMmsis.some((m) => isHighlightedMmsi(m)) // Actual hover-state highlighting is now handled in
? 1 // updateGlobeOverlayPaintStates.
: 0, highlighted: 0,
}, },
}; };
}), }),
@ -2829,10 +3001,10 @@ export function Map3D({
ownerLabel: c.ownerLabel, ownerLabel: c.ownerLabel,
count: c.count, count: c.count,
vesselMmsis: c.vesselMmsis, vesselMmsis: c.vesselMmsis,
highlighted: // Kept for backward compatibility with existing paint expressions.
isHighlightedFleet(c.ownerKey, c.vesselMmsis) || c.vesselMmsis.some((m) => isHighlightedMmsi(m)) // Actual hover-state highlighting is now handled in
? 1 // updateGlobeOverlayPaintStates.
: 0, highlighted: 0,
}, },
}; };
}), }),
@ -2930,11 +3102,6 @@ export function Map3D({
overlays.fleetCircles, overlays.fleetCircles,
fleetCircles, fleetCircles,
mapSyncEpoch, mapSyncEpoch,
hoveredShipSignature,
hoveredFleetSignature,
hoveredFleetOwnerKey,
isHighlightedFleet,
isHighlightedMmsi,
reorderGlobeFeatureLayers, reorderGlobeFeatureLayers,
]); ]);
@ -2992,7 +3159,10 @@ export function Map3D({
aMmsi: c.aMmsi, aMmsi: c.aMmsi,
bMmsi: c.bMmsi, bMmsi: c.bMmsi,
distanceNm: c.distanceNm, distanceNm: c.distanceNm,
highlighted: isHighlightedPair(c.aMmsi, c.bMmsi) ? 1 : 0, // Kept for backward compatibility with existing paint expressions.
// Actual hover-state highlighting is now handled in
// updateGlobeOverlayPaintStates.
highlighted: 0,
}, },
}; };
}), }),
@ -3056,22 +3226,148 @@ export function Map3D({
overlays.pairRange, overlays.pairRange,
pairLinks, pairLinks,
mapSyncEpoch, mapSyncEpoch,
hoveredShipSignature,
hoveredPairSignature,
hoveredFleetSignature,
isHighlightedPair,
reorderGlobeFeatureLayers, reorderGlobeFeatureLayers,
]); ]);
const shipData = useMemo(() => { const updateGlobeOverlayPaintStates = useCallback(() => {
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); if (projection !== "globe" || projectionBusyRef.current) return;
}, [targets]);
const shipByMmsi = useMemo(() => { const map = mapRef.current;
const byMmsi = new Map<number, AisTarget>(); if (!map || !map.isStyleLoaded()) return;
for (const t of shipData) byMmsi.set(t.mmsi, t);
return byMmsi; const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
}, [shipData]);
const pairHighlightExpr = hoveredPairMmsiList.length >= 2
? makeMmsiPairHighlightExpr("aMmsi", "bMmsi", hoveredPairMmsiList)
: false;
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
? makeMmsiAnyEndpointExpr("fcMmsi", "otherMmsi", fleetAwarePairMmsiList)
: false;
const fleetOwnerMatchExpr =
hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
const fleetMemberExpr =
hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
const fleetHighlightExpr =
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
? (["any", fleetOwnerMatchExpr, fleetMemberExpr] as never)
: false;
try {
if (map.getLayer("pair-lines-ml")) {
map.setPaintProperty(
"pair-lines-ml",
"line-color",
["case", pairHighlightExpr, "rgba(245,158,11,0.98)", ["boolean", ["get", "warn"], false], "rgba(59,130,246,0.55)", "rgba(59,130,246,0.55)"] as never,
);
map.setPaintProperty(
"pair-lines-ml",
"line-width",
["case", pairHighlightExpr, 2.8, ["boolean", ["get", "warn"], false], 2.2, 1.4] as never,
);
}
} catch {
// ignore
}
try {
if (map.getLayer("fc-lines-ml")) {
map.setPaintProperty(
"fc-lines-ml",
"line-color",
["case", fcEndpointHighlightExpr, "rgba(245,158,11,0.98)", ["boolean", ["get", "suspicious"], false], "rgba(239,68,68,0.95)", "rgba(217,119,6,0.92)"] as never,
);
map.setPaintProperty(
"fc-lines-ml",
"line-width",
["case", fcEndpointHighlightExpr, 2.0, 1.3] as never,
);
}
} catch {
// ignore
}
try {
if (map.getLayer("pair-range-ml")) {
map.setPaintProperty(
"pair-range-ml",
"line-color",
[
"case",
pairHighlightExpr,
"rgba(245,158,11,0.92)",
["boolean", ["get", "warn"], false],
"rgba(245,158,11,0.75)",
"rgba(59,130,246,0.45)",
] as never,
);
map.setPaintProperty(
"pair-range-ml",
"line-width",
["case", pairHighlightExpr, 1.6, 1.0] as never,
);
}
} catch {
// ignore
}
try {
if (map.getLayer("fleet-circles-ml-fill")) {
map.setPaintProperty(
"fleet-circles-ml-fill",
"fill-color",
[
"case",
fleetHighlightExpr,
"rgba(245,158,11,0.24)",
"rgba(245,158,11,0.02)",
] as never,
);
map.setPaintProperty(
"fleet-circles-ml-fill",
"fill-opacity",
["case", fleetHighlightExpr, 0.7, 0.28] as never,
);
}
if (map.getLayer("fleet-circles-ml")) {
map.setPaintProperty(
"fleet-circles-ml",
"line-color",
["case", fleetHighlightExpr, "rgba(245,158,11,0.95)", "rgba(245,158,11,0.65)"] as never,
);
map.setPaintProperty(
"fleet-circles-ml",
"line-width",
["case", fleetHighlightExpr, 2, 1.1] as never,
);
}
} catch {
// ignore
}
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const stop = onMapStyleReady(map, updateGlobeOverlayPaintStates);
updateGlobeOverlayPaintStates();
return () => {
stop();
};
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]);
const shipLayerData = useMemo(() => {
if (shipData.length === 0) return shipData;
const layer = [...shipData];
layer.sort((a, b) => {
const aPriority = a.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(a.mmsi) ? 2 : 0;
const bPriority = b.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(b.mmsi) ? 2 : 0;
if (aPriority !== bPriority) return aPriority - bPriority;
return a.mmsi - b.mmsi;
});
return layer;
}, [shipData, isHighlightedMmsi, selectedMmsi]);
const clearGlobeTooltip = useCallback(() => { const clearGlobeTooltip = useCallback(() => {
if (!mapTooltipRef.current) return; if (!mapTooltipRef.current) return;
@ -3178,15 +3474,15 @@ export function Map3D({
if (!map) return; if (!map) return;
const clearDeckGlobeHoverState = () => { const clearDeckGlobeHoverState = () => {
setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); clearDeckHoverMmsi();
setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); clearDeckHoverPairs();
setHoveredZoneId((prev) => (prev === null ? prev : null)); setHoveredZoneId((prev) => (prev === null ? prev : null));
clearMapFleetHoverState(); clearMapFleetHoverState();
}; };
const resetGlobeHoverStates = () => { const resetGlobeHoverStates = () => {
setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); clearDeckHoverMmsi();
setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); clearDeckHoverPairs();
setHoveredZoneId((prev) => (prev === null ? prev : null)); setHoveredZoneId((prev) => (prev === null ? prev : null));
clearMapFleetHoverState(); clearMapFleetHoverState();
}; };
@ -3287,36 +3583,36 @@ export function Map3D({
if (isShipLayer) { if (isShipLayer) {
const mmsi = toIntMmsi(props.mmsi); const mmsi = toIntMmsi(props.mmsi);
setHoveredDeckMmsiSingle(mmsi); setDeckHoverMmsi(mmsi == null ? [] : [mmsi]);
setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); clearDeckHoverPairs();
clearMapFleetHoverState(); clearMapFleetHoverState();
setHoveredZoneId((prev) => (prev === null ? prev : null)); setHoveredZoneId((prev) => (prev === null ? prev : null));
} else if (isPairLayer) { } else if (isPairLayer) {
const aMmsi = toIntMmsi(props.aMmsi); const aMmsi = toIntMmsi(props.aMmsi);
const bMmsi = toIntMmsi(props.bMmsi); const bMmsi = toIntMmsi(props.bMmsi);
setHoveredDeckPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]); setDeckHoverPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]);
setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); clearDeckHoverMmsi();
clearMapFleetHoverState(); clearMapFleetHoverState();
setHoveredZoneId((prev) => (prev === null ? prev : null)); setHoveredZoneId((prev) => (prev === null ? prev : null));
} else if (isFcLayer) { } else if (isFcLayer) {
const from = toIntMmsi(props.fcMmsi); const from = toIntMmsi(props.fcMmsi);
const to = toIntMmsi(props.otherMmsi); const to = toIntMmsi(props.otherMmsi);
const fromTo = [from, to].filter((v): v is number => v != null); const fromTo = [from, to].filter((v): v is number => v != null);
setHoveredDeckPairs(fromTo); setDeckHoverPairs(fromTo);
setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, fromTo) ? prev : fromTo)); setDeckHoverMmsi(fromTo);
clearMapFleetHoverState(); clearMapFleetHoverState();
setHoveredZoneId((prev) => (prev === null ? prev : null)); setHoveredZoneId((prev) => (prev === null ? prev : null));
} else if (isFleetLayer) { } else if (isFleetLayer) {
const ownerKey = String(props.ownerKey ?? ""); const ownerKey = String(props.ownerKey ?? "");
const list = normalizeMmsiList(props.vesselMmsis); const list = normalizeMmsiList(props.vesselMmsis);
setMapFleetHoverState(ownerKey || null, list); setMapFleetHoverState(ownerKey || null, list);
setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); clearDeckHoverMmsi();
setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); clearDeckHoverPairs();
setHoveredZoneId((prev) => (prev === null ? prev : null)); setHoveredZoneId((prev) => (prev === null ? prev : null));
} else if (isZoneLayer) { } else if (isZoneLayer) {
clearMapFleetHoverState(); clearMapFleetHoverState();
setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); clearDeckHoverMmsi();
setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); clearDeckHoverPairs();
const zoneId = getZoneIdFromProps(props); const zoneId = getZoneIdFromProps(props);
setHoveredZoneId(zoneId || null); setHoveredZoneId(zoneId || null);
} else { } else {
@ -3358,8 +3654,10 @@ export function Map3D({
buildGlobeFeatureTooltip, buildGlobeFeatureTooltip,
clearGlobeTooltip, clearGlobeTooltip,
clearMapFleetHoverState, clearMapFleetHoverState,
setHoveredDeckPairs, clearDeckHoverPairs,
setHoveredDeckMmsiSingle, clearDeckHoverMmsi,
setDeckHoverPairs,
setDeckHoverMmsi,
setMapFleetHoverState, setMapFleetHoverState,
setGlobeTooltip, setGlobeTooltip,
]); ]);
@ -3369,6 +3667,18 @@ export function Map3D({
return shipData.filter((t) => legacyHits.has(t.mmsi)); return shipData.filter((t) => legacyHits.has(t.mmsi));
}, [shipData, legacyHits]); }, [shipData, legacyHits]);
const legacyTargetsOrdered = useMemo(() => {
if (legacyTargets.length === 0) return legacyTargets;
const layer = [...legacyTargets];
layer.sort((a, b) => {
const aPriority = a.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(a.mmsi) ? 2 : 0;
const bPriority = b.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(b.mmsi) ? 2 : 0;
if (aPriority !== bPriority) return aPriority - bPriority;
return a.mmsi - b.mmsi;
});
return layer;
}, [legacyTargets, isHighlightedMmsi, selectedMmsi]);
const fcDashed = useMemo(() => { const fcDashed = useMemo(() => {
const segs: DashSeg[] = []; const segs: DashSeg[] = [];
for (const l of fcLinks || []) { for (const l of fcLinks || []) {
@ -3405,14 +3715,16 @@ export function Map3D({
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map || !fleetFocus) return; if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat))
const [lon, lat] = fleetFocus.center; return;
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return; const lon = fleetFocusLon;
const lat = fleetFocusLat;
const zoom = fleetFocusZoom ?? 10;
const apply = () => { const apply = () => {
map.easeTo({ map.easeTo({
center: [lon, lat], center: [lon, lat],
zoom: fleetFocus.zoom ?? 10, zoom,
duration: 700, duration: 700,
}); });
}; };
@ -3426,7 +3738,7 @@ export function Map3D({
return () => { return () => {
stop(); stop();
}; };
}, [fleetFocus?.id, fleetFocus?.center?.[0], fleetFocus?.center?.[1], fleetFocus?.zoom]); }, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]);
// Update Deck.gl layers // Update Deck.gl layers
useEffect(() => { useEffect(() => {
@ -3452,9 +3764,7 @@ export function Map3D({
const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS; const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS;
const layers = []; const layers = [];
const clearDeckHover = () => { const clearDeckHover = () => {
setHoveredMmsiList([]); touchDeckHoverState(false);
setHoveredDeckPairs([]);
clearMapFleetHoverState();
}; };
const toFleetMmsiList = (value: unknown) => { const toFleetMmsiList = (value: unknown) => {
@ -3488,7 +3798,7 @@ export function Map3D({
layers.push( layers.push(
new HexagonLayer<AisTarget>({ new HexagonLayer<AisTarget>({
id: "density", id: "density",
data: shipData, data: shipLayerData,
pickable: true, pickable: true,
extruded: true, extruded: true,
radius: 2500, radius: 2500,
@ -3525,11 +3835,12 @@ export function Map3D({
clearDeckHover(); clearDeckHover();
return; return;
} }
touchDeckHoverState(true);
const p = info.object as PairRangeCircle; const p = info.object as PairRangeCircle;
const aMmsi = p.aMmsi; const aMmsi = p.aMmsi;
const bMmsi = p.bMmsi; const bMmsi = p.bMmsi;
setHoveredDeckPairs([aMmsi, bMmsi]); setDeckHoverPairs([aMmsi, bMmsi]);
setHoveredMmsiList([aMmsi, bMmsi]); setDeckHoverMmsi([aMmsi, bMmsi]);
clearMapFleetHoverState(); clearMapFleetHoverState();
}, },
onClick: (info) => { onClick: (info) => {
@ -3574,9 +3885,10 @@ export function Map3D({
clearDeckHover(); clearDeckHover();
return; return;
} }
touchDeckHoverState(true);
const obj = info.object as PairLink; const obj = info.object as PairLink;
setHoveredDeckPairs([obj.aMmsi, obj.bMmsi]); setDeckHoverPairs([obj.aMmsi, obj.bMmsi]);
setHoveredMmsiList([obj.aMmsi, obj.bMmsi]); setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]);
clearMapFleetHoverState(); clearMapFleetHoverState();
}, },
onClick: (info) => { onClick: (info) => {
@ -3622,15 +3934,16 @@ export function Map3D({
clearDeckHover(); clearDeckHover();
return; return;
} }
touchDeckHoverState(true);
const obj = info.object as DashSeg; const obj = info.object as DashSeg;
const aMmsi = obj.fromMmsi; const aMmsi = obj.fromMmsi;
const bMmsi = obj.toMmsi; const bMmsi = obj.toMmsi;
if (aMmsi == null || bMmsi == null) { if (aMmsi == null || bMmsi == null) {
setHoveredMmsiList([]); clearDeckHover();
return; return;
} }
setHoveredDeckPairs([aMmsi, bMmsi]); setDeckHoverPairs([aMmsi, bMmsi]);
setHoveredMmsiList([aMmsi, bMmsi]); setDeckHoverMmsi([aMmsi, bMmsi]);
clearMapFleetHoverState(); clearMapFleetHoverState();
}, },
onClick: (info) => { onClick: (info) => {
@ -3679,11 +3992,12 @@ export function Map3D({
clearDeckHover(); clearDeckHover();
return; return;
} }
touchDeckHoverState(true);
const obj = info.object as FleetCircle; const obj = info.object as FleetCircle;
const list = toFleetMmsiList(obj.vesselMmsis); const list = toFleetMmsiList(obj.vesselMmsis);
setMapFleetHoverState(obj.ownerKey || null, list); setMapFleetHoverState(obj.ownerKey || null, list);
setHoveredMmsiList(list); setDeckHoverMmsi(list);
setHoveredDeckPairs([]); clearDeckHoverPairs();
}, },
onClick: (info) => { onClick: (info) => {
if (!info.object) return; if (!info.object) return;
@ -3735,7 +4049,7 @@ export function Map3D({
layers.push( layers.push(
new ScatterplotLayer<AisTarget>({ new ScatterplotLayer<AisTarget>({
id: "legacy-halo", id: "legacy-halo",
data: legacyTargets, data: legacyTargetsOrdered,
pickable: false, pickable: false,
billboard: false, billboard: false,
// This ring is most prone to z-fighting, so force it into pure painter's-order rendering. // This ring is most prone to z-fighting, so force it into pure painter's-order rendering.
@ -3778,7 +4092,7 @@ export function Map3D({
layers.push( layers.push(
new IconLayer<AisTarget>({ new IconLayer<AisTarget>({
id: "ships", id: "ships",
data: shipData, data: shipLayerData,
pickable: true, pickable: true,
// Keep icons horizontal on the sea surface when view is pitched/rotated. // Keep icons horizontal on the sea surface when view is pitched/rotated.
billboard: false, billboard: false,
@ -3810,9 +4124,10 @@ export function Map3D({
clearDeckHover(); clearDeckHover();
return; return;
} }
touchDeckHoverState(true);
const obj = info.object as AisTarget; const obj = info.object as AisTarget;
setHoveredMmsiList([obj.mmsi]); setDeckHoverMmsi([obj.mmsi]);
setHoveredDeckPairs([]); clearDeckHoverPairs();
clearMapFleetHoverState(); clearMapFleetHoverState();
}, },
onClick: (info) => { onClick: (info) => {
@ -3969,7 +4284,8 @@ export function Map3D({
applyDeckProps(); applyDeckProps();
}, [ }, [
projection, projection,
shipData, shipLayerData,
legacyTargetsOrdered,
baseMap, baseMap,
zones, zones,
selectedMmsi, selectedMmsi,
@ -3993,12 +4309,18 @@ export function Map3D({
hoveredFleetSignature, hoveredFleetSignature,
hoveredPairSignature, hoveredPairSignature,
hoveredFleetOwnerKey, hoveredFleetOwnerKey,
highlightedMmsiSet, highlightedMmsiSetCombined,
onToggleHighlightMmsi,
isHighlightedMmsi, isHighlightedMmsi,
isHighlightedFleet, isHighlightedFleet,
isHighlightedPair, isHighlightedPair,
setDeckHoverMmsi,
clearDeckHoverMmsi,
setDeckHoverPairs,
clearDeckHoverPairs,
clearMapFleetHoverState, clearMapFleetHoverState,
setMapFleetHoverState, setMapFleetHoverState,
touchDeckHoverState,
ensureMercatorOverlay, ensureMercatorOverlay,
]); ]);

파일 보기

@ -1,17 +1,38 @@
import { VESSEL_TYPES } from "../../entities/vessel/model/meta"; import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
import type { MouseEvent } from "react";
type Props = { type Props = {
vessels: DerivedLegacyVessel[]; vessels: DerivedLegacyVessel[];
selectedMmsi: number | null; selectedMmsi: number | null;
highlightedMmsiSet?: number[];
onToggleHighlightMmsi: (mmsi: number) => void;
onSelectMmsi: (mmsi: number) => void; onSelectMmsi: (mmsi: number) => void;
onHoverMmsi?: (mmsi: number) => void;
onClearHover?: () => void;
};
export function VesselList({
vessels,
selectedMmsi,
highlightedMmsiSet = [],
onToggleHighlightMmsi,
onSelectMmsi,
onHoverMmsi,
onClearHover,
}: Props) {
const handlePrimaryAction = (e: MouseEvent, mmsi: number) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
onToggleHighlightMmsi(mmsi);
return;
}
onSelectMmsi(mmsi);
}; };
function isFiniteNumber(x: unknown): x is number { function isFiniteNumber(x: unknown): x is number {
return typeof x === "number" && Number.isFinite(x); return typeof x === "number" && Number.isFinite(x);
} }
export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) {
const sorted = vessels const sorted = vessels
.slice() .slice()
.sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1)) .sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1))
@ -29,13 +50,15 @@ export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) {
const speedColor = inRange ? "#22C55E" : (v.sog ?? 0) > 5 ? "#3B82F6" : "var(--muted)"; const speedColor = inRange ? "#22C55E" : (v.sog ?? 0) > 5 ? "#3B82F6" : "var(--muted)";
const hasPair = v.pairPermitNo ? "⛓" : ""; const hasPair = v.pairPermitNo ? "⛓" : "";
const sel = selectedMmsi === v.mmsi; const sel = selectedMmsi === v.mmsi;
const hl = highlightedMmsiSet.includes(v.mmsi);
return ( return (
<div <div
key={v.mmsi} key={v.mmsi}
className="vi" className={`vi ${sel ? "sel" : ""} ${hl ? "hl" : ""}`}
onClick={() => onSelectMmsi(v.mmsi)} onClick={(e) => handlePrimaryAction(e, v.mmsi)}
style={sel ? { background: "rgba(59,130,246,.12)", border: "1px solid rgba(59,130,246,.45)" } : undefined} onMouseEnter={() => onHoverMmsi?.(v.mmsi)}
onMouseLeave={() => onClearHover?.()}
title={v.name} title={v.name}
> >
<div className="dot" style={{ background: meta.color, boxShadow: v.state.isFishing ? `0 0 3px ${meta.color}` : undefined }} /> <div className="dot" style={{ background: meta.color, boxShadow: v.state.isFishing ? `0 0 3px ${meta.color}` : undefined }} />
@ -56,4 +79,3 @@ export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) {
</div> </div>
); );
} }