feat: extend supply workbench demo and map navigation
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user