feat(map3d): stabilize globe overlays and hover-highlight sync
This commit is contained in:
부모
b944887430
커밋
05b0c6b881
@ -188,12 +188,17 @@ export function computeFleetCircles(vessels: DerivedLegacyVessel[]): FleetCircle
|
||||
const out: FleetCircle[] = [];
|
||||
for (const [ownerKey, vs] of groups.entries()) {
|
||||
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 lat = vs.reduce((sum, v) => sum + v.lat, 0) / vs.length;
|
||||
let radiusNm = 0;
|
||||
for (const v of vs) radiusNm = Math.max(radiusNm, haversineNm(lat, lon, v.lat, v.lon));
|
||||
out.push({
|
||||
ownerKey,
|
||||
ownerLabel,
|
||||
center: [lon, lat],
|
||||
radiusNm: Math.max(0.2, radiusNm),
|
||||
count: vs.length,
|
||||
|
||||
@ -56,6 +56,7 @@ export type FcLink = {
|
||||
|
||||
export type FleetCircle = {
|
||||
ownerKey: string;
|
||||
ownerLabel: string;
|
||||
center: [number, number];
|
||||
radiusNm: number;
|
||||
count: number;
|
||||
@ -71,4 +72,3 @@ export type LegacyAlarm = {
|
||||
text: string;
|
||||
relatedMmsi: number[];
|
||||
};
|
||||
|
||||
|
||||
@ -610,6 +610,10 @@ export function DashboardPage() {
|
||||
fleetCircles={fleetCirclesForMap}
|
||||
fleetFocus={fleetFocus}
|
||||
onProjectionLoadingChange={setIsProjectionLoading}
|
||||
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
||||
onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))}
|
||||
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
||||
onClearPairHover={() => setHoveredPairMmsiSet([])}
|
||||
onHoverFleet={(ownerKey, fleetMmsis) => {
|
||||
setHoveredFleetOwnerKey(ownerKey);
|
||||
setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis));
|
||||
|
||||
@ -56,6 +56,10 @@ type Props = {
|
||||
};
|
||||
onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void;
|
||||
onClearFleetHover?: () => void;
|
||||
onHoverMmsi?: (mmsiList: number[]) => void;
|
||||
onClearMmsiHover?: () => void;
|
||||
onHoverPair?: (mmsiList: number[]) => void;
|
||||
onClearPairHover?: () => void;
|
||||
};
|
||||
|
||||
function toNumberSet(values: number[] | undefined | null) {
|
||||
@ -141,6 +145,49 @@ function makeFleetCircleFeatureId(ownerKey: string) {
|
||||
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 = {
|
||||
ship: {
|
||||
x: 0,
|
||||
@ -922,7 +969,37 @@ type PairRangeCircle = {
|
||||
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[]) => {
|
||||
if (a.length !== b.length) return false;
|
||||
@ -959,6 +1036,10 @@ export function Map3D({
|
||||
fleetFocus,
|
||||
onHoverFleet,
|
||||
onClearFleetHover,
|
||||
onHoverMmsi,
|
||||
onClearMmsiHover,
|
||||
onHoverPair,
|
||||
onClearPairHover,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | 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 projectionPrevRef = useRef<MapProjectionId>(projection);
|
||||
const mapTooltipRef = useRef<maplibregl.Popup | null>(null);
|
||||
const deckHoverRafRef = useRef<number | null>(null);
|
||||
const deckHoverHasHitRef = useRef(false);
|
||||
const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState<string | null>(null);
|
||||
@ -991,13 +1074,16 @@ export function Map3D({
|
||||
if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey);
|
||||
return keys;
|
||||
}, [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;
|
||||
if (!map || projectionRef.current !== "globe") return;
|
||||
if (projectionBusyRef.current) return;
|
||||
|
||||
const shipTop = options?.shipTop === true;
|
||||
const ordering = [
|
||||
"zones-fill",
|
||||
"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);
|
||||
}, []);
|
||||
|
||||
@ -1078,6 +1153,9 @@ export function Map3D({
|
||||
[effectiveHoveredFleetMmsiSet, hoveredFleetOwnerKeys],
|
||||
);
|
||||
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(
|
||||
(mmsi: number) => highlightedMmsiSetCombined.has(mmsi),
|
||||
@ -1100,6 +1178,16 @@ export function Map3D({
|
||||
[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?: {
|
||||
shiftKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
@ -1109,21 +1197,6 @@ export function Map3D({
|
||||
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 normalized = makeUniqueSorted(next);
|
||||
setHoveredDeckFleetMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
|
||||
@ -1135,6 +1208,12 @@ export function Map3D({
|
||||
|
||||
const onHoverFleetRef = useRef(onHoverFleet);
|
||||
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<{
|
||||
ownerKey: string | null;
|
||||
vesselMmsis: number[];
|
||||
@ -1143,22 +1222,11 @@ export function Map3D({
|
||||
useEffect(() => {
|
||||
onHoverFleetRef.current = onHoverFleet;
|
||||
onClearFleetHoverRef.current = onClearFleetHover;
|
||||
}, [onHoverFleet, onClearFleetHover]);
|
||||
|
||||
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;
|
||||
}
|
||||
setHoveredDeckFleetOwner(ownerKey);
|
||||
setHoveredDeckFleetMmsis(normalized);
|
||||
mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized };
|
||||
onHoverFleetRef.current?.(ownerKey, normalized);
|
||||
},
|
||||
[setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis],
|
||||
);
|
||||
onHoverMmsiRef.current = onHoverMmsi;
|
||||
onClearMmsiHoverRef.current = onClearMmsiHover;
|
||||
onHoverPairRef.current = onHoverPair;
|
||||
onClearPairHoverRef.current = onClearPairHover;
|
||||
}, [onHoverFleet, onClearFleetHover, onHoverMmsi, onClearMmsiHover, onHoverPair, onClearPairHover]);
|
||||
|
||||
const clearMapFleetHoverState = useCallback(() => {
|
||||
const nextOwner = null;
|
||||
@ -1172,6 +1240,97 @@ export function Map3D({
|
||||
}
|
||||
}, [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(() => {
|
||||
mapFleetHoverStateRef.current = {
|
||||
ownerKey: hoveredFleetOwnerKey,
|
||||
@ -2155,19 +2314,7 @@ export function Map3D({
|
||||
|
||||
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 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)) {
|
||||
try {
|
||||
@ -2181,10 +2328,12 @@ export function Map3D({
|
||||
"circle-sort-key": [
|
||||
"case",
|
||||
["==", ["get", "selected"], 1],
|
||||
30,
|
||||
90,
|
||||
["==", ["get", "highlighted"], 1],
|
||||
25,
|
||||
10,
|
||||
80,
|
||||
["==", ["get", "permitted"], 1],
|
||||
60,
|
||||
20,
|
||||
] as never,
|
||||
},
|
||||
paint: {
|
||||
@ -2208,7 +2357,20 @@ export function Map3D({
|
||||
} else {
|
||||
try {
|
||||
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", [
|
||||
"case",
|
||||
["==", ["get", "selected"], 1],
|
||||
@ -2239,6 +2401,8 @@ export function Map3D({
|
||||
"rgba(14,234,255,0.95)",
|
||||
["==", ["get", "highlighted"], 1],
|
||||
"rgba(245,158,11,0.95)",
|
||||
["==", ["get", "permitted"], 1],
|
||||
"rgba(125,211,252,0.95)",
|
||||
"rgba(59,130,246,0.75)",
|
||||
] as never,
|
||||
"circle-stroke-width": [
|
||||
@ -2247,6 +2411,8 @@ export function Map3D({
|
||||
3.4,
|
||||
["==", ["get", "highlighted"], 1],
|
||||
2.7,
|
||||
["==", ["get", "permitted"], 1],
|
||||
1.8,
|
||||
0.0,
|
||||
] as never,
|
||||
"circle-stroke-opacity": 0.85,
|
||||
@ -2256,10 +2422,12 @@ export function Map3D({
|
||||
"circle-sort-key": [
|
||||
"case",
|
||||
["==", ["get", "selected"], 1],
|
||||
40,
|
||||
100,
|
||||
["==", ["get", "highlighted"], 1],
|
||||
35,
|
||||
15,
|
||||
90,
|
||||
["==", ["get", "permitted"], 1],
|
||||
70,
|
||||
30,
|
||||
] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
@ -2280,6 +2448,8 @@ export function Map3D({
|
||||
"rgba(14,234,255,0.95)",
|
||||
["==", ["get", "highlighted"], 1],
|
||||
"rgba(245,158,11,0.95)",
|
||||
["==", ["get", "permitted"], 1],
|
||||
"rgba(125,211,252,0.95)",
|
||||
"rgba(59,130,246,0.75)",
|
||||
] as never,
|
||||
);
|
||||
@ -2292,6 +2462,8 @@ export function Map3D({
|
||||
3.4,
|
||||
["==", ["get", "highlighted"], 1],
|
||||
2.7,
|
||||
["==", ["get", "permitted"], 1],
|
||||
1.8,
|
||||
0.0,
|
||||
] as never,
|
||||
);
|
||||
@ -2312,10 +2484,12 @@ export function Map3D({
|
||||
"symbol-sort-key": [
|
||||
"case",
|
||||
["==", ["get", "selected"], 1],
|
||||
50,
|
||||
95,
|
||||
["==", ["get", "highlighted"], 1],
|
||||
85,
|
||||
["==", ["get", "permitted"], 1],
|
||||
65,
|
||||
45,
|
||||
20,
|
||||
] as never,
|
||||
"icon-image": imgId,
|
||||
"icon-size": [
|
||||
@ -2346,6 +2520,8 @@ export function Map3D({
|
||||
"rgba(14,234,255,1)",
|
||||
["==", ["get", "highlighted"], 1],
|
||||
"rgba(245,158,11,1)",
|
||||
["==", ["get", "permitted"], 1],
|
||||
"rgba(125,211,252,1)",
|
||||
"rgba(59,130,246,1)",
|
||||
] as never,
|
||||
"icon-opacity": [
|
||||
@ -2391,6 +2567,8 @@ export function Map3D({
|
||||
"rgba(14,234,255,1)",
|
||||
["==", ["get", "highlighted"], 1],
|
||||
"rgba(245,158,11,1)",
|
||||
["==", ["get", "permitted"], 1],
|
||||
"rgba(125,211,252,1)",
|
||||
["coalesce", ["get", "shipColor"], "#64748b"],
|
||||
] as never,
|
||||
);
|
||||
@ -2415,6 +2593,8 @@ export function Map3D({
|
||||
"rgba(14,234,255,0.68)",
|
||||
["==", ["get", "highlighted"], 1],
|
||||
"rgba(245,158,11,0.72)",
|
||||
["==", ["get", "permitted"], 1],
|
||||
"rgba(125,211,252,0.58)",
|
||||
"rgba(15,23,42,0.25)",
|
||||
] as never,
|
||||
);
|
||||
@ -2436,8 +2616,7 @@ export function Map3D({
|
||||
}
|
||||
|
||||
// Selection and highlight are now source-data driven.
|
||||
bringShipLayersToFront();
|
||||
reorderGlobeFeatureLayers({ shipTop: true });
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
@ -2448,7 +2627,7 @@ export function Map3D({
|
||||
}, [
|
||||
projection,
|
||||
settings.showShips,
|
||||
targets,
|
||||
shipData,
|
||||
legacyHits,
|
||||
selectedMmsi,
|
||||
hoveredMmsiSetRef,
|
||||
@ -2572,7 +2751,6 @@ export function Map3D({
|
||||
bMmsi: p.bMmsi,
|
||||
distanceNm: p.distanceNm,
|
||||
warn: p.warn,
|
||||
highlighted: isHighlightedPair(p.aMmsi, p.bMmsi) ? 1 : 0,
|
||||
},
|
||||
})),
|
||||
};
|
||||
@ -2643,9 +2821,6 @@ export function Map3D({
|
||||
overlays.pairLines,
|
||||
pairLinks,
|
||||
mapSyncEpoch,
|
||||
hoveredShipSignature,
|
||||
hoveredPairSignature,
|
||||
isHighlightedPair,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
|
||||
@ -2693,7 +2868,6 @@ export function Map3D({
|
||||
distanceNm: s.distanceNm,
|
||||
fcMmsi: s.fromMmsi ?? -1,
|
||||
otherMmsi: s.toMmsi ?? -1,
|
||||
highlighted: s.fromMmsi != null && isHighlightedMmsi(s.fromMmsi) ? 1 : 0,
|
||||
},
|
||||
})),
|
||||
};
|
||||
@ -2757,8 +2931,6 @@ export function Map3D({
|
||||
overlays.fcLines,
|
||||
fcLinks,
|
||||
mapSyncEpoch,
|
||||
hoveredShipSignature,
|
||||
isHighlightedMmsi,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
|
||||
@ -2806,10 +2978,10 @@ export function Map3D({
|
||||
ownerLabel: c.ownerLabel,
|
||||
count: c.count,
|
||||
vesselMmsis: c.vesselMmsis,
|
||||
highlighted:
|
||||
isHighlightedFleet(c.ownerKey, c.vesselMmsis) || c.vesselMmsis.some((m) => isHighlightedMmsi(m))
|
||||
? 1
|
||||
: 0,
|
||||
// Kept for backward compatibility with existing paint expressions.
|
||||
// Actual hover-state highlighting is now handled in
|
||||
// updateGlobeOverlayPaintStates.
|
||||
highlighted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
@ -2829,10 +3001,10 @@ export function Map3D({
|
||||
ownerLabel: c.ownerLabel,
|
||||
count: c.count,
|
||||
vesselMmsis: c.vesselMmsis,
|
||||
highlighted:
|
||||
isHighlightedFleet(c.ownerKey, c.vesselMmsis) || c.vesselMmsis.some((m) => isHighlightedMmsi(m))
|
||||
? 1
|
||||
: 0,
|
||||
// Kept for backward compatibility with existing paint expressions.
|
||||
// Actual hover-state highlighting is now handled in
|
||||
// updateGlobeOverlayPaintStates.
|
||||
highlighted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
@ -2930,11 +3102,6 @@ export function Map3D({
|
||||
overlays.fleetCircles,
|
||||
fleetCircles,
|
||||
mapSyncEpoch,
|
||||
hoveredShipSignature,
|
||||
hoveredFleetSignature,
|
||||
hoveredFleetOwnerKey,
|
||||
isHighlightedFleet,
|
||||
isHighlightedMmsi,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
|
||||
@ -2992,7 +3159,10 @@ export function Map3D({
|
||||
aMmsi: c.aMmsi,
|
||||
bMmsi: c.bMmsi,
|
||||
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,
|
||||
pairLinks,
|
||||
mapSyncEpoch,
|
||||
hoveredShipSignature,
|
||||
hoveredPairSignature,
|
||||
hoveredFleetSignature,
|
||||
isHighlightedPair,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
|
||||
const shipData = useMemo(() => {
|
||||
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi));
|
||||
}, [targets]);
|
||||
const updateGlobeOverlayPaintStates = useCallback(() => {
|
||||
if (projection !== "globe" || projectionBusyRef.current) return;
|
||||
|
||||
const shipByMmsi = useMemo(() => {
|
||||
const byMmsi = new Map<number, AisTarget>();
|
||||
for (const t of shipData) byMmsi.set(t.mmsi, t);
|
||||
return byMmsi;
|
||||
}, [shipData]);
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
|
||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||
|
||||
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(() => {
|
||||
if (!mapTooltipRef.current) return;
|
||||
@ -3178,15 +3474,15 @@ export function Map3D({
|
||||
if (!map) return;
|
||||
|
||||
const clearDeckGlobeHoverState = () => {
|
||||
setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
clearDeckHoverMmsi();
|
||||
clearDeckHoverPairs();
|
||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||
clearMapFleetHoverState();
|
||||
};
|
||||
|
||||
const resetGlobeHoverStates = () => {
|
||||
setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
clearDeckHoverMmsi();
|
||||
clearDeckHoverPairs();
|
||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||
clearMapFleetHoverState();
|
||||
};
|
||||
@ -3287,36 +3583,36 @@ export function Map3D({
|
||||
|
||||
if (isShipLayer) {
|
||||
const mmsi = toIntMmsi(props.mmsi);
|
||||
setHoveredDeckMmsiSingle(mmsi);
|
||||
setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
setDeckHoverMmsi(mmsi == null ? [] : [mmsi]);
|
||||
clearDeckHoverPairs();
|
||||
clearMapFleetHoverState();
|
||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||
} else if (isPairLayer) {
|
||||
const aMmsi = toIntMmsi(props.aMmsi);
|
||||
const bMmsi = toIntMmsi(props.bMmsi);
|
||||
setHoveredDeckPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]);
|
||||
setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
setDeckHoverPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]);
|
||||
clearDeckHoverMmsi();
|
||||
clearMapFleetHoverState();
|
||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||
} else if (isFcLayer) {
|
||||
const from = toIntMmsi(props.fcMmsi);
|
||||
const to = toIntMmsi(props.otherMmsi);
|
||||
const fromTo = [from, to].filter((v): v is number => v != null);
|
||||
setHoveredDeckPairs(fromTo);
|
||||
setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, fromTo) ? prev : fromTo));
|
||||
setDeckHoverPairs(fromTo);
|
||||
setDeckHoverMmsi(fromTo);
|
||||
clearMapFleetHoverState();
|
||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||
} else if (isFleetLayer) {
|
||||
const ownerKey = String(props.ownerKey ?? "");
|
||||
const list = normalizeMmsiList(props.vesselMmsis);
|
||||
setMapFleetHoverState(ownerKey || null, list);
|
||||
setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
clearDeckHoverMmsi();
|
||||
clearDeckHoverPairs();
|
||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||
} else if (isZoneLayer) {
|
||||
clearMapFleetHoverState();
|
||||
setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
clearDeckHoverMmsi();
|
||||
clearDeckHoverPairs();
|
||||
const zoneId = getZoneIdFromProps(props);
|
||||
setHoveredZoneId(zoneId || null);
|
||||
} else {
|
||||
@ -3358,8 +3654,10 @@ export function Map3D({
|
||||
buildGlobeFeatureTooltip,
|
||||
clearGlobeTooltip,
|
||||
clearMapFleetHoverState,
|
||||
setHoveredDeckPairs,
|
||||
setHoveredDeckMmsiSingle,
|
||||
clearDeckHoverPairs,
|
||||
clearDeckHoverMmsi,
|
||||
setDeckHoverPairs,
|
||||
setDeckHoverMmsi,
|
||||
setMapFleetHoverState,
|
||||
setGlobeTooltip,
|
||||
]);
|
||||
@ -3369,6 +3667,18 @@ export function Map3D({
|
||||
return shipData.filter((t) => legacyHits.has(t.mmsi));
|
||||
}, [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 segs: DashSeg[] = [];
|
||||
for (const l of fcLinks || []) {
|
||||
@ -3405,14 +3715,16 @@ export function Map3D({
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !fleetFocus) return;
|
||||
const [lon, lat] = fleetFocus.center;
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return;
|
||||
if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat))
|
||||
return;
|
||||
const lon = fleetFocusLon;
|
||||
const lat = fleetFocusLat;
|
||||
const zoom = fleetFocusZoom ?? 10;
|
||||
|
||||
const apply = () => {
|
||||
map.easeTo({
|
||||
center: [lon, lat],
|
||||
zoom: fleetFocus.zoom ?? 10,
|
||||
zoom,
|
||||
duration: 700,
|
||||
});
|
||||
};
|
||||
@ -3426,7 +3738,7 @@ export function Map3D({
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [fleetFocus?.id, fleetFocus?.center?.[0], fleetFocus?.center?.[1], fleetFocus?.zoom]);
|
||||
}, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]);
|
||||
|
||||
// Update Deck.gl layers
|
||||
useEffect(() => {
|
||||
@ -3452,9 +3764,7 @@ export function Map3D({
|
||||
const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS;
|
||||
const layers = [];
|
||||
const clearDeckHover = () => {
|
||||
setHoveredMmsiList([]);
|
||||
setHoveredDeckPairs([]);
|
||||
clearMapFleetHoverState();
|
||||
touchDeckHoverState(false);
|
||||
};
|
||||
|
||||
const toFleetMmsiList = (value: unknown) => {
|
||||
@ -3488,7 +3798,7 @@ export function Map3D({
|
||||
layers.push(
|
||||
new HexagonLayer<AisTarget>({
|
||||
id: "density",
|
||||
data: shipData,
|
||||
data: shipLayerData,
|
||||
pickable: true,
|
||||
extruded: true,
|
||||
radius: 2500,
|
||||
@ -3525,11 +3835,12 @@ export function Map3D({
|
||||
clearDeckHover();
|
||||
return;
|
||||
}
|
||||
touchDeckHoverState(true);
|
||||
const p = info.object as PairRangeCircle;
|
||||
const aMmsi = p.aMmsi;
|
||||
const bMmsi = p.bMmsi;
|
||||
setHoveredDeckPairs([aMmsi, bMmsi]);
|
||||
setHoveredMmsiList([aMmsi, bMmsi]);
|
||||
setDeckHoverPairs([aMmsi, bMmsi]);
|
||||
setDeckHoverMmsi([aMmsi, bMmsi]);
|
||||
clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
@ -3574,9 +3885,10 @@ export function Map3D({
|
||||
clearDeckHover();
|
||||
return;
|
||||
}
|
||||
touchDeckHoverState(true);
|
||||
const obj = info.object as PairLink;
|
||||
setHoveredDeckPairs([obj.aMmsi, obj.bMmsi]);
|
||||
setHoveredMmsiList([obj.aMmsi, obj.bMmsi]);
|
||||
setDeckHoverPairs([obj.aMmsi, obj.bMmsi]);
|
||||
setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]);
|
||||
clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
@ -3622,15 +3934,16 @@ export function Map3D({
|
||||
clearDeckHover();
|
||||
return;
|
||||
}
|
||||
touchDeckHoverState(true);
|
||||
const obj = info.object as DashSeg;
|
||||
const aMmsi = obj.fromMmsi;
|
||||
const bMmsi = obj.toMmsi;
|
||||
if (aMmsi == null || bMmsi == null) {
|
||||
setHoveredMmsiList([]);
|
||||
clearDeckHover();
|
||||
return;
|
||||
}
|
||||
setHoveredDeckPairs([aMmsi, bMmsi]);
|
||||
setHoveredMmsiList([aMmsi, bMmsi]);
|
||||
setDeckHoverPairs([aMmsi, bMmsi]);
|
||||
setDeckHoverMmsi([aMmsi, bMmsi]);
|
||||
clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
@ -3679,11 +3992,12 @@ export function Map3D({
|
||||
clearDeckHover();
|
||||
return;
|
||||
}
|
||||
touchDeckHoverState(true);
|
||||
const obj = info.object as FleetCircle;
|
||||
const list = toFleetMmsiList(obj.vesselMmsis);
|
||||
setMapFleetHoverState(obj.ownerKey || null, list);
|
||||
setHoveredMmsiList(list);
|
||||
setHoveredDeckPairs([]);
|
||||
setDeckHoverMmsi(list);
|
||||
clearDeckHoverPairs();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) return;
|
||||
@ -3735,7 +4049,7 @@ export function Map3D({
|
||||
layers.push(
|
||||
new ScatterplotLayer<AisTarget>({
|
||||
id: "legacy-halo",
|
||||
data: legacyTargets,
|
||||
data: legacyTargetsOrdered,
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
// 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(
|
||||
new IconLayer<AisTarget>({
|
||||
id: "ships",
|
||||
data: shipData,
|
||||
data: shipLayerData,
|
||||
pickable: true,
|
||||
// Keep icons horizontal on the sea surface when view is pitched/rotated.
|
||||
billboard: false,
|
||||
@ -3810,9 +4124,10 @@ export function Map3D({
|
||||
clearDeckHover();
|
||||
return;
|
||||
}
|
||||
touchDeckHoverState(true);
|
||||
const obj = info.object as AisTarget;
|
||||
setHoveredMmsiList([obj.mmsi]);
|
||||
setHoveredDeckPairs([]);
|
||||
setDeckHoverMmsi([obj.mmsi]);
|
||||
clearDeckHoverPairs();
|
||||
clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
@ -3969,7 +4284,8 @@ export function Map3D({
|
||||
applyDeckProps();
|
||||
}, [
|
||||
projection,
|
||||
shipData,
|
||||
shipLayerData,
|
||||
legacyTargetsOrdered,
|
||||
baseMap,
|
||||
zones,
|
||||
selectedMmsi,
|
||||
@ -3993,12 +4309,18 @@ export function Map3D({
|
||||
hoveredFleetSignature,
|
||||
hoveredPairSignature,
|
||||
hoveredFleetOwnerKey,
|
||||
highlightedMmsiSet,
|
||||
highlightedMmsiSetCombined,
|
||||
onToggleHighlightMmsi,
|
||||
isHighlightedMmsi,
|
||||
isHighlightedFleet,
|
||||
isHighlightedPair,
|
||||
setDeckHoverMmsi,
|
||||
clearDeckHoverMmsi,
|
||||
setDeckHoverPairs,
|
||||
clearDeckHoverPairs,
|
||||
clearMapFleetHoverState,
|
||||
setMapFleetHoverState,
|
||||
touchDeckHoverState,
|
||||
ensureMercatorOverlay,
|
||||
]);
|
||||
|
||||
|
||||
@ -1,17 +1,38 @@
|
||||
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||
import type { MouseEvent } from "react";
|
||||
|
||||
type Props = {
|
||||
vessels: DerivedLegacyVessel[];
|
||||
selectedMmsi: number | null;
|
||||
highlightedMmsiSet?: number[];
|
||||
onToggleHighlightMmsi: (mmsi: number) => void;
|
||||
onSelectMmsi: (mmsi: number) => void;
|
||||
onHoverMmsi?: (mmsi: number) => void;
|
||||
onClearHover?: () => void;
|
||||
};
|
||||
|
||||
function isFiniteNumber(x: unknown): x is number {
|
||||
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 {
|
||||
return typeof x === "number" && Number.isFinite(x);
|
||||
}
|
||||
|
||||
export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) {
|
||||
const sorted = vessels
|
||||
.slice()
|
||||
.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 hasPair = v.pairPermitNo ? "⛓" : "";
|
||||
const sel = selectedMmsi === v.mmsi;
|
||||
const hl = highlightedMmsiSet.includes(v.mmsi);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={v.mmsi}
|
||||
className="vi"
|
||||
onClick={() => onSelectMmsi(v.mmsi)}
|
||||
style={sel ? { background: "rgba(59,130,246,.12)", border: "1px solid rgba(59,130,246,.45)" } : undefined}
|
||||
className={`vi ${sel ? "sel" : ""} ${hl ? "hl" : ""}`}
|
||||
onClick={(e) => handlePrimaryAction(e, v.mmsi)}
|
||||
onMouseEnter={() => onHoverMmsi?.(v.mmsi)}
|
||||
onMouseLeave={() => onClearHover?.()}
|
||||
title={v.name}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user