feat(map): manage agent overlays in layer panel
This commit is contained in:
@@ -1,54 +1,18 @@
|
||||
import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl";
|
||||
import { annotationPoints, impactAreaPolygon } from "../data/workbench-simulation";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
|
||||
export const SIMULATION_SOURCE_IDS = {
|
||||
impactArea: "simulation-impact-area",
|
||||
annotations: "simulation-annotations",
|
||||
backendAnalysis: "simulation-backend-analysis"
|
||||
} as const;
|
||||
|
||||
const EMPTY_FEATURE_COLLECTION = {
|
||||
type: "FeatureCollection" as const,
|
||||
features: []
|
||||
};
|
||||
|
||||
export const simulationSources = {
|
||||
impactArea: {
|
||||
type: "geojson",
|
||||
data: impactAreaPolygon
|
||||
} satisfies GeoJSONSourceSpecification,
|
||||
annotations: {
|
||||
type: "geojson",
|
||||
data: annotationPoints
|
||||
} satisfies GeoJSONSourceSpecification,
|
||||
backendAnalysis: {
|
||||
type: "geojson",
|
||||
data: EMPTY_FEATURE_COLLECTION
|
||||
data: { type: "FeatureCollection", features: [] }
|
||||
} satisfies GeoJSONSourceSpecification
|
||||
};
|
||||
|
||||
export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
{
|
||||
id: "simulation-impact-fill",
|
||||
type: "fill",
|
||||
source: SIMULATION_SOURCE_IDS.impactArea,
|
||||
paint: {
|
||||
"fill-color": MAP_STYLE_TOKENS.state.risk,
|
||||
"fill-opacity": MAP_STYLE_TOKENS.opacity.area
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-impact-outline",
|
||||
type: "line",
|
||||
source: SIMULATION_SOURCE_IDS.impactArea,
|
||||
paint: {
|
||||
"line-color": MAP_STYLE_TOKENS.state.risk,
|
||||
"line-width": 1.5,
|
||||
"line-dasharray": [2, 2],
|
||||
"line-opacity": 0.72
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-backend-analysis-fill",
|
||||
type: "fill",
|
||||
@@ -82,83 +46,6 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
"circle-stroke-width": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-burst-halo",
|
||||
type: "circle",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "burst"],
|
||||
paint: {
|
||||
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"circle-radius": 18,
|
||||
"circle-stroke-color": MAP_STYLE_TOKENS.state.incident,
|
||||
"circle-stroke-width": 3,
|
||||
"circle-opacity": 0.88
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-burst-point",
|
||||
type: "circle",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "burst"],
|
||||
paint: {
|
||||
"circle-color": MAP_STYLE_TOKENS.state.incident,
|
||||
"circle-radius": 7,
|
||||
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"circle-stroke-width": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-pipe-label",
|
||||
type: "symbol",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "pipe-label"],
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 13,
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": MAP_STYLE_TOKENS.label.inverse,
|
||||
"text-halo-color": MAP_STYLE_TOKENS.state.agent,
|
||||
"text-halo-width": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-impact-label",
|
||||
type: "symbol",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "tooltip"],
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 14,
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-offset": [0, 0],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": MAP_STYLE_TOKENS.state.risk,
|
||||
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"text-halo-width": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-district-label",
|
||||
type: "symbol",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "district"],
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 13,
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": MAP_STYLE_TOKENS.label.secondary,
|
||||
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"text-halo-width": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-backend-analysis-label",
|
||||
type: "symbol",
|
||||
@@ -176,25 +63,6 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"text-halo-width": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "simulation-burst-label",
|
||||
type: "symbol",
|
||||
source: SIMULATION_SOURCE_IDS.annotations,
|
||||
filter: ["==", ["get", "kind"], "burst"],
|
||||
layout: {
|
||||
"text-field": "演示爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
|
||||
"text-size": 12,
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-offset": [5.2, -3.4],
|
||||
"text-anchor": "left",
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": MAP_STYLE_TOKENS.label.primary,
|
||||
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||
"text-halo-width": 5
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -54,8 +54,7 @@ describe("workbench supply layer controls", () => {
|
||||
"scada-flow",
|
||||
"water_quality_monitoring_points",
|
||||
"fx_20200423_pipes",
|
||||
"fx_20200811_dn100_pipes",
|
||||
"simulation"
|
||||
"fx_20200811_dn100_pipes"
|
||||
])
|
||||
);
|
||||
expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({
|
||||
@@ -104,12 +103,30 @@ describe("workbench supply layer controls", () => {
|
||||
"valves",
|
||||
"reservoirs",
|
||||
"scada-pressure",
|
||||
"scada-flow",
|
||||
"simulation"
|
||||
"scada-flow"
|
||||
]);
|
||||
});
|
||||
|
||||
it("defines map-matched symbols for model, source GIS, and analysis layers", () => {
|
||||
it("lists actual presentation overlays with their visibility and removes cleared entries", () => {
|
||||
const overlays = [
|
||||
{ id: "presentation:investigation:area", title: "爆管影响范围", description: "Agent 调查 · 空间范围", featureCount: 1, visible: true },
|
||||
{ id: "presentation:chat:area", title: "投诉点热力", description: "Agent 对话 · 热力分布", featureCount: 8, visible: false }
|
||||
];
|
||||
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY, undefined, {}, overlays);
|
||||
|
||||
expect(items.filter((item) => item.group === "analysis")).toMatchObject([
|
||||
{ id: overlays[0].id, label: "爆管影响范围", visible: true, featureCount: 1 },
|
||||
{ id: overlays[1].id, label: "投诉点热力", visible: false, featureCount: 8 }
|
||||
]);
|
||||
expect(createVisibleMapLegendItems(items).filter((item) => item.group === "analysis")).toMatchObject([
|
||||
{ label: "爆管影响范围", layerControlId: overlays[0].id, shape: "area" }
|
||||
]);
|
||||
const cleared = createLayerControlItems(INITIAL_LAYER_VISIBILITY);
|
||||
expect(cleared.filter((item) => item.group === "analysis")).toEqual([]);
|
||||
expect(createVisibleMapLegendItems(cleared).filter((item) => item.group === "analysis")).toEqual([]);
|
||||
});
|
||||
|
||||
it("defines map-matched symbols for model and source GIS layers", () => {
|
||||
expect(MAP_LEGEND_ITEMS.map((item) => item.id)).toEqual([
|
||||
"major-pipe",
|
||||
"medium-pipe",
|
||||
@@ -134,8 +151,7 @@ describe("workbench supply layer controls", () => {
|
||||
"source-hydrant",
|
||||
"source-connection",
|
||||
"source-customer-meter",
|
||||
"source-non-residential-meter",
|
||||
"impact-area"
|
||||
"source-non-residential-meter"
|
||||
]);
|
||||
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({
|
||||
imageSrc: "/map/valve.png"
|
||||
@@ -186,11 +202,6 @@ describe("workbench supply layer controls", () => {
|
||||
).toMatchObject({
|
||||
imageSrc: "/map/gis-non-residential-meter.svg"
|
||||
});
|
||||
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "impact-area")).toMatchObject({
|
||||
groupLabel: "分析覆盖",
|
||||
layerControlId: "simulation",
|
||||
shape: "area"
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the legend synchronized with visible layer controls", () => {
|
||||
@@ -222,8 +233,7 @@ describe("workbench supply layer controls", () => {
|
||||
|
||||
const reducedItems = createLayerControlItems({
|
||||
...INITIAL_LAYER_VISIBILITY,
|
||||
valves: false,
|
||||
simulation: false
|
||||
valves: false
|
||||
});
|
||||
const reducedLegendIds = createVisibleMapLegendItems(reducedItems).map((item) => item.id);
|
||||
|
||||
|
||||
@@ -15,19 +15,6 @@ import {
|
||||
import { SCADA_ICON_PATHS, SCADA_LAYER_CONTROL_ITEMS, SCADA_LAYER_GROUPS } from "./scada";
|
||||
import { SUPPLY_ASSET_ICON_PATHS } from "./supply-icons";
|
||||
|
||||
export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
|
||||
simulation: [
|
||||
"simulation-impact-fill",
|
||||
"simulation-impact-outline",
|
||||
"simulation-burst-halo",
|
||||
"simulation-burst-point",
|
||||
"simulation-pipe-label",
|
||||
"simulation-impact-label",
|
||||
"simulation-district-label",
|
||||
"simulation-burst-label"
|
||||
]
|
||||
};
|
||||
|
||||
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
|
||||
...Object.fromEntries(
|
||||
SUPPLY_LAYER_CATALOG.filter((layer) => layer.id !== "scada").map((layer) => [
|
||||
@@ -35,8 +22,17 @@ export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
|
||||
layer.initialVisible
|
||||
])
|
||||
),
|
||||
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true])),
|
||||
simulation: false
|
||||
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true]))
|
||||
};
|
||||
|
||||
export type AnalysisOverlayLayer = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
visible: boolean;
|
||||
disabled?: boolean;
|
||||
featureCount: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
const SOURCE_CONTROL_LABELS = Object.fromEntries(
|
||||
@@ -421,25 +417,27 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
|
||||
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter,
|
||||
layerControlId: "fx_20200423_non_residential_meters",
|
||||
...SOURCE_FACILITY_LEGEND_GROUP
|
||||
},
|
||||
{
|
||||
id: "impact-area",
|
||||
label: "影响范围",
|
||||
description: "事故或分析结果",
|
||||
color: MAP_STYLE_TOKENS.state.incident,
|
||||
shape: "area",
|
||||
layerControlId: "simulation",
|
||||
...ANALYSIS_LEGEND_GROUP
|
||||
}
|
||||
];
|
||||
|
||||
export function createVisibleMapLegendItems(
|
||||
layerItems: readonly Pick<MapLayerControlItem, "id" | "visible">[]
|
||||
layerItems: readonly MapLayerControlItem[]
|
||||
) {
|
||||
const visibleLayerIds = new Set(layerItems.filter((item) => item.visible).map((item) => item.id));
|
||||
return MAP_LEGEND_ITEMS.filter((item) =>
|
||||
item.layerControlId ? visibleLayerIds.has(item.layerControlId) : true
|
||||
);
|
||||
return [
|
||||
...MAP_LEGEND_ITEMS.filter((item) =>
|
||||
item.layerControlId ? visibleLayerIds.has(item.layerControlId) : true
|
||||
),
|
||||
...layerItems.filter((item) => item.group === "analysis" && item.visible).map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
color: item.visual?.color ?? MAP_STYLE_TOKENS.state.risk,
|
||||
shape: "area" as const,
|
||||
layerControlId: item.id,
|
||||
...ANALYSIS_LEGEND_GROUP
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
export const BASE_LAYER_GROUPS = {
|
||||
@@ -502,7 +500,8 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
|
||||
export function createLayerControlItems(
|
||||
layerVisibility: Record<string, boolean>,
|
||||
availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||
featureCounts: Partial<Record<WaterNetworkSourceId, number>> = {}
|
||||
featureCounts: Partial<Record<WaterNetworkSourceId, number>> = {},
|
||||
analysisOverlays: readonly AnalysisOverlayLayer[] = []
|
||||
): MapLayerControlItem[] {
|
||||
const available = new Set<WaterNetworkSourceId>(availableSourceIds);
|
||||
const catalogItems = SUPPLY_LAYER_CATALOG.filter(
|
||||
@@ -560,16 +559,18 @@ export function createLayerControlItems(
|
||||
groupDescription: "源数据快照与专项台账",
|
||||
groupDefaultOpen: false
|
||||
})),
|
||||
{
|
||||
id: "simulation",
|
||||
label: "模拟结果",
|
||||
description: "影响范围与事故标注",
|
||||
visual: { kind: "area", color: MAP_STYLE_TOKENS.state.incident },
|
||||
visible: layerVisibility.simulation,
|
||||
...analysisOverlays.map((overlay) => ({
|
||||
id: overlay.id,
|
||||
label: overlay.title,
|
||||
description: overlay.description,
|
||||
featureCount: overlay.featureCount,
|
||||
visual: { kind: "area" as const, color: overlay.color ?? MAP_STYLE_TOKENS.state.risk },
|
||||
visible: overlay.visible,
|
||||
disabled: overlay.disabled,
|
||||
group: "analysis",
|
||||
groupLabel: "分析覆盖",
|
||||
groupDescription: "随已打开的空间成果显示"
|
||||
}
|
||||
groupDescription: "Agent 生成的范围与分析成果"
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
@@ -589,8 +590,7 @@ export function getWorkbenchLayerIds(
|
||||
.map((layer) => layer.id);
|
||||
}
|
||||
|
||||
const layerIds = WORKBENCH_LAYER_GROUPS[groupId] ?? [];
|
||||
return layerIds.filter((layerId) => map.getLayer(layerId));
|
||||
return [];
|
||||
}
|
||||
|
||||
function isWaterNetworkSourceId(value: string): value is WaterNetworkSourceId {
|
||||
|
||||
Reference in New Issue
Block a user