feat: extend map targeting and overlay controls

Support batch feature targeting and clear map highlights and overlays from the More panel. Remove misleading action chevrons and unused configuration export entries.

Validated the isolated patch with lint, typecheck, 36 focused tests and 5 browser cases using mocked data.
This commit is contained in:
2026-10-28 14:47:33 +08:00
parent e57e0f099f
commit 5b710d1247
17 changed files with 666 additions and 202 deletions
@@ -1,4 +1,4 @@
import { ChevronRight, type LucideIcon } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import {
@@ -204,9 +204,7 @@ export function MapActionRow({
>
{status}
</span>
) : (
<ChevronRight size={15} aria-hidden="true" className={cn("shrink-0", isPrimary ? "text-[var(--palette-paper)]" : "text-[var(--ink-muted)]")} />
)}
) : null}
</button>
);
}
@@ -1,5 +1,5 @@
import type { LucideIcon } from "lucide-react";
import { ChevronRight, Copy, Ruler, Trash2 } from "lucide-react";
import { Copy, Ruler, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
@@ -276,15 +276,11 @@ function MeasureActionButton({
) : (
<span>{action.label}</span>
)}
{primary ? (
active ? (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-[var(--action-soft)] px-2 py-1 text-[10px] font-semibold text-[var(--action-blue-hover)] ring-1 ring-inset ring-[var(--surface-border)]">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--action-blue)] motion-safe:animate-pulse" aria-hidden="true" />
进行中
</span>
) : (
<ChevronRight size={16} aria-hidden="true" className="shrink-0 text-[var(--ink-muted)]" />
)
{primary && active ? (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-[var(--action-soft)] px-2 py-1 text-[10px] font-semibold text-[var(--action-blue-hover)] ring-1 ring-inset ring-[var(--surface-border)]">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--action-blue)] motion-safe:animate-pulse" aria-hidden="true" />
进行中
</span>
) : null}
</button>
);
@@ -10,6 +10,28 @@ import { MapDevPanel } from "./map-dev-panel";
afterEach(cleanup);
describe("MapDevPanel", () => {
it("deduplicates pasted IDs for every location action and prevents oversized batches", async () => {
const user = userEvent.setup();
const commands = createCommands();
render(<MapDevPanel commands={commands} controllerState={createControllerState()}
detailFeature={null} onClose={vi.fn()} />);
const input = screen.getByLabelText("要素 ID(可多个)");
await user.clear(input);
await user.paste("P-1,P-2\nP-1 P-3;P-4");
const target = { sourceId: "pipes", featureIds: ["P-1", "P-2", "P-3", "P-4"] };
await user.click(screen.getByRole("button", { name: "仅高亮" }));
expect(commands.highlight).toHaveBeenCalledWith(target);
await user.click(screen.getByRole("button", { name: "缩放到要素" }));
expect(commands.zoomToFeature).toHaveBeenCalledWith(target);
await user.click(screen.getByRole("button", { name: "定位并高亮" }));
expect(commands.locateAndHighlight).toHaveBeenCalledWith(target);
await user.clear(input);
expect(screen.getByRole("button", { name: "定位并高亮" })).toBeDisabled();
await user.paste(Array.from({ length: 101 }, (_, i) => `P-${i}`).join(","));
expect(screen.getByRole("alert")).toHaveTextContent("单次最多 100 个");
expect(screen.getByRole("button", { name: "定位并高亮" })).toBeDisabled();
});
it("defaults to following investigations and allows disabling it", async () => {
const user = userEvent.setup();
const onFollowChange = vi.fn();
@@ -109,6 +131,8 @@ function createControllerState(
): WorkbenchMapControllerState {
return {
target: null,
targets: [],
targetResult: null,
valueLabels: {},
pending: false,
errorCode: null,
@@ -5,15 +5,17 @@ import { Checkbox } from "@/shared/ui/checkbox";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import {
LAYER_GROUP_GEOMETRIES,
type LayerGroupId,
type LayerGroupStylePatch
} from "../map/layer-group-style";
import { MAP_STYLE_TOKENS } from "../map/map-colors";
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
import { getNumericFeatureProperties } from "../map/value-label";
import type {
FeatureTarget,
FeatureBatchTarget,
WorkbenchMapCommands,
WorkbenchMapControllerState
} from "../map/workbench-map-controller";
@@ -90,6 +92,7 @@ const LABEL_PRESETS: Record<
const ERROR_LABELS: Record<string, string> = {
MAP_NOT_READY: "地图尚未就绪",
FEATURE_NOT_FOUND: "目标要素不存在",
INVALID_FEATURE_IDS: "请填写有效的要素 ID,单次最多 100 个",
WFS_UNAVAILABLE: "WFS 查询暂不可用",
SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用",
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
@@ -140,7 +143,10 @@ export function MapDevPanel({
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const target: FeatureTarget = { sourceId, featureId: featureId.trim() };
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
const target: FeatureBatchTarget = { sourceId, featureIds };
const invalidIds = featureIds.length > MAX_MAP_FEATURE_IDS || featureIds.some((id) => id.length > MAX_MAP_FEATURE_ID_LENGTH);
const canLocate = featureIds.length > 0 && !invalidIds && !controllerState.pending;
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
const activeLabel = controllerState.valueLabels[groupId];
const hasActiveLabels = Object.keys(controllerState.valueLabels).length > 0;
@@ -286,13 +292,13 @@ export function MapDevPanel({
<PanelSection
title="要素定位"
description="读取目标要素后,执行地图高亮或相机定位。"
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
initiallyOpen
status={
controllerState.pending ? "执行中" : controllerState.target ? "已高亮" : undefined
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
}
>
<div className="grid grid-cols-[120px_minmax(0,1fr)] gap-2">
<div className="space-y-3">
<FieldSelect
label="业务源"
value={sourceId}
@@ -305,13 +311,21 @@ export function MapDevPanel({
label: GROUP_LABELS[id]
}))}
/>
<Field label="Feature ID">
<Input
<Field label="要素 ID(可多个)">
<Textarea
value={featureId}
onChange={(event) => setFeatureId(event.target.value)}
className={inputClassName}
rows={3}
placeholder="例如:440681, 440682&#10;440683"
aria-describedby="map-dev-feature-ids-help"
aria-invalid={invalidIds}
className="resize-y text-xs"
/>
</Field>
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
</p>
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
</div>
<Button
type="button"
@@ -325,7 +339,7 @@ export function MapDevPanel({
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!target.featureId || controllerState.pending}
disabled={!canLocate}
onClick={() => void commands.highlight(target)}
className="w-full text-xs"
>
@@ -334,17 +348,17 @@ export function MapDevPanel({
</Button>
<Button
type="button"
disabled={!target.featureId || controllerState.pending}
disabled={!canLocate}
onClick={() => void commands.zoomToFeature(target)}
className="w-full text-xs"
>
<ZoomIn size={14} />
缩放到 Feature
缩放到要素
</Button>
</div>
<Button
type="button"
disabled={!target.featureId || controllerState.pending}
disabled={!canLocate}
onClick={() => void commands.locateAndHighlight(target)}
className="w-full text-xs"
variant="outline"
@@ -359,9 +373,15 @@ export function MapDevPanel({
>
清除高亮
</Button>
{controllerState.target ? (
{controllerState.targets.length ? (
<StatusText>
已高亮 {controllerState.target.sourceId}:{controllerState.target.featureId}
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
</StatusText>
) : null}
{controllerState.targetResult ? (
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
本次命中 {controllerState.targetResult.featureIds.length} 个
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
</StatusText>
) : null}
</PanelSection>
@@ -423,8 +443,8 @@ export function MapDevPanel({
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={sourceId !== "pipes" || !target.featureId}
onClick={() => setFlowPipeId(target.featureId)}
disabled={sourceId !== "pipes" || featureIds.length !== 1}
onClick={() => setFlowPipeId(featureIds[0])}
className="w-full text-xs"
variant="outline"
>
@@ -803,11 +823,11 @@ function RangeField({
);
}
function StatusText({ children }: { children: React.ReactNode }) {
function StatusText({ children, tone = "success" }: { children: React.ReactNode; tone?: "success" | "warning" }) {
return (
<p
role="status"
className="status-tone-success break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium leading-5 text-[var(--status-foreground)] [overflow-wrap:anywhere]"
className={`status-tone-${tone} break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium leading-5 text-[var(--status-foreground)] [overflow-wrap:anywhere]`}
>
{children}
</p>
@@ -4,7 +4,7 @@ import {
Copy,
Database,
Dot,
Download,
Eraser,
Keyboard,
Layers3,
MapPinned,
@@ -134,7 +134,7 @@ export type ToolbarPanelProps = {
onRefreshTiles: () => void;
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
onClearMapOverlays: () => void;
panelWidthClassName?: string;
};
@@ -176,7 +176,7 @@ export function ToolbarPanel({
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onExportConfig,
onClearMapOverlays,
panelWidthClassName
}: ToolbarPanelProps) {
const annotationItems = useMemo<MapAnnotationItem[]>(
@@ -403,7 +403,7 @@ export function ToolbarPanel({
onRefreshTiles={onRefreshTiles}
onShowDataStatus={onShowDataStatus}
onShowShortcuts={onShowShortcuts}
onExportConfig={onExportConfig}
onClearMapOverlays={onClearMapOverlays}
/>
</MapControlPanel>
);
@@ -476,22 +476,25 @@ function LegendPanel({ legendItems }: { legendItems: MapLegendItem[] }) {
}
type MorePanelProps = {
onClearMapOverlays: () => void;
onExportView: (preset: ExportViewPreset) => void;
onRefreshTiles: () => void;
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
};
function MorePanel({
onClearMapOverlays,
onExportView,
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onExportConfig
onShowShortcuts
}: MorePanelProps) {
return (
<div className="space-y-3">
<MapPanelSection title="清理地图">
<MapActionRow icon={Eraser} label="清空所有高亮和覆盖层" description="分析、测量、标注与定位标记" onClick={onClearMapOverlays} />
</MapPanelSection>
<MapPanelSection title="导出视图">
<MapActionRow icon={Camera} label="当前分辨率" description="按当前地图画布尺寸导出" onClick={() => onExportView("current")} />
<MapActionRow icon={Camera} label="4K 分辨率" description="长边约 3840px 的 PNG" onClick={() => onExportView("4k")} />
@@ -504,7 +507,6 @@ function MorePanel({
<MapPanelSection title="系统">
<MapActionRow icon={Keyboard} label="快捷键" description="查看地图操作键位" onClick={onShowShortcuts} />
<MapActionRow icon={Download} label="导出配置" description="保存当前图层与工具状态" onClick={onExportConfig} />
</MapPanelSection>
</div>
);
@@ -7,7 +7,6 @@ import {
ChevronDown,
ChevronRight,
Columns2,
Download,
FileText,
Keyboard,
LogOut,
@@ -282,7 +281,6 @@ export function UserMenu({
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onExportConfig,
onLogout
}: {
open: boolean;
@@ -292,7 +290,6 @@ export function UserMenu({
onRefreshTiles: () => void;
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
onLogout?: () => Promise<void>;
}) {
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
@@ -335,7 +332,6 @@ export function UserMenu({
<MenuSectionLabel>会话控制</MenuSectionLabel>
<MenuAction icon={Activity} label="查看运行状态" description="检查数据源与运行健康度" onSelect={onShowDataStatus} />
<MenuAction icon={RefreshCw} label="刷新地图瓦片" description="请求地图重新渲染业务图层" onSelect={onRefreshTiles} />
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
<MenuSeparator />
<DropdownMenuItem
@@ -59,7 +59,6 @@ export type WorkbenchTopBarProps = {
onShowDataStatus: () => void;
onRefreshTiles: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
onLogout?: () => Promise<void>;
};
@@ -97,7 +96,6 @@ export function WorkbenchTopBar({
onShowDataStatus,
onRefreshTiles,
onShowShortcuts,
onExportConfig,
onLogout
}: WorkbenchTopBarProps) {
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
@@ -256,7 +254,6 @@ export function WorkbenchTopBar({
onRefreshTiles={onRefreshTiles}
onShowDataStatus={onShowDataStatus}
onShowShortcuts={onShowShortcuts}
onExportConfig={onExportConfig}
onLogout={onLogout}
/>
</div>
@@ -47,6 +47,24 @@ function setup(overrides: Partial<ConstructorParameters<typeof InvestigationPres
}
describe("live investigation presentation following", () => {
it("discards queued map commands while retaining chart commands and later new map events", async () => {
const painting = deferred<void>();
const execute = vi.fn((command: InvestigationPresentationCommand) =>
command.action === "open_chart" ? painting.promise : Promise.resolve());
const test = setup({ execute });
test.follower.start();
test.channel.publish({ type: "open" });
try {
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live"));
test.channel.publish({ type: "events", events: [event(11, openChart), event(12), event(13, closeChart)] });
test.follower.discardPendingMapCommands();
painting.resolve();
await vi.waitFor(() => expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["open_chart", "close_chart"]));
test.channel.publish({ type: "events", events: [event(14)] });
await vi.waitFor(() => expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["open_chart", "close_chart", "highlight_features"]));
} finally { painting.resolve(); test.follower.stop(); }
});
it("listens before fetching the baseline and preserves events arriving during cursor lookup", async () => {
const baseline = deferred<InvestigationPresentationCursor>();
const order: string[] = [];
@@ -43,6 +43,12 @@ export class InvestigationPresentationFollower {
this.disconnect();
}
discardPendingMapCommands() {
this.pending = this.pending.filter(({ event }) =>
event.command.action === "open_chart" || event.command.action === "close_chart"
);
}
updateIdentity(identity: InvestigationIdentity) {
if (identity.task_id !== this.options.taskId || this.identity && identity.execution_generation < this.identity.execution_generation) return;
const changed = this.identity && (identity.execution_generation !== this.identity.execution_generation
@@ -138,6 +144,7 @@ export class InvestigationPresentationFollower {
if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) {
await this.resolveIdentity(next.event);
if (epoch !== this.epoch) break;
if (this.pending[0] !== next) continue;
if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) break;
}
this.pending.shift();
@@ -55,15 +55,25 @@ export function useInvestigationPresentation(options: Options) {
const [highlightLayers, setHighlightLayers] = useState<string[]>([]);
const followerRef = useRef<InvestigationPresentationFollower | null>(null);
const manualRequestRef = useRef<AbortController | null>(null);
const mapCommandsRef = useRef(new AbortController());
const scene = useMemo(() => new InvestigationMapPresentation(() => options.mapRef.current, () => valuesRef.current.getPadding()), [options.mapRef]);
const taskId = options.task?.task_id ?? null;
const subscribe = options.sync.subscribePresentationEvents;
const clear = useCallback(() => {
scene.clear(); setCharts([]); setOverlay(null); setHighlightLayers([]);
valuesRef.current.onClearLayers();
const clearMapOverlays = useCallback((discardPending = true) => {
mapCommandsRef.current.abort();
mapCommandsRef.current = new AbortController();
manualRequestRef.current?.abort();
if (discardPending) followerRef.current?.discardPendingMapCommands();
scene.clear(); setOverlay(null); setHighlightLayers([]);
}, [scene]);
const clear = useCallback(() => {
// New task generations retain their first queued map command.
clearMapOverlays(false); setCharts([]);
valuesRef.current.onClearLayers();
}, [clearMapOverlays]);
useEffect(() => {
manualRequestRef.current?.abort();
cameraPausedRef.current = false;
@@ -77,26 +87,35 @@ export function useInvestigationPresentation(options: Options) {
stopPulse: () => scene.stopPulse(),
onStatus: (next, message) => { setStatus(next); setError(message ?? null); },
execute: async (command, signal) => {
const sourceSignal = signal;
if (command.action !== "open_chart" && command.action !== "close_chart") {
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
}
signal.throwIfAborted();
if (command.action === "highlight_features") {
const collection = await valuesRef.current.controller.queryPresentationFeatures(command.layer, command.feature_ids, signal);
signal.throwIfAborted();
scene.highlight(collection, command.effect === "pulse", !cameraPausedRef.current);
setHighlightLayers([command.layer]);
} else if (command.action === "clear_highlights") {
scene.clearHighlights(); setHighlightLayers([]);
} else if (command.action === "open_chart") {
setCharts((current) => [...current.filter((item) => item.chart_id !== command.chart_id).slice(-5), command]);
await waitForChart(command.chart_id, signal);
} else if (command.action === "close_chart") {
setCharts((current) => current.filter((item) => item.chart_id !== command.chart_id));
await frame(signal);
} else if (command.action === "set_overlay") {
const next = { ...command, visible: true };
scene.setOverlay(next, !cameraPausedRef.current); setOverlay(next);
} else {
scene.clearOverlay(command.overlay_id);
setOverlay((current) => current?.overlay_id === command.overlay_id ? null : current);
try {
if (command.action === "highlight_features") {
const collection = await valuesRef.current.controller.queryPresentationFeatures(command.layer, command.feature_ids, signal);
signal.throwIfAborted();
scene.highlight(collection, command.effect === "pulse", !cameraPausedRef.current);
setHighlightLayers([command.layer]);
} else if (command.action === "clear_highlights") {
scene.clearHighlights(); setHighlightLayers([]);
} else if (command.action === "open_chart") {
setCharts((current) => [...current.filter((item) => item.chart_id !== command.chart_id).slice(-5), command]);
await waitForChart(command.chart_id, signal);
} else if (command.action === "close_chart") {
setCharts((current) => current.filter((item) => item.chart_id !== command.chart_id));
await frame(signal);
} else if (command.action === "set_overlay") {
const next = { ...command, visible: true };
scene.setOverlay(next, !cameraPausedRef.current); setOverlay(next);
} else {
scene.clearOverlay(command.overlay_id);
setOverlay((current) => current?.overlay_id === command.overlay_id ? null : current);
}
} catch (cause) {
if (signal.aborted && !sourceSignal.aborted) return;
throw cause;
}
}
});
@@ -138,7 +157,7 @@ export function useInvestigationPresentation(options: Options) {
}, [pauseCamera, scene]);
return {
charts, overlay, cameraPaused, status, error, highlightLayers,
charts, overlay, cameraPaused, status, error, highlightLayers, clearMapOverlays,
pauseCamera,
resumeCamera: () => { cameraPausedRef.current = false; setCameraPaused(false); },
locateTargets,
+1
View File
@@ -3,6 +3,7 @@ export { ProjectWorkbench } from "./project-workbench";
export { WorkbenchMapController } from "./map/workbench-map-controller";
export type {
FeatureTarget,
FeatureBatchTarget,
WorkbenchMapCommands,
WorkbenchMapControllerState,
WorkbenchMapErrorCode
+11 -21
View File
@@ -1108,25 +1108,16 @@ function MapWorkbenchContent({
}
}
function handleExportConfig() {
const config = {
exportedAt: new Date().toISOString(),
activeBaseLayerId,
layerVisibility,
activeToolId,
activeMeasureModeId,
activeMeasureUnitId
};
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "supply-network-map-config.json";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
showMapNotice({ tone: "success", message: "当前地图配置已导出。" });
function handleClearMapOverlays() {
mapController.clearOverlays();
presentation.clearMapOverlays();
handleClearSelection();
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
measurement.clear();
drawing.clear();
geocoding.clear();
handleToggleLayer("simulation", false);
showMapNotice({ tone: "success", message: "已清空所有高亮和覆盖层。" });
}
const toolbarPanelProps: ToolbarPanelProps = {
@@ -1167,7 +1158,7 @@ function MapWorkbenchContent({
onRefreshTiles: handleRefreshTiles,
onShowDataStatus: handleShowDataStatus,
onShowShortcuts: handleShowShortcuts,
onExportConfig: handleExportConfig
onClearMapOverlays: handleClearMapOverlays
};
const selectedOperationalEvent =
operationalEvents.find((event) => event.id === workspaceContext.operational.selectedEventId) ??
@@ -1377,7 +1368,6 @@ function MapWorkbenchContent({
onShowDataStatus={handleShowDataStatus}
onRefreshTiles={handleRefreshTiles}
onShowShortcuts={handleShowShortcuts}
onExportConfig={handleExportConfig}
onLogout={onLogout}
/>
@@ -9,6 +9,112 @@ import {
import { WorkbenchMapController } from "./workbench-map-controller";
describe("WorkbenchMapController", () => {
it("clears every controller overlay, preserves layer styles, and invalidates pending analysis", async () => {
const map = createMap();
map.getLayer.mockReturnValue({} as never);
const setData = vi.fn();
map.getSource.mockReturnValue({ setData });
let resolveFeatures = (_value: FeatureCollection) => {};
const pendingAnalysis = new Promise<FeatureCollection>((resolve) => { resolveFeatures = resolve; });
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: (source) => source === "scada" ? pendingAnalysis : Promise.resolve(pointCollection)
});
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
controller.applyLayerGroupStyle("pipes", { color: "#123456" });
const showingFlow = controller.setFlowVisible(true);
const analysis = controller.renderScadaAnalysis([{ device_id: "J-1", level: "high" }]);
controller.clearOverlays();
await showingFlow;
resolveFeatures(pointCollection);
await expect(analysis).rejects.toThrow("ACTION_SUPERSEDED");
expect(setData).toHaveBeenCalledWith({ type: "FeatureCollection", features: [] });
expect(controller.getSnapshot()).toMatchObject({
target: null, targets: [], targetResult: null, scadaAnalysis: null,
flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"]
});
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted");
expect(map.setLayoutProperty).toHaveBeenCalledWith(FLOW_LINE_LAYER_ID, "visibility", "none");
expect(map.easeTo).not.toHaveBeenCalled();
});
it("queries a batch once, fits all matches, and replaces and clears every highlight", async () => {
const map = createMap();
const collection: FeatureCollection = { type: "FeatureCollection", features: [
...pointCollection.features,
{ type: "Feature", properties: { id: "J-2" }, geometry: { type: "Point", coordinates: [121, 29] } }
] };
const fetchFeatures = vi.fn(async () => collection);
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
getPadding: () => ({ top: 48, right: 48, bottom: 48, left: 48 }), fetchFeatures
});
await expect(controller.locateFeatures("junctions", ["J-1", "J-2", "J-1", "missing"])).resolves.toEqual({
located_ids: ["J-1", "J-2"], missing_ids: ["missing"], fitted: true
});
expect(fetchFeatures).toHaveBeenCalledExactlyOnceWith("junctions", ["J-1", "J-2", "missing"], undefined);
expect(map.fitBounds).toHaveBeenCalledWith([[120.7, 28], [121, 29]], expect.objectContaining({ maxZoom: 18 }));
for (const id of ["J-1", "J-2"]) {
expect(map.setFeatureState).toHaveBeenCalledWith({ source: "junctions", sourceLayer: "junctions", id }, { highlighted: true });
}
expect(controller.getSnapshot().targets).toHaveLength(2);
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
for (const id of ["J-1", "J-2"]) {
expect(map.removeFeatureState).toHaveBeenCalledWith({ source: "junctions", sourceLayer: "junctions", id }, "highlighted");
}
await controller.highlight({ sourceId: "junctions", featureIds: ["J-1", "J-2"] });
map.removeFeatureState.mockClear();
controller.clearHighlight();
expect(map.removeFeatureState).toHaveBeenCalledTimes(2);
expect(controller.getSnapshot()).toMatchObject({ targets: [], target: null, targetResult: null });
});
it("keeps highlight-only and zoom-only batch behavior separate", async () => {
const map = createMap();
const controller = createController(map, lineCollection);
const target = { sourceId: "pipes" as const, featureIds: ["P-1", "missing"] };
await controller.highlight(target);
expect(map.fitBounds).not.toHaveBeenCalled();
expect(map.easeTo).not.toHaveBeenCalled();
expect(controller.getSnapshot().targetResult).toMatchObject({ featureIds: ["P-1"], missingIds: ["missing"] });
map.setFeatureState.mockClear();
await controller.zoomToFeature(target);
expect(map.fitBounds).toHaveBeenCalledOnce();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(map.removeFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot().targets).toEqual([{ sourceId: "pipes", featureId: "P-1" }]);
});
it("cancels an in-flight batch without applying late highlights or moving the camera", async () => {
const map = createMap();
const abort = new AbortController();
let resolveFeatures = (_value: FeatureCollection) => {};
const fetchFeatures = vi.fn(() => new Promise<FeatureCollection>((resolve) => { resolveFeatures = resolve; }));
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), fetchFeatures
});
const locating = controller.locateFeatures("junctions", ["J-1", "J-2"], abort.signal);
abort.abort();
resolveFeatures(pointCollection);
await expect(locating).rejects.toThrow();
expect(fetchFeatures).toHaveBeenCalledWith("junctions", ["J-1", "J-2"], abort.signal);
expect(map.fitBounds).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot()).toMatchObject({ pending: false, targets: [] });
});
it("rejects empty and oversized batches without querying or moving", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
for (const ids of [[], [" "], Array.from({ length: 101 }, (_, i) => `J-${i}`)]) {
await expect(controller.locateFeatures("junctions", ids)).rejects.toThrow("INVALID_FEATURE_IDS");
}
expect(map.fitBounds).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
});
it("returns only resolved IDs and rejects a missing target rather than fitting the whole network", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
@@ -23,6 +23,7 @@ import {
import {
MAX_PIPE_FEATURES,
MAX_MAP_FEATURE_IDS,
MAX_MAP_FEATURE_ID_LENGTH,
createMapFeatureWfsUrl,
normalizeMapFeatureCollection,
parseMapFeatureQuery
@@ -44,9 +45,14 @@ import {
} from "./scada-analysis";
export type FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string };
export type FeatureBatchTarget = { sourceId: WaterNetworkSourceId; featureIds: string[] };
type FeatureCommandTarget = FeatureTarget | FeatureBatchTarget;
type FeatureCommandMode = "highlight" | "zoom" | "locate";
type FeatureCommandResult = FeatureBatchTarget & { missingIds: string[]; mode: FeatureCommandMode };
export type WorkbenchMapErrorCode =
| "MAP_NOT_READY"
| "FEATURE_NOT_FOUND"
| "INVALID_FEATURE_IDS"
| "WFS_UNAVAILABLE"
| "SCADA_API_UNAVAILABLE"
| "SCADA_FEATURES_NOT_FOUND"
@@ -57,6 +63,8 @@ export type WorkbenchMapErrorCode =
export type WorkbenchMapControllerState = {
target: FeatureTarget | null;
targets: FeatureTarget[];
targetResult: FeatureCommandResult | null;
valueLabels: Partial<Record<ValueLabelSourceId, ValueLabelStyle>>;
pending: boolean;
errorCode: WorkbenchMapErrorCode | null;
@@ -68,9 +76,9 @@ export type WorkbenchMapControllerState = {
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
export type WorkbenchMapCommands = {
zoomToFeature: (target: FeatureTarget) => Promise<void>;
locateAndHighlight: (target: FeatureTarget) => Promise<void>;
highlight: (target: FeatureTarget) => Promise<void>;
zoomToFeature: (target: FeatureCommandTarget) => Promise<void>;
locateAndHighlight: (target: FeatureCommandTarget) => Promise<void>;
highlight: (target: FeatureCommandTarget) => Promise<void>;
clearHighlight: () => void;
setFlowVisible: (visible: boolean) => Promise<void>;
replacePipeFlowCalculations: (records: readonly PipeFlowCalculation[]) => Promise<PipeFlowSummary | null>;
@@ -98,6 +106,8 @@ export type WorkbenchMapControllerOptions = {
export class WorkbenchMapController implements WorkbenchMapCommands {
private state: WorkbenchMapControllerState = {
target: null,
targets: [],
targetResult: null,
valueLabels: {},
pending: false,
errorCode: null,
@@ -107,15 +117,14 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
scadaAnalysis: null
};
private listeners = new Set<() => void>();
private highlightedTarget: FeatureTarget | null = null;
private cachedFeatures = new Map<string, Feature>();
private flowState: FlowOverlayState = createFlowOverlayState();
private flowUpdateRevision = 0;
private flowVisibilityRevision = 0;
private scadaAnalysisRevision = 0;
private targetRequestRevision = 0;
private pendingTargetSourceId: WaterNetworkSourceId | null = null;
private availableSourceIds: ReadonlySet<WaterNetworkSourceId> | null = null;
private sourceAvailabilityGenerations = new Map<WaterNetworkSourceId, number>();
constructor(private readonly options: WorkbenchMapControllerOptions) {}
@@ -126,47 +135,20 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
getSnapshot = () => this.state;
async zoomToFeature(target: FeatureTarget) {
const requestRevision = this.beginTargetCommand();
if (!this.requireTargetMap(target)) return;
const feature = await this.resolveTarget(target, requestRevision);
if (requestRevision !== this.targetRequestRevision) return;
const map = this.requireTargetMap(target);
if (!feature || !map) return;
this.moveCameraToFeature(map, feature);
async zoomToFeature(target: FeatureCommandTarget) {
await this.executeFeatureCommand(target, "zoom");
}
async locateAndHighlight(target: FeatureTarget) {
const requestRevision = this.beginTargetCommand();
if (!this.requireTargetMap(target)) return;
const feature = await this.resolveTarget(target, requestRevision);
if (requestRevision !== this.targetRequestRevision) return;
const map = this.requireTargetMap(target);
if (!feature || !map) return;
if (!this.moveCameraToFeature(map, feature)) return;
this.setHighlight(target);
async locateAndHighlight(target: FeatureCommandTarget) {
await this.executeFeatureCommand(target, "locate");
}
async locateFeatures(sourceId: WaterNetworkSourceId, featureIds: string[], signal?: AbortSignal) {
const requestRevision = this.beginTargetCommand();
const target = { sourceId, featureId: featureIds[0] ?? "" };
if (!this.requireTargetMap(target)) throw new Error(this.state.errorCode ?? "MAP_NOT_READY");
const found: Array<{ id: string; feature: Feature }> = [];
const missing: string[] = [];
for (const id of [...new Set(featureIds)]) {
signal?.throwIfAborted();
const feature = await this.resolveTarget({ sourceId, featureId: id }, requestRevision);
if (requestRevision !== this.targetRequestRevision) throw new Error("ACTION_SUPERSEDED");
if (this.state.errorCode === "WFS_UNAVAILABLE" || this.state.errorCode === "SCADA_API_UNAVAILABLE") throw new Error(this.state.errorCode);
if (feature) found.push({ id, feature }); else missing.push(id);
}
signal?.throwIfAborted();
const map = this.requireTargetMap(target);
if (!map || found.length === 0) throw new Error(this.state.errorCode ?? "FEATURE_NOT_FOUND");
const combined: Feature = { type: "Feature", properties: {}, geometry: { type: "MultiPoint", coordinates: found.flatMap(({ feature }) => collectCoordinates(feature.geometry)) } };
if (!this.moveCameraToFeature(map, combined, 0)) throw new Error("FEATURE_NOT_FOUND");
this.setHighlight({ sourceId, featureId: found[0].id });
return { located_ids: found.map(({ id }) => id), missing_ids: missing, fitted: true as const };
const requestRevision = this.targetRequestRevision + 1;
const result = await this.executeFeatureCommand({ sourceId, featureIds }, "locate", signal, 0);
if (requestRevision !== this.targetRequestRevision) throw new Error("ACTION_SUPERSEDED");
if (!result) throw new Error(this.state.errorCode ?? "ACTION_SUPERSEDED");
return { located_ids: result.featureIds, missing_ids: result.missingIds, fitted: true as const };
}
/** Resolve a task-owned overlay without changing the user's selection or controller highlight. */
@@ -209,36 +191,23 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
return true;
}
async highlight(target: FeatureTarget) {
const requestRevision = this.beginTargetCommand();
if (!this.requireTargetMap(target)) return;
const feature = await this.resolveTarget(target, requestRevision);
if (requestRevision !== this.targetRequestRevision) return;
if (feature) this.setHighlight(target);
async highlight(target: FeatureCommandTarget) {
await this.executeFeatureCommand(target, "highlight");
}
clearHighlight = () => {
this.beginTargetCommand();
const map = this.options.getMap();
if (map && this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) {
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
for (const target of this.state.targets) {
if (map?.getSource(target.sourceId)) {
map.removeFeatureState(toFeatureStateTarget(target), "highlighted");
}
}
this.highlightedTarget = null;
this.patchState({ target: null, errorCode: null });
this.patchState({ target: null, targets: [], targetResult: null, errorCode: null });
};
syncAvailableSources(sourceIds: readonly WaterNetworkSourceId[]) {
const availableSourceIds = new Set<WaterNetworkSourceId>(sourceIds);
if (this.availableSourceIds) {
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
if (this.availableSourceIds?.has(sourceId) && !availableSourceIds.has(sourceId)) {
this.sourceAvailabilityGenerations.set(
sourceId,
(this.sourceAvailabilityGenerations.get(sourceId) ?? 0) + 1
);
}
});
}
this.availableSourceIds = availableSourceIds;
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
@@ -249,9 +218,9 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
}
});
const targetUnavailable = Boolean(
this.highlightedTarget && !availableSourceIds.has(this.highlightedTarget.sourceId)
);
const unavailableTargets = this.state.targets.filter((target) => !availableSourceIds.has(target.sourceId));
const targetUnavailable = unavailableTargets.length > 0;
const resultUnavailable = Boolean(this.state.targetResult && !availableSourceIds.has(this.state.targetResult.sourceId));
const pendingTargetUnavailable = Boolean(
this.pendingTargetSourceId && !availableSourceIds.has(this.pendingTargetSourceId)
);
@@ -259,12 +228,11 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
this.targetRequestRevision += 1;
this.pendingTargetSourceId = null;
}
if (targetUnavailable && this.highlightedTarget) {
const map = this.options.getMap();
if (map?.getSource(this.highlightedTarget.sourceId)) {
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
const map = this.options.getMap();
for (const target of unavailableTargets) {
if (map?.getSource(target.sourceId)) {
map.removeFeatureState(toFeatureStateTarget(target), "highlighted");
}
this.highlightedTarget = null;
}
const valueLabelEntries = Object.entries(this.state.valueLabels);
@@ -278,6 +246,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
const styledGroupsChanged = styledGroups.length !== this.state.styledGroups.length;
if (
!targetUnavailable &&
!resultUnavailable &&
!pendingTargetUnavailable &&
!valueLabelsChanged &&
!styledGroupsChanged
@@ -285,6 +254,8 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
this.patchState({
target: targetUnavailable ? null : this.state.target,
targets: targetUnavailable ? [] : this.state.targets,
targetResult: resultUnavailable ? null : this.state.targetResult,
pending: pendingTargetUnavailable ? false : this.state.pending,
errorCode: pendingTargetUnavailable ? "WFS_UNAVAILABLE" : this.state.errorCode,
valueLabels: valueLabelsChanged
@@ -295,6 +266,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
}
async setFlowVisible(visible: boolean) {
const revision = ++this.flowVisibilityRevision;
const map = this.requireMap();
if (!map) return;
if (!visible) {
@@ -309,8 +281,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
this.flowState,
this.options.reducedMotion?.()
);
if (revision !== this.flowVisibilityRevision) return;
this.patchState({ pending: false, flowVisible: true });
} catch {
if (revision !== this.flowVisibilityRevision) return;
hideFlowOverlay(map, this.flowState);
this.patchState({ pending: false, flowVisible: false, errorCode: "FLOW_UNAVAILABLE" });
}
@@ -533,10 +507,18 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
return { cleared: true as const };
}
resetAll = () => {
clearOverlays = () => {
this.clearHighlight();
this.clearValueLabel();
this.clearScadaAnalysis();
this.flowVisibilityRevision += 1;
const map = this.options.getMap();
if (map) hideFlowOverlay(map, this.flowState);
this.patchState({ flowVisible: false });
};
resetAll = () => {
this.clearOverlays();
const map = this.options.getMap();
if (map) {
hideFlowOverlay(map, this.flowState);
@@ -574,42 +556,86 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
return requestRevision;
}
private async resolveTarget(target: FeatureTarget, requestRevision: number) {
const map = this.requireMap();
if (!map) return null;
const key = `${target.sourceId}:${target.featureId}`;
const dataDependent = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.includes(
target.sourceId as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
);
const cached = dataDependent ? undefined : this.cachedFeatures.get(key);
if (cached) return cached;
const sourceAvailabilityGeneration = this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0;
this.pendingTargetSourceId = target.sourceId;
this.patchState({ pending: true, errorCode: null });
try {
const collection = await this.fetchFeatures(target.sourceId, [target.featureId]);
if (
requestRevision !== this.targetRequestRevision ||
sourceAvailabilityGeneration !== (this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0)
) return null;
this.pendingTargetSourceId = null;
const feature = collection.features.find((item) => String(item.properties?.id ?? item.properties?.device_id ?? item.id) === target.featureId);
if (!feature) {
this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" });
return null;
}
if (!dataDependent) this.cachedFeatures.set(key, feature);
this.patchState({ pending: false });
return feature;
} catch {
if (requestRevision !== this.targetRequestRevision) return null;
this.pendingTargetSourceId = null;
this.patchState({
pending: false,
errorCode: target.sourceId === "scada" ? "SCADA_API_UNAVAILABLE" : "WFS_UNAVAILABLE"
});
private async executeFeatureCommand(
input: FeatureCommandTarget,
mode: FeatureCommandMode,
signal?: AbortSignal,
durationOverride?: number
): Promise<FeatureCommandResult | null> {
const requestRevision = this.beginTargetCommand();
const featureIds = [...new Set(("featureIds" in input ? input.featureIds : [input.featureId]).map((id) => id.trim()))];
this.patchState({ targetResult: null, errorCode: null });
if (!featureIds.length || featureIds.length > MAX_MAP_FEATURE_IDS ||
featureIds.some((id) => !id || id.length > MAX_MAP_FEATURE_ID_LENGTH)) {
this.setError("INVALID_FEATURE_IDS");
return null;
}
const { sourceId } = input;
const target = { sourceId, featureId: featureIds[0] };
if (!this.requireTargetMap(target)) return null;
const dataDependent = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.includes(
sourceId as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
);
const found = new Map<string, Feature>();
const uncachedIds = featureIds.filter((id) => {
const cached = dataDependent ? undefined : this.cachedFeatures.get(`${sourceId}:${id}`);
if (cached) found.set(id, cached);
return !cached;
});
this.pendingTargetSourceId = sourceId;
this.patchState({ pending: true });
try {
signal?.throwIfAborted();
if (uncachedIds.length) {
const collection = await this.fetchFeatures(sourceId, uncachedIds, signal);
if (requestRevision !== this.targetRequestRevision) return null;
signal?.throwIfAborted();
const requestedIds = new Set(uncachedIds);
for (const feature of collection.features) {
const id = String(feature.properties?.id ?? feature.properties?.device_id ?? feature.id);
if (!requestedIds.has(id) || !collectCoordinates(feature.geometry).length) continue;
found.set(id, feature);
if (!dataDependent) this.cachedFeatures.set(`${sourceId}:${id}`, feature);
}
}
} catch (error) {
if (requestRevision !== this.targetRequestRevision) return null;
if (signal?.aborted) throw error;
this.setError(sourceId === "scada" ? "SCADA_API_UNAVAILABLE" : "WFS_UNAVAILABLE");
return null;
} finally {
if (requestRevision === this.targetRequestRevision) {
this.pendingTargetSourceId = null;
this.patchState({ pending: false });
}
}
if (requestRevision !== this.targetRequestRevision) return null;
signal?.throwIfAborted();
const map = this.requireTargetMap(target);
if (!map) return null;
const result: FeatureCommandResult = {
sourceId, mode,
featureIds: featureIds.filter((id) => found.has(id)),
missingIds: featureIds.filter((id) => !found.has(id))
};
this.patchState({ targetResult: result });
if (!result.featureIds.length) {
this.setError("FEATURE_NOT_FOUND");
return null;
}
if (mode !== "highlight") {
const features = result.featureIds.map((id) => found.get(id)!);
const combined: Feature = features.length === 1 && durationOverride === undefined ? features[0] : {
type: "Feature", properties: {},
geometry: { type: "MultiPoint", coordinates: features.flatMap((feature) => collectCoordinates(feature.geometry)) }
};
if (!this.moveCameraToFeature(map, combined, durationOverride)) return null;
}
if (mode !== "zoom") {
this.setHighlights(result.featureIds.map((featureId) => ({ sourceId, featureId })));
}
this.patchState({ errorCode: null });
return result;
}
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
@@ -631,15 +657,18 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
return collection;
}
private setHighlight(target: FeatureTarget) {
const map = this.requireTargetMap(target);
private setHighlights(targets: FeatureTarget[]) {
const map = this.options.getMap();
if (!map) return;
if (this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) {
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
for (const target of this.state.targets) {
if (map.getSource(target.sourceId)) {
map.removeFeatureState(toFeatureStateTarget(target), "highlighted");
}
}
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
this.highlightedTarget = target;
this.patchState({ target, errorCode: null });
for (const target of targets) {
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
}
this.patchState({ target: targets[0] ?? null, targets, errorCode: null });
}
private requireMap() {