diff --git a/src/features/map/MapSettingsPanel.tsx b/src/features/map/MapSettingsPanel.tsx index d8acc94..c8d992c 100644 --- a/src/features/map/MapSettingsPanel.tsx +++ b/src/features/map/MapSettingsPanel.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { MapStyleSwitcher } from "./MapStyleSwitcher"; import { SegmentedControl } from "./SegmentedControl"; import { NODE_TYPE_FILTER_OPTIONS, type NeighborLinesMode } from "./types"; +import { liveNeighborLines } from "./live-map"; import { Section } from "../../components/DetailPanel"; import { CopyLinkButton } from "../../components/CopyLinkButton"; import { useIsMobile } from "../../hooks/useMediaQuery"; @@ -150,11 +151,13 @@ export function MapSettingsPanel({ onNeighborLinesChange(v as NeighborLinesMode)} className="w-full" + disabledValues={liveMap ? ["on"] : undefined} /> - {neighborLines === "selected" && } + {liveMap &&
{t("map.neighborLinesLiveNote")}
} + {liveNeighborLines(neighborLines, liveMap) === "selected" && }
urlView.flow ?? false); + // what the map draws; the saved choice stays in neighborLines for the panel, storage and links + const shownNeighborLines = liveNeighborLines(neighborLines, packetFlow); // IATA region borders overlay, off by default; seeded URL -> localStorage like the other toggles const [borders, setBorders] = useState(() => urlView.borders ?? localStorage.getItem(MAP_BORDERS_STORAGE_KEY) === "on"); @@ -159,26 +161,26 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp const { data: focusNeighbors } = useQuery({ queryKey: ["node-neighbors", selectedNodeId], queryFn: () => getNodeNeighbors(selectedNodeId!), - enabled: neighborLines === "selected" && !!selectedNodeId, + enabled: shownNeighborLines === "selected" && !!selectedNodeId, staleTime: 30_000, }); // "on" is a pure client-side render over already-loaded nodes (neighborIds ship with every map // page) so it never refetches; "selected" colours the detail edges; "off" short-circuits to none. const neighborEdges = useMemo(() => { - if (neighborLines === "off") return EMPTY_EDGES; - if (neighborLines === "selected") { + if (shownNeighborLines === "off") return EMPTY_EDGES; + if (shownNeighborLines === "selected") { if (!selectedNodeId) return EMPTY_EDGES; return buildFocusedNeighborEdges(nodes.find((n) => n.id === selectedNodeId), focusNeighbors ?? []); } return buildNeighborEdges(nodes, "on", selectedNodeId); - }, [nodes, neighborLines, selectedNodeId, focusNeighbors]); + }, [nodes, shownNeighborLines, selectedNodeId, focusNeighbors]); // With neighbors shown and a node selected, fade every other node (like live mode) to spotlight // the selection and its neighbors. null when there's nothing to focus, so the map stays full-bright. const focusIds = useMemo( - () => (neighborLines === "off" ? null : neighborFocusIds(nodes, selectedNodeId)), - [nodes, neighborLines, selectedNodeId], + () => (shownNeighborLines === "off" ? null : neighborFocusIds(nodes, selectedNodeId)), + [nodes, shownNeighborLines, selectedNodeId], ); // IATA coords to frame: the selection's airports, or every airport for "All". Regions carry no diff --git a/src/features/map/SegmentedControl.tsx b/src/features/map/SegmentedControl.tsx index b06f31b..43f2a54 100644 --- a/src/features/map/SegmentedControl.tsx +++ b/src/features/map/SegmentedControl.tsx @@ -13,11 +13,13 @@ interface SegmentedControlProps { className?: string; // locks the control (e.g. a setting another mode overrides); the value still shows disabled?: boolean; + // locks just these options, e.g. one another mode overrides + disabledValues?: string[]; } // Single-select segmented control; active state uses aria-pressed, not color alone. -export function SegmentedControl({ options, value, onChange, ariaLabel, wrap = false, className, disabled = false }: SegmentedControlProps) { +export function SegmentedControl({ options, value, onChange, ariaLabel, wrap = false, className, disabled = false, disabledValues }: SegmentedControlProps) { const containerCls = wrap ? `flex flex-wrap gap-1 ${className ?? ""}` : `flex bg-bg-raised border border-border rounded-sm overflow-hidden ${className ?? ""}`; @@ -43,9 +45,9 @@ export function SegmentedControl({ options, value, onChange, ariaLabel, wrap = f key={o.value} type="button" aria-pressed={active} - disabled={disabled} + disabled={disabled || disabledValues?.includes(o.value)} onClick={() => onChange(o.value)} - className={`${buttonCls(active)} disabled:cursor-not-allowed`} + className={`${buttonCls(active)} disabled:cursor-not-allowed ${disabledValues?.includes(o.value) ? "opacity-40" : ""}`} > {o.label} diff --git a/src/features/map/live-map.ts b/src/features/map/live-map.ts index 8b08f23..87e715a 100644 --- a/src/features/map/live-map.ts +++ b/src/features/map/live-map.ts @@ -1,6 +1,6 @@ import type { Feature, FeatureCollection, Point } from "geojson"; import type { NodeFeatureProps } from "./node-geojson"; -import { PACKET_FLOW_FLASH_MS } from "./types"; +import { PACKET_FLOW_FLASH_MS, type NeighborLinesMode } from "./types"; // Pure helpers for Live Map's lit nodes and idle dots. No maplibre import, so they stay // unit-testable; useMapPacketFlow owns the layers and the animation loop. @@ -90,3 +90,8 @@ export function withSelectedNode( const selected = all.features.find((f) => f.properties.id === selectedId); return selected ? { ...filtered, features: [...filtered.features, selected] } : filtered; } + +// Every repeater's neighbour lines would bury the packets, so Live shows only the selected node's. +export function liveNeighborLines(mode: NeighborLinesMode, live: boolean): NeighborLinesMode { + return live && mode === "on" ? "selected" : mode; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 546ce2e..a183f5a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -608,6 +608,7 @@ "clusteringLiveNote": "Off while Live Map is on", "neighborLines": "Neighbor Lines", "neighborLinesLabel": "Neighbor lines", + "neighborLinesLiveNote": "All lines are off while Live Map is on", "areaBorders": "Area Borders", "areaBordersLabel": "Area borders", "all": "All", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5af7880..6525e89 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -588,6 +588,7 @@ "clusteringLiveNote": "Désactivé tant que la carte en direct est active", "neighborLines": "Liens de voisinage", "neighborLinesLabel": "Liens de voisinage", + "neighborLinesLiveNote": "Toutes les lignes sont désactivées tant que la carte en direct est active", "areaBorders": "Limites de zone", "areaBordersLabel": "Limites de zone", "all": "Tous", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 880694e..1472f14 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -573,6 +573,7 @@ "clusteringLiveNote": "Av medan livekartan är på", "neighborLines": "Grannlinjer", "neighborLinesLabel": "Grannlinjer", + "neighborLinesLiveNote": "Alla linjer är av medan livekartan är på", "areaBorders": "Områdesgränser", "areaBordersLabel": "Områdesgränser", "all": "Alla", diff --git a/tests/features/map/MapSettingsPanel.test.tsx b/tests/features/map/MapSettingsPanel.test.tsx index 43ffbc1..64a0576 100644 --- a/tests/features/map/MapSettingsPanel.test.tsx +++ b/tests/features/map/MapSettingsPanel.test.tsx @@ -47,4 +47,24 @@ describe("map settings", () => { fireEvent.click(within(group).getByRole("button", { name: "On" })); expect(onClusteredChange).not.toHaveBeenCalled(); }); + + it("disables all-node neighbour lines with a note while Live Map is on", async () => { + await i18n.changeLanguage("en"); + const onNeighborLinesChange = vi.fn(); + render( + {}} typeFilter="" onTypeChange={() => {}} + clustered onClusteredChange={() => {}} neighborLines="on" onNeighborLinesChange={onNeighborLinesChange} + borders onBordersChange={() => {}} buildShareParams={() => ({})} liveMap />, + ); + const group = screen.getByRole("group", { name: "Neighbor lines" }); + const on = within(group).getByRole("button", { name: "On" }); + expect(on).toBeDisabled(); + expect(within(group).getByRole("button", { name: "Selected" })).toHaveAttribute("aria-pressed", "true"); + expect(within(group).getByRole("button", { name: "Off" })).toBeEnabled(); + expect(screen.getByText("All lines are off while Live Map is on")).toBeInTheDocument(); + fireEvent.click(on); + expect(onNeighborLinesChange).not.toHaveBeenCalled(); + fireEvent.click(within(group).getByRole("button", { name: "Off" })); + expect(onNeighborLinesChange).toHaveBeenCalledWith("off"); + }); }); diff --git a/tests/features/map/live-map.test.ts b/tests/features/map/live-map.test.ts index f00a68f..0cf9efe 100644 --- a/tests/features/map/live-map.test.ts +++ b/tests/features/map/live-map.test.ts @@ -7,6 +7,7 @@ import { litFeatureCollection, buildLiveNodeIndex, withSelectedNode, + liveNeighborLines, type LitEntry, } from "../../../src/features/map/live-map"; @@ -94,3 +95,14 @@ describe("withSelectedNode", () => { expect(withSelectedNode(filtered, base, "gone")).toBe(filtered); }); }); + +describe("liveNeighborLines", () => { + it("turns all-node lines into selected-node lines while Live is on", () => { + expect(liveNeighborLines("on", true)).toBe("selected"); + expect(liveNeighborLines("selected", true)).toBe("selected"); + expect(liveNeighborLines("off", true)).toBe("off"); + }); + it("leaves the setting alone when Live is off", () => { + expect(liveNeighborLines("on", false)).toBe("on"); + }); +});