mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 14:11:24 +00:00
Live Map: hide all-node neighbour lines while Live is on (#172)
This commit is contained in:
1 parent
79f05a2ebd
commit
bd5b8951e4
9 files changed
+60
-13
No files matched your search
@@ -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({
|
||||
<SegmentedControl
|
||||
ariaLabel={t("map.neighborLinesLabel")}
|
||||
options={neighborOptions}
|
||||
value={neighborLines}
|
||||
value={liveNeighborLines(neighborLines, liveMap)}
|
||||
onChange={(v) => onNeighborLinesChange(v as NeighborLinesMode)}
|
||||
className="w-full"
|
||||
disabledValues={liveMap ? ["on"] : undefined}
|
||||
/>
|
||||
{neighborLines === "selected" && <NeighborLegend />}
|
||||
{liveMap && <div className="text-[9px] text-text-dim mt-1">{t("map.neighborLinesLiveNote")}</div>}
|
||||
{liveNeighborLines(neighborLines, liveMap) === "selected" && <NeighborLegend />}
|
||||
</Section>
|
||||
<Section title={t("map.areaBorders")}>
|
||||
<SegmentedControl
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useMapPacketFlow } from "./useMapPacketFlow";
|
||||
import { PacketFlowButton } from "./PacketFlowButton";
|
||||
import { useMapNodesData } from "./useMapNodesData";
|
||||
import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, buildFocusedNeighborEdges, neighborFocusIds, type NeighborEdgeProps } from "./node-geojson";
|
||||
import { buildLiveNodeIndex, idleDotTypes, withSelectedNode } from "./live-map";
|
||||
import { buildLiveNodeIndex, idleDotTypes, withSelectedNode, liveNeighborLines } from "./live-map";
|
||||
import { MapSettingsPanel } from "./MapSettingsPanel";
|
||||
import { parseMapView, parseNodeType, buildMapParams, type MapViewSnapshot } from "./map-url";
|
||||
import { MAP_STYLE_STORAGE_KEY, DEFAULT_STYLE_ID, resolveMapStyle, MAP_NEIGHBOR_LINES_STORAGE_KEY, MAP_CLUSTER_STORAGE_KEY, MAP_NODE_TYPE_STORAGE_KEY, MAP_BORDERS_STORAGE_KEY, DEFAULT_CENTER, DEFAULT_ZOOM, type NeighborLinesMode } from "./types";
|
||||
@@ -92,6 +92,8 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp
|
||||
|
||||
// live packet-flow animation: opt-in per session (off by default, not persisted; a deep link can seed it)
|
||||
const [packetFlow, setPacketFlow] = useState(() => 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
|
||||
|
||||
@@ -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}
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
<MapSettingsPanel styleId="dark" onStyleChange={() => {}} 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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user