feat: extend supply workbench demo and map navigation

This commit is contained in:
2026-09-23 13:19:30 +08:00
parent bebcf2a06c
commit 1752b9ef84
45 changed files with 2137 additions and 449 deletions
+15 -3
View File
@@ -3,11 +3,13 @@ import { describe, expect, it, vi } from "vitest";
import { fitNetworkBounds, getResponsiveWorkbenchPadding, getWorkbenchPadding } from "./camera";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
function createMap(width = 1280, height = 720) {
function createMap(width = 1280, height = 720, bearing = 0, pitch = 0) {
const fitBounds = vi.fn();
const map = {
fitBounds,
getCanvas: () => ({ clientWidth: width, clientHeight: height, width, height })
getBearing: () => bearing,
getCanvas: () => ({ clientWidth: width, clientHeight: height, width, height }),
getPitch: () => pitch
} as unknown as MapLibreMap;
return { fitBounds, map };
@@ -87,10 +89,20 @@ describe("fitNetworkBounds", () => {
expect(bounds[1][0]).toBeCloseTo(121.772482, 6);
expect(bounds[1][1]).toBeCloseTo(31.007207, 6);
expect(options).toMatchObject({
bearing: 0,
duration: 600,
maxZoom: 13,
padding: { top: 96, right: 48, bottom: 48, left: 48 }
padding: { top: 96, right: 48, bottom: 48, left: 48 },
pitch: 0
});
expect(options).not.toHaveProperty("zoom");
});
it("preserves the current 3D orientation when returning to the global extent", () => {
const { fitBounds, map } = createMap(1280, 720, 42, 50);
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
expect(fitBounds.mock.calls[0]?.[1]).toMatchObject({ bearing: 42, pitch: 50 });
});
});
+2
View File
@@ -53,6 +53,8 @@ export function fitNetworkBounds(
map.fitBounds(getLngLatBoundsFromBbox3857(viewParams.bbox3857), {
padding: getResponsivePadding(map, padding),
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
bearing: map.getBearing(),
pitch: map.getPitch(),
duration: 600
});
}
@@ -0,0 +1,87 @@
import maplibregl, { type Map as MapLibreMap } from "maplibre-gl";
import {
CITY_LAYERS,
CITY_ROOT,
cityManifestSchema,
citySources,
findCitySceneInsertionLayerId
} from "./city-scene-layers";
import { Protocol } from "pmtiles";
const cityTileProtocol = new Protocol();
maplibregl.addProtocol("pmtiles", cityTileProtocol.tile);
class CitySceneControl {
private map?: MapLibreMap;
private initialized = false;
private attribution?: maplibregl.AttributionControl;
attach(map: MapLibreMap) {
this.map = map;
map.on("pitchend", this.syncVisibility);
this.syncVisibility();
}
private syncVisibility = () => {
const map = this.map;
if (!map) return;
const enabled = map.getPitch() > 0;
if (enabled && !this.initialized) this.addLayers();
if (!this.initialized) return;
CITY_LAYERS.forEach(({ id }) =>
map.setLayoutProperty(id, "visibility", enabled ? "visible" : "none")
);
};
private addLayers() {
const map = this.map;
if (!map) return;
Object.entries(citySources()).forEach(([id, source]) => map.addSource(id, source));
const firstBusinessLayer = findCitySceneInsertionLayerId(map.getStyle().layers);
CITY_LAYERS.forEach((layer) => map.addLayer(layer, firstBusinessLayer));
this.attribution = new maplibregl.AttributionControl({
compact: true,
customAttribution:
'<a href="/city-model/ATTRIBUTION.md" target="_blank" rel="noopener noreferrer">Qian Shi et al. · 数据来源与许可</a>'
});
map.addControl(this.attribution, "bottom-left");
this.initialized = true;
}
onRemove() {
const map = this.map;
map?.off("pitchend", this.syncVisibility);
if (this.attribution && map?.hasControl(this.attribution)) map.removeControl(this.attribution);
if (map && this.initialized) {
[...CITY_LAYERS].reverse().forEach(({ id }) => {
if (map.getLayer(id)) map.removeLayer(id);
});
Object.keys(citySources()).forEach((id) => {
if (map.getSource(id)) map.removeSource(id);
});
}
this.map = undefined;
}
}
/** Scene is optional; absent local data must not break the operational map. */
export function installCityScene(map: MapLibreMap): () => void {
const abort = new AbortController();
let control: CitySceneControl | undefined;
void fetch(`${CITY_ROOT}/manifest.json`, { signal: abort.signal })
.then(async (response) => {
if (!response.ok) return;
const manifest = cityManifestSchema.safeParse(await response.json());
if (!manifest.success || abort.signal.aborted) return;
control = new CitySceneControl();
control.attach(map);
})
.catch(() => {
/* Optional scene is not deployed on every installation. */
});
return () => {
abort.abort();
control?.onRemove();
};
}
@@ -0,0 +1,56 @@
import type { LayerSpecification } from "maplibre-gl";
import { describe, expect, it } from "vitest";
import {
CITY_LAYERS,
cityManifestSchema,
citySources,
findCitySceneInsertionLayerId
} from "./city-scene-layers";
const manifest = {
schema_version: 1,
name: "奉贤",
bbox: [121.34, 30.68, 121.85, 31.02],
building_count: 1,
pipe_count: 1,
asset_count: 1,
notice: "参考模型",
attribution: "OSM"
};
describe("city scene geographic contract", () => {
it("inserts buildings above every basemap layer and below business layers", () => {
const layers = [
{ id: "background", type: "background" },
{ id: "mapbox-light", type: "raster", source: "mapbox-light" },
{ id: "tianditu-vector", type: "raster", source: "tianditu-vector" },
{
id: "tianditu-vector-annotation",
type: "raster",
source: "tianditu-vector-annotation"
},
{ id: "pipes", type: "line", source: "pipes", "source-layer": "pipes" }
] satisfies LayerSpecification[];
expect(findCitySceneInsertionLayerId(layers)).toBe("pipes");
});
it("adds only the building background and leaves business layers to their existing controls", () => {
expect(Object.keys(citySources())).toEqual(["fengxian-city-buildings"]);
expect(CITY_LAYERS.map((layer) => layer.id)).toEqual(["fengxian-city-buildings"]);
});
it("accepts WGS84 scene bounds", () => {
expect(cityManifestSchema.safeParse(manifest).success).toBe(true);
});
it("rejects 3857 meters passed as longitude and latitude", () => {
expect(
cityManifestSchema.safeParse({ ...manifest, bbox: [13508801, 3608163, 13555650, 3633685] })
.success
).toBe(false);
});
it("rejects reversed extents and malformed counts", () => {
expect(
cityManifestSchema.safeParse({ ...manifest, bbox: [121.85, 30.68, 121.34, 31.02] }).success
).toBe(false);
expect(cityManifestSchema.safeParse({ ...manifest, pipe_count: -1 }).success).toBe(false);
});
});
@@ -0,0 +1,60 @@
import type { LayerSpecification, SourceSpecification } from "maplibre-gl";
import { z } from "zod";
import { BASE_LAYER_IDS } from "./map-control-config";
import { MAP_STYLE_TOKENS as colors } from "./map-colors";
export const CITY_ROOT = "/city-model";
export const CITY_PREFIX = "fengxian-city-";
export function findCitySceneInsertionLayerId(layers: readonly LayerSpecification[]) {
const basemapLayerIds = new Set<string>(BASE_LAYER_IDS);
return layers.find((layer) => layer.id !== "background" && !basemapLayerIds.has(layer.id))?.id;
}
export const cityManifestSchema = z
.object({
schema_version: z.literal(1),
name: z.string(),
bbox: z.tuple([
z.number().min(-180).max(180),
z.number().min(-90).max(90),
z.number().min(-180).max(180),
z.number().min(-90).max(90)
]),
building_count: z.number().int().nonnegative(),
pipe_count: z.number().int().nonnegative(),
asset_count: z.number().int().nonnegative(),
notice: z.string(),
attribution: z.string()
})
.refine(({ bbox: [w, s, e, n] }) => w < e && s < n, "Invalid scene bounds");
export function citySources(): Record<string, SourceSpecification> {
return {
[CITY_PREFIX + "buildings"]: {
type: "vector",
url: `pmtiles://${window.location.origin}${CITY_ROOT}/buildings.pmtiles`,
minzoom: 12,
maxzoom: 16,
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a> · <a href="https://docs.overturemaps.org/attribution/">Overture Maps</a>',
promoteId: "id"
}
};
}
export const CITY_LAYERS: LayerSpecification[] = [
{
id: CITY_PREFIX + "buildings",
source: CITY_PREFIX + "buildings",
"source-layer": "buildings",
type: "fill-extrusion",
minzoom: 12,
paint: {
"fill-extrusion-color": colors.canvas.secondary,
"fill-extrusion-height": ["to-number", ["get", "height_m"], 6],
"fill-extrusion-base": 0,
"fill-extrusion-opacity": 1,
"fill-extrusion-vertical-gradient": true
}
}
];
@@ -3,30 +3,61 @@ import { describe, expect, it } from "vitest";
import { waterNetworkLayers } from "./layers";
import { MAP_STYLE_TOKENS } from "./map-colors";
import {
BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS,
applyBaseLayerVisibility,
createLayerControlItems,
createVisibleMapLegendItems,
getWorkbenchLayerIds
} from "./map-control-config";
describe("workbench supply layer controls", () => {
it("switches Tianditu basemaps as base and annotation layer pairs", () => {
const visibility = new Map<string, string>();
const map = {
getLayer: () => ({}),
setLayoutProperty: (layerId: string, _property: string, value: string) => {
visibility.set(layerId, value);
}
} as unknown as MapLibreMap;
expect(BASE_LAYER_OPTIONS.map((option) => option.id)).toEqual([
"mapbox-light",
"mapbox-satellite",
"tianditu-vector",
"tianditu-image",
"none"
]);
applyBaseLayerVisibility(map, "tianditu-image");
expect(visibility.get("tianditu-image")).toBe("visible");
expect(visibility.get("tianditu-image-annotation")).toBe("visible");
expect(visibility.get("tianditu-vector")).toBe("none");
expect(visibility.get("tianditu-vector-annotation")).toBe("none");
expect(visibility.get("mapbox-light")).toBe("none");
expect(visibility.get("mapbox-satellite")).toBe("none");
});
it("creates controls only for available supply system sources", () => {
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY, undefined, {
fx_20200423_pipes: 215_797,
fx_20200811_dn100_pipes: 122_686
});
expect(items.map((item) => item.id)).toEqual(expect.arrayContaining([
"pipes",
"junctions",
"scada-pressure",
"scada-flow",
"water_quality_monitoring_points",
"fx_20200423_pipes",
"fx_20200811_dn100_pipes",
"simulation"
]));
expect(items.map((item) => item.id)).toEqual(
expect.arrayContaining([
"pipes",
"junctions",
"scada-pressure",
"scada-flow",
"water_quality_monitoring_points",
"fx_20200423_pipes",
"fx_20200811_dn100_pipes",
"simulation"
])
);
expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({
group: "source-gis",
groupLabel: "GIS 原始数据",
@@ -99,15 +130,25 @@ describe("workbench supply layer controls", () => {
"source-non-residential-meter",
"impact-area"
]);
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({ imageSrc: "/map/valve.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({
imageSrc: "/map/valve.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "reservoir")).toMatchObject({
label: "水库",
imageSrc: "/map/reservoir.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "pump")).toMatchObject({ imageSrc: "/map/pump.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "tank")).toMatchObject({ imageSrc: "/map/tank.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-flow")).toMatchObject({ imageSrc: "/map/scada-flow.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({ imageSrc: "/map/scada-pressure.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "pump")).toMatchObject({
imageSrc: "/map/pump.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "tank")).toMatchObject({
imageSrc: "/map/tank.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-flow")).toMatchObject({
imageSrc: "/map/scada-flow.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({
imageSrc: "/map/scada-pressure.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({
color: MAP_STYLE_TOKENS.state.inactive,
lineWidth: 2.5,
@@ -133,7 +174,9 @@ describe("workbench supply layer controls", () => {
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-hydrant")).toMatchObject({
imageSrc: "/map/gis-hydrant.svg"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-non-residential-meter")).toMatchObject({
expect(
MAP_LEGEND_ITEMS.find((item) => item.id === "source-non-residential-meter")
).toMatchObject({
imageSrc: "/map/gis-non-residential-meter.svg"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "impact-area")).toMatchObject({
@@ -147,32 +190,27 @@ describe("workbench supply layer controls", () => {
const initialItems = createLayerControlItems(INITIAL_LAYER_VISIBILITY);
const initialLegendIds = createVisibleMapLegendItems(initialItems).map((item) => item.id);
expect(initialLegendIds).toEqual(expect.arrayContaining([
"major-pipe",
"valve",
"scada-flow",
"water-quality-monitoring"
]));
expect(initialLegendIds).not.toEqual(expect.arrayContaining([
"source-major-pipe",
"source-inventory-pipe",
"impact-area"
]));
expect(initialLegendIds).toEqual(
expect.arrayContaining(["major-pipe", "valve", "scada-flow", "water-quality-monitoring"])
);
expect(initialLegendIds).not.toEqual(
expect.arrayContaining(["source-major-pipe", "source-inventory-pipe", "impact-area"])
);
const sourcePipeItems = createLayerControlItems({
...INITIAL_LAYER_VISIBILITY,
fx_20200423_pipes: true
});
const sourcePipeLegendIds = createVisibleMapLegendItems(sourcePipeItems).map(
(item) => item.id
);
const sourcePipeLegendIds = createVisibleMapLegendItems(sourcePipeItems).map((item) => item.id);
expect(sourcePipeLegendIds).toEqual(expect.arrayContaining([
"source-major-pipe",
"source-medium-pipe",
"source-minor-pipe",
"source-unknown-pipe"
]));
expect(sourcePipeLegendIds).toEqual(
expect.arrayContaining([
"source-major-pipe",
"source-medium-pipe",
"source-minor-pipe",
"source-unknown-pipe"
])
);
expect(sourcePipeLegendIds).not.toContain("source-inventory-pipe");
const reducedItems = createLayerControlItems({
+184 -49
View File
@@ -11,11 +11,7 @@ import {
SUPPLY_LAYER_CATALOG,
type WaterNetworkSourceId
} from "./sources";
import {
SCADA_ICON_PATHS,
SCADA_LAYER_CONTROL_ITEMS,
SCADA_LAYER_GROUPS
} from "./scada";
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[]> = {
@@ -33,18 +29,17 @@ export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
...Object.fromEntries(
SUPPLY_LAYER_CATALOG
.filter((layer) => layer.id !== "scada")
.map((layer) => [layer.id, layer.initialVisible])
SUPPLY_LAYER_CATALOG.filter((layer) => layer.id !== "scada").map((layer) => [
layer.id,
layer.initialVisible
])
),
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true])),
simulation: false
};
const SOURCE_CONTROL_LABELS = Object.fromEntries(
SUPPLY_LAYER_CATALOG
.filter((layer) => layer.available)
.map((layer) => [layer.id, layer.label])
SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => [layer.id, layer.label])
) as Record<WaterNetworkSourceId, string>;
type LayerControlPresentation = {
@@ -55,7 +50,10 @@ type LayerControlPresentation = {
visual: MapLayerControlVisual;
};
const LAYER_CONTROL_PRESENTATION: Record<Exclude<WaterNetworkSourceId, "scada">, LayerControlPresentation> = {
const LAYER_CONTROL_PRESENTATION: Record<
Exclude<WaterNetworkSourceId, "scada">,
LayerControlPresentation
> = {
pipes: {
description: "按管径分级",
minZoom: 8.5,
@@ -258,14 +256,70 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
layerControlId: "pipes",
...MODEL_PIPE_LEGEND_GROUP
},
{ id: "junction", label: "节点", color: MAP_STYLE_TOKENS.supply.junction, shape: "dot", layerControlId: "junctions", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "valve", label: "阀门", color: MAP_STYLE_TOKENS.supply.valve, imageSrc: SUPPLY_ASSET_ICON_PATHS.valve, layerControlId: "valves", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "reservoir", label: "水库", color: MAP_STYLE_TOKENS.supply.reservoir, imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir, layerControlId: "reservoirs", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "pump", label: "水泵", color: MAP_STYLE_TOKENS.supply.pump, imageSrc: SUPPLY_ASSET_ICON_PATHS.pump, layerControlId: "pumps", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "tank", label: "水箱", color: MAP_STYLE_TOKENS.supply.tank, imageSrc: SUPPLY_ASSET_ICON_PATHS.tank, layerControlId: "tanks", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "scada-flow", label: "流量监测", color: MAP_STYLE_TOKENS.supply.scadaFlow, imageSrc: SCADA_ICON_PATHS.flow, layerControlId: "scada-flow", ...MONITORING_LEGEND_GROUP },
{ id: "scada-pressure", label: "压力监测", color: MAP_STYLE_TOKENS.supply.scadaPressure, imageSrc: SCADA_ICON_PATHS.pressure, layerControlId: "scada-pressure", ...MONITORING_LEGEND_GROUP },
{ id: "water-quality-monitoring", label: "水质监测", color: MAP_STYLE_TOKENS.supply.waterQuality, imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality, layerControlId: "water_quality_monitoring_points", ...MONITORING_LEGEND_GROUP },
{
id: "junction",
label: "节点",
color: MAP_STYLE_TOKENS.supply.junction,
shape: "dot",
layerControlId: "junctions",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "valve",
label: "阀门",
color: MAP_STYLE_TOKENS.supply.valve,
imageSrc: SUPPLY_ASSET_ICON_PATHS.valve,
layerControlId: "valves",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "reservoir",
label: "水库",
color: MAP_STYLE_TOKENS.supply.reservoir,
imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir,
layerControlId: "reservoirs",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "pump",
label: "水泵",
color: MAP_STYLE_TOKENS.supply.pump,
imageSrc: SUPPLY_ASSET_ICON_PATHS.pump,
layerControlId: "pumps",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "tank",
label: "水箱",
color: MAP_STYLE_TOKENS.supply.tank,
imageSrc: SUPPLY_ASSET_ICON_PATHS.tank,
layerControlId: "tanks",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "scada-flow",
label: "流量监测",
color: MAP_STYLE_TOKENS.supply.scadaFlow,
imageSrc: SCADA_ICON_PATHS.flow,
layerControlId: "scada-flow",
...MONITORING_LEGEND_GROUP
},
{
id: "scada-pressure",
label: "压力监测",
color: MAP_STYLE_TOKENS.supply.scadaPressure,
imageSrc: SCADA_ICON_PATHS.pressure,
layerControlId: "scada-pressure",
...MONITORING_LEGEND_GROUP
},
{
id: "water-quality-monitoring",
label: "水质监测",
color: MAP_STYLE_TOKENS.supply.waterQuality,
imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality,
layerControlId: "water_quality_monitoring_points",
...MONITORING_LEGEND_GROUP
},
{
id: "source-major-pipe",
label: "DN600 及以上",
@@ -311,36 +365,101 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
layerControlId: "fx_20200811_dn100_pipes",
...SOURCE_AUGUST_PIPE_LEGEND_GROUP
},
{ id: "source-valve", label: "GIS 阀门", color: MAP_STYLE_TOKENS.supply.sourceValve, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve, layerControlId: "fx_20200423_valves", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-drain-valve", label: "排水阀", color: MAP_STYLE_TOKENS.supply.sourceDrainValve, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve, layerControlId: "fx_20200423_drain_valves", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-flow-meter", label: "流量仪", color: MAP_STYLE_TOKENS.supply.sourceFlowMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter, layerControlId: "fx_20200423_flow_meters", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-hydrant", label: "消火栓", color: MAP_STYLE_TOKENS.supply.sourceHydrant, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant, layerControlId: "fx_20200423_hydrants", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-connection", label: "接水点", color: MAP_STYLE_TOKENS.supply.sourceConnection, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection, layerControlId: "fx_20200423_service_connections", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-customer-meter", label: "用户水表", color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter, layerControlId: "fx_20200423_customer_meters", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-non-residential-meter", label: "非居民水表", color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter, 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 }
{
id: "source-valve",
label: "GIS 阀门",
color: MAP_STYLE_TOKENS.supply.sourceValve,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve,
layerControlId: "fx_20200423_valves",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-drain-valve",
label: "排水阀",
color: MAP_STYLE_TOKENS.supply.sourceDrainValve,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve,
layerControlId: "fx_20200423_drain_valves",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-flow-meter",
label: "流量仪",
color: MAP_STYLE_TOKENS.supply.sourceFlowMeter,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter,
layerControlId: "fx_20200423_flow_meters",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-hydrant",
label: "消火栓",
color: MAP_STYLE_TOKENS.supply.sourceHydrant,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant,
layerControlId: "fx_20200423_hydrants",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-connection",
label: "接水点",
color: MAP_STYLE_TOKENS.supply.sourceConnection,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection,
layerControlId: "fx_20200423_service_connections",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-customer-meter",
label: "用户水表",
color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter,
layerControlId: "fx_20200423_customer_meters",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-non-residential-meter",
label: "非居民水表",
color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter,
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">[]
) {
const visibleLayerIds = new Set(
layerItems.filter((item) => item.visible).map((item) => item.id)
);
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
);
}
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
export const BASE_LAYER_GROUPS = {
"mapbox-light": ["mapbox-light"],
"mapbox-satellite": ["mapbox-satellite"],
"tianditu-vector": ["tianditu-vector", "tianditu-vector-annotation"],
"tianditu-image": ["tianditu-image", "tianditu-image-annotation"]
} as const;
export const BASE_LAYER_IDS = Object.values(BASE_LAYER_GROUPS).flat();
export function applyBaseLayerVisibility(map: MapLibreMap, activeBaseLayerId: string) {
const activeLayerIds = new Set<string>(
BASE_LAYER_GROUPS[activeBaseLayerId as keyof typeof BASE_LAYER_GROUPS] ?? []
);
BASE_LAYER_IDS.forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(
layerId,
"visibility",
activeBaseLayerId === layerId ? "visible" : "none"
activeLayerIds.has(layerId) ? "visible" : "none"
);
}
});
@@ -359,6 +478,18 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
description: "卫星街道",
swatchClassName: "bg-gradient-to-br from-emerald-950 via-emerald-700 to-yellow-200"
},
{
id: "tianditu-vector",
label: "天地图矢量",
description: "矢量与中文注记",
swatchClassName: "bg-gradient-to-br from-sky-50 via-stone-100 to-emerald-100"
},
{
id: "tianditu-image",
label: "天地图影像",
description: "影像与中文注记",
swatchClassName: "bg-gradient-to-br from-slate-950 via-emerald-800 to-stone-300"
},
{
id: "none",
label: "无底图",
@@ -390,20 +521,23 @@ export function createLayerControlItems(
groupLabel: "管网模型",
groupDescription: "计算模型与基础设施"
})),
...(available.has("scada") ? SCADA_LAYER_CONTROL_ITEMS.map((item) => ({
id: item.id,
label: item.label,
description: item.description,
minZoom: 8.5,
visual: {
kind: "image" as const,
imageSrc: item.id === "scada-pressure" ? SCADA_ICON_PATHS.pressure : SCADA_ICON_PATHS.flow
},
visible: layerVisibility[item.id],
group: "monitoring",
groupLabel: "监测点",
groupDescription: "在线监测与分析选点"
})) : []),
...(available.has("scada")
? SCADA_LAYER_CONTROL_ITEMS.map((item) => ({
id: item.id,
label: item.label,
description: item.description,
minZoom: 8.5,
visual: {
kind: "image" as const,
imageSrc:
item.id === "scada-pressure" ? SCADA_ICON_PATHS.pressure : SCADA_ICON_PATHS.flow
},
visible: layerVisibility[item.id],
group: "monitoring",
groupLabel: "监测点",
groupDescription: "在线监测与分析选点"
}))
: []),
...monitoringItems.map((layer) => ({
id: layer.id,
label: SOURCE_CONTROL_LABELS[layer.id],
@@ -443,8 +577,9 @@ export function getWorkbenchLayerIds(
groupId: string
) {
if (groupId in SCADA_LAYER_GROUPS) {
return SCADA_LAYER_GROUPS[groupId as keyof typeof SCADA_LAYER_GROUPS]
.filter((layerId) => map.getLayer(layerId));
return SCADA_LAYER_GROUPS[groupId as keyof typeof SCADA_LAYER_GROUPS].filter((layerId) =>
map.getLayer(layerId)
);
}
if (isWaterNetworkSourceId(groupId)) {
return map
+43 -2
View File
@@ -24,8 +24,9 @@ describe("createWaterNetworkSources", () => {
});
expect(WATER_NETWORK_SOURCE_IDS).toContain("water_quality_monitoring_points");
expect(WATER_NETWORK_SOURCE_IDS).toContain("fx_20200423_non_residential_meters");
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "water_quality_monitoring_points"))
.toMatchObject({ available: true, availability: "probe", group: "monitoring" });
expect(
SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "water_quality_monitoring_points")
).toMatchObject({ available: true, availability: "probe", group: "monitoring" });
expect(SUPPLY_LAYER_CATALOG.filter((layer) => layer.group === "source-gis")).toHaveLength(9);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
geometry: "point",
@@ -63,6 +64,46 @@ describe("createWaterNetworkSources", () => {
});
});
it("creates paired Tianditu vector and imagery sources only when configured", () => {
expect(createBaseStyle().sources).not.toHaveProperty("tianditu-vector");
const style = createBaseStyle(undefined, "test-tianditu-token");
expect(style.sources["tianditu-vector"]).toMatchObject({
type: "raster",
tileSize: 256,
maxzoom: 18,
tiles: [expect.stringContaining("/vec_w/wmts?")]
});
expect(style.sources["tianditu-vector-annotation"]).toMatchObject({
tiles: [expect.stringMatching(/LAYER=cva.*tk=test-tianditu-token/)]
});
expect(style.sources["tianditu-image"]).toMatchObject({
tiles: [expect.stringMatching(/LAYER=img.*tk=test-tianditu-token/)]
});
expect(style.sources["tianditu-image-annotation"]).toMatchObject({
tiles: [expect.stringMatching(/LAYER=cia.*tk=test-tianditu-token/)]
});
expect(style.layers?.find((layer) => layer.id === "tianditu-vector")?.layout).toMatchObject({
visibility: "visible"
});
expect(
style.layers?.find((layer) => layer.id === "tianditu-vector-annotation")?.layout
).toMatchObject({ visibility: "visible" });
expect(style.layers?.find((layer) => layer.id === "tianditu-image")?.layout).toMatchObject({
visibility: "none"
});
});
it("keeps Tianditu hidden when Mapbox is the initial basemap", () => {
const style = createBaseStyle("mapbox-token", "tianditu-token");
expect(style.layers?.find((layer) => layer.id === "tianditu-vector")?.layout).toMatchObject({
visibility: "none"
});
expect(style.layers?.find((layer) => layer.id === "mapbox-light")?.layout).toBeUndefined();
});
it("creates only the requested GeoServer vector sources", () => {
const sources = createWaterNetworkSources(["pipes", "junctions"]);
+63 -6
View File
@@ -2,10 +2,7 @@ import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import { MAP_STYLE_TOKENS } from "./map-colors";
import {
SCADA_ANALYSIS_SOURCE_ID,
createEmptyScadaAnalysisCollection
} from "./scada-analysis";
import { SCADA_ANALYSIS_SOURCE_ID, createEmptyScadaAnalysisCollection } from "./scada-analysis";
import { SCADA_SOURCE_ID } from "./scada";
export const WATER_NETWORK_GLOBAL_VIEW = {
@@ -280,7 +277,10 @@ export function isAvailableWaterNetworkSourceId(
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
}
export function createWaterNetworkSources(): Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>;
export function createWaterNetworkSources(): Record<
GeoServerWaterNetworkSourceId,
VectorSourceSpecification
>;
export function createWaterNetworkSources(
sourceIds: readonly GeoServerWaterNetworkSourceId[]
): Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
@@ -312,7 +312,11 @@ function createGeoServerVectorSource(
};
}
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
function createTiandituTileUrl(layer: "vec" | "cva" | "img" | "cia", token: string) {
return `https://t0.tianditu.gov.cn/${layer}_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=${layer}&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${token}`;
}
export function createBaseStyle(mapboxToken?: string, tiandituToken?: string): StyleSpecification {
const sources: StyleSpecification["sources"] = {
[SCADA_SOURCE_ID]: {
type: "geojson",
@@ -379,6 +383,59 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
});
}
if (tiandituToken) {
const tiandituSources = [
["tianditu-vector", "vec"],
["tianditu-vector-annotation", "cva"],
["tianditu-image", "img"],
["tianditu-image-annotation", "cia"]
] as const;
tiandituSources.forEach(([sourceId, layer]) => {
sources[sourceId] = {
type: "raster",
tiles: [createTiandituTileUrl(layer, tiandituToken)],
tileSize: 256,
maxzoom: 18,
attribution: '© <a href="https://www.tianditu.gov.cn/">天地图</a>'
};
});
const vectorVisible = mapboxToken ? "none" : "visible";
layers.push(
{
id: "tianditu-vector",
type: "raster",
source: "tianditu-vector",
layout: { visibility: vectorVisible },
paint: { "raster-opacity": 0.9 }
},
{
id: "tianditu-vector-annotation",
type: "raster",
source: "tianditu-vector-annotation",
layout: { visibility: vectorVisible }
},
{
id: "tianditu-image",
type: "raster",
source: "tianditu-image",
layout: { visibility: "none" },
paint: {
"raster-opacity": 0.9,
"raster-saturation": -0.12,
"raster-contrast": 0.06
}
},
{
id: "tianditu-image-annotation",
type: "raster",
source: "tianditu-image-annotation",
layout: { visibility: "none" }
}
);
}
return {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",