feat: refine supply map layers and workbench branding

This commit is contained in:
2026-10-27 17:56:38 +08:00
parent 6983ba03f1
commit 4f0f34f460
23 changed files with 948 additions and 171 deletions
+163
View File
@@ -0,0 +1,163 @@
import { expect, test, type Page } from "@playwright/test";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { mockPipeVectorTiles } from "./support/mock-pipe-vector-tiles";
declare global {
interface Window { __layerFadeSamples: number[]; __layerFadePixels: number[]; }
}
async function openLayerFixture(page: Page, vectorPipes = false) {
await mockWorkbenchContext(page);
if (vectorPipes) await mockPipeVectorTiles(page);
await page.route("https://workbench-map.invalid/geoserver/tjwater_next/ows?**", (route) => {
const type = new URL(route.request().url()).searchParams.get("typeNames");
return route.fulfill({ contentType: "application/xml",
body: `<wfs:FeatureCollection xmlns:wfs="http://www.opengis.net/wfs/2.0" numberMatched="${type?.endsWith(":reservoirs") || type?.endsWith(":water_quality_monitoring_points") ? 1 : 0}"/>` });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect.poll(() => page.evaluate(() => Boolean(
window.__waterNetworkMap?.getLayer("reservoirs-symbol")
)), { timeout: 15_000 }).toBe(true);
await page.evaluate((vectorPipes) => {
const map = window.__waterNetworkMap!;
for (const sourceId of ["pipes", "reservoirs", "water_quality_monitoring_points"]) {
if (vectorPipes && sourceId === "pipes") continue;
const layers = map.getStyle().layers.filter((layer) => "source" in layer && layer.source === sourceId);
[...layers].reverse().forEach((layer) => map.removeLayer(layer.id));
map.removeSource(sourceId);
map.addSource(sourceId, { type: "geojson", data: { type: "FeatureCollection", features: [{
type: "Feature", properties: { id: "fade-fixture", diameter: 800 },
geometry: sourceId === "pipes"
? { type: "LineString", coordinates: [[121.45, 30.91], [121.48, 30.91]] }
: { type: "Point", coordinates: [sourceId === "reservoirs" ? 121.46 : 121.47, 30.915] }
}] } });
layers.forEach((layer) => {
const copy = { ...layer };
if ("source-layer" in copy) delete copy["source-layer"];
map.addLayer(copy);
});
}
const scada = map.getSource("scada");
if (scada && "setData" in scada) scada.setData({ type: "FeatureCollection", features: [{
type: "Feature", id: "pressure-size-fixture",
properties: { device_id: "pressure-size-fixture", device_type: "pressure" },
geometry: { type: "Point", coordinates: [121.475, 30.915] }
}] });
map.jumpTo({ center: [121.465, 30.913], zoom: 13, pitch: 0, bearing: 0 });
}, vectorPipes);
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
}
for (const [label, layerId, property] of [
["管线", "pipes-flow", "line-opacity"],
["水库", "reservoirs-symbol", "icon-opacity"],
["水质监测点", "water_quality_monitoring_points-symbol", "icon-opacity"]
] as const) {
test(`${label} fades on toggle and restores its original paint`, async ({ page }, testInfo) => {
await openLayerFixture(page);
const original = await page.evaluate(({ layerId, property }) => {
const map = window.__waterNetworkMap!;
const original = map.getPaintProperty(layerId, property);
const setPaint = map.setPaintProperty.bind(map);
window.__layerFadeSamples = [];
map.setPaintProperty = (id, name, value, options) => {
if (id === layerId && name === property) {
if (typeof value === "number" && typeof original === "number") {
window.__layerFadeSamples.push(value / original);
}
const output = Array.isArray(value) && value[0] === "interpolate" ? value[4] : value;
if (Array.isArray(output) && output[0] === "*") {
window.__layerFadeSamples.push(Number(output.at(-1)));
}
}
return setPaint(id, name, value, options);
};
return original;
}, { layerId, property });
const toggle = page.getByRole("button", { name: new RegExp(`^${label},`) });
await toggle.click();
await expect.poll(() => page.evaluate((id) => window.__waterNetworkMap!
.getLayoutProperty(id, "visibility"), layerId)).toBe("none");
const hiddenSamples = await page.evaluate(() => window.__layerFadeSamples);
expect(hiddenSamples.some((value) => value > 0 && value < 1)).toBe(true);
expect(await page.evaluate(({ layerId, property }) => window.__waterNetworkMap!
.getPaintProperty(layerId, property), { layerId, property })).toEqual(original);
await toggle.click();
await expect.poll(() => page.evaluate(({ layerId, property }) => window.__waterNetworkMap!
.getPaintProperty(layerId, property), { layerId, property })).toEqual(original);
await page.screenshot({ path: testInfo.outputPath("layer-visible.png") });
await toggle.click();
await toggle.click();
await expect.poll(() => page.evaluate((id) => window.__waterNetworkMap!
.getLayoutProperty(id, "visibility"), layerId)).toBe("visible");
await expect.poll(() => page.evaluate(({ layerId, property }) => window.__waterNetworkMap!
.getPaintProperty(layerId, property), { layerId, property })).toEqual(original);
});
}
test("reduced motion switches layers without a fade", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await openLayerFixture(page);
await page.getByRole("button", { name: /^管线,/ }).click();
expect(await page.evaluate(() => window.__waterNetworkMap!
.getLayoutProperty("pipes-flow", "visibility"))).toBe("none");
});
for (const [label, layerId, property, longitude, latitude] of [
["管线", "pipes-flow", "line-opacity", 121.465, 30.91],
["水库", "reservoirs-symbol", "icon-opacity", 121.46, 30.915],
["水质监测点", "water_quality_monitoring_points-symbol", "icon-opacity", 121.47, 30.915]
] as const) {
test(`${label} has intermediate rendered pixels while fading in and out`, async ({ page }, testInfo) => {
await openLayerFixture(page, label === "管线");
await expect.poll(() => page.evaluate((id) => window.__waterNetworkMap!
.queryRenderedFeatures({ layers: [id] }).length, layerId)).toBeGreaterThan(0);
await page.evaluate(() => new Promise<void>((resolve) => {
const map = window.__waterNetworkMap!;
map.once("idle", () => resolve());
map.triggerRepaint();
}));
await page.screenshot({ path: testInfo.outputPath("fade-before.png") });
const original = await page.evaluate(({ layerId, property, longitude, latitude }) => {
const map = window.__waterNetworkMap!;
const canvas = map.getCanvas();
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
if (!gl) throw new Error("No WebGL context");
window.__layerFadePixels = [];
map.on("render", () => {
const point = map.project([longitude, latitude]);
const ratio = canvas.width / canvas.clientWidth;
const pixels = new Uint8Array(7 * 7 * 4);
gl.readPixels(Math.round(point.x * ratio) - 3,
canvas.height - Math.round(point.y * ratio) - 3, 7, 7, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let darkest = 0;
for (let index = 0; index < pixels.length; index += 4) {
darkest = Math.max(darkest, 765 - pixels[index] - pixels[index + 1] - pixels[index + 2]);
}
window.__layerFadePixels.push(darkest);
});
map.triggerRepaint();
return map.getPaintProperty(layerId, property);
}, { layerId, property, longitude, latitude });
const toggle = page.getByRole("button", { name: new RegExp(`^${label},`) });
await toggle.click();
await expect.poll(() => page.evaluate((id) => window.__waterNetworkMap!
.getLayoutProperty(id, "visibility"), layerId)).toBe("none");
const hiding = await page.evaluate(() => window.__layerFadePixels);
const min = Math.min(...hiding);
const max = Math.max(...hiding);
await testInfo.attach("hiding-pixels", { body: JSON.stringify(hiding), contentType: "application/json" });
expect(max - min).toBeGreaterThan(50);
// Require multiple rendered intermediate alpha states without prescribing a GPU frame rate.
expect(new Set(hiding.filter((pixel) => pixel > min + 10 && pixel < max - 10)).size).toBeGreaterThan(1);
await page.evaluate(() => { window.__layerFadePixels = []; });
await toggle.click();
await expect.poll(() => page.evaluate(({ layerId, property }) => window.__waterNetworkMap!
.getPaintProperty(layerId, property), { layerId, property })).toEqual(original);
const showing = await page.evaluate(() => window.__layerFadePixels);
expect(new Set(showing.filter((pixel) => pixel > min + 10 && pixel < max - 10)).size).toBeGreaterThan(1);
await testInfo.attach("rendered-opacity", {
body: JSON.stringify({ hiding, showing }), contentType: "application/json"
});
});
}
+199
View File
@@ -0,0 +1,199 @@
import { expect, test } from "@playwright/test";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test("keeps pipes and SCADA visible across fractional zoom changes", async ({ page }, testInfo) => {
await mockWorkbenchContext(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect.poll(() => page.evaluate(() => Boolean(
window.__waterNetworkMap?.getLayer("pipes-flow")
&& window.__waterNetworkMap?.getLayer("scada-pressure-symbol")
)), { timeout: 15_000 }).toBe(true);
await page.evaluate(() => {
const map = window.__waterNetworkMap!;
map.addSource("scale-fixture", {
type: "geojson",
data: {
type: "FeatureCollection",
features: [
...[800, 400, 100, null].map((diameter, index) => ({
type: "Feature" as const,
id: `pipe-${index}`,
properties: { diameter, fixture_id: `pipe-${index}` },
geometry: {
type: "LineString" as const,
coordinates: [[121.46, 30.91 + index * 0.001], [121.47, 30.91 + index * 0.001]]
}
})),
...["pipe_flow", "pressure"].map((deviceType, index) => ({
type: "Feature" as const,
id: deviceType,
properties: { device_type: deviceType, fixture_id: deviceType },
geometry: {
type: "Point" as const,
coordinates: [121.462 + index * 0.008, 30.915]
}
}))
]
}
});
for (const id of ["pipes-casing", "pipes-flow", "pipes-hit", "scada-flow-symbol", "scada-pressure-symbol"]) {
const original = map.getStyle().layers.find((layer) => layer.id === id)!;
const copy = { ...original };
if ("source-layer" in copy) delete copy["source-layer"];
map.addLayer({
...copy,
id: `scale-fixture-${id}`,
source: "scale-fixture",
filter: ["all", ["==", ["geometry-type"], original.type === "line" ? "LineString" : "Point"],
"filter" in original ? original.filter ?? true : true]
});
}
map.jumpTo({ center: [121.465, 30.913], zoom: 11.7, pitch: 0, bearing: 0 });
});
const pipeIds = ["pipe-0", "pipe-1", "pipe-2", "pipe-3"];
for (const [zoom, scadaIds] of [
[11.7, ["pipe_flow", "pressure"]],
[12, ["pipe_flow", "pressure"]],
[13, ["pipe_flow", "pressure"]],
[14, ["pipe_flow", "pressure"]],
[16, ["pipe_flow", "pressure"]],
[11.7, ["pipe_flow", "pressure"]]
] as const) {
await page.evaluate((value) => window.__waterNetworkMap!.jumpTo({ zoom: value }), zoom);
await expect.poll(() => page.evaluate(() => {
const map = window.__waterNetworkMap!;
const ids = (layers: string[]) => [...new Set(
map.queryRenderedFeatures({ layers }).map((feature) => String(feature.properties.fixture_id))
)].sort();
return {
pipes: ids(["scale-fixture-pipes-flow"]),
hits: ids(["scale-fixture-pipes-hit"]),
scada: ids(["scale-fixture-scada-flow-symbol", "scale-fixture-scada-pressure-symbol"])
};
})).toEqual({ pipes: [...pipeIds], hits: [...pipeIds], scada: [...scadaIds] });
await page.screenshot({ path: testInfo.outputPath(`zoom-${zoom}.png`) });
}
});
for (const width of [1440, 390]) {
test(`declutters dense facilities and preserves selection at width ${width}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await mockWorkbenchContext(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect.poll(() => page.evaluate(() => Boolean(
window.__waterNetworkMap?.getLayer("pipes-flow")
)), { timeout: 15_000 }).toBe(true);
await page.evaluate(async () => {
const layerModuleUrl = "/src/features/workbench/map/layers.ts";
const { waterNetworkLayers } = await import(layerModuleUrl);
const scadaModuleUrl = "/src/features/workbench/map/scada.ts";
const { scadaLayers } = await import(scadaModuleUrl);
const layers = [...waterNetworkLayers, ...scadaLayers];
const map = window.__waterNetworkMap!;
map.addSource("density-fixture", {
type: "geojson",
promoteId: "fixture_id",
data: {
type: "FeatureCollection",
features: [
...Array.from({ length: 70 }, (_, index) => ({
type: "Feature" as const,
properties: { fixture_id: `valve-${index}`, kind: "valve" },
geometry: {
type: "Point" as const,
coordinates: [121.4635 + index % 10 * 0.00035, 30.912 + Math.floor(index / 10) * 0.00035]
}
})),
{
type: "Feature" as const,
properties: { fixture_id: "reservoir", kind: "reservoir" },
geometry: { type: "Point" as const, coordinates: [121.465, 30.913] }
},
...[
["pump", [121.464, 30.9114]],
["tank", [121.466, 30.9114]],
["water-quality", [121.465, 30.9098]],
["pressure", [121.505, 30.913]],
["junction", [121.4648, 30.9138]],
["gis-detail", [121.466, 30.9138]],
["gis-flow", [121.467, 30.9138]]
].map(([kind, coordinates]) => ({
type: "Feature" as const,
properties: { fixture_id: kind, kind },
geometry: { type: "Point" as const, coordinates }
}))
]
}
});
const kinds: Record<string, string> = {
"reservoirs-symbol": "reservoir", "pumps-symbol": "pump", "tanks-symbol": "tank",
"water_quality_monitoring_points-symbol": "water-quality",
"water_quality_monitoring_points-hit": "water-quality",
"scada-pressure-symbol": "pressure",
junctions: "junction", "junctions-hit": "junction",
"fx_20200423_hydrants-point": "gis-detail", "fx_20200423_hydrants-hit": "gis-detail",
"fx_20200423_flow_meters-point": "gis-flow", "fx_20200423_flow_meters-hit": "gis-flow"
};
for (const id of ["valves-casing", "valves", "valves-symbol", ...Object.keys(kinds),
"valves-selected-outer", "valves-selected", "valves-hit"]) {
const original = layers.find((layer: { id: string }) => layer.id === id);
const copy = { ...original };
delete copy["source-layer"];
map.addLayer({
...copy, id: `density-${id}`, source: "density-fixture",
filter: ["==", ["get", "kind"], kinds[id] ?? "valve"]
});
}
map.jumpTo({ center: [121.465, 30.913], zoom: 13.1, pitch: 0, bearing: 0 });
});
const renderedCounts = () => page.evaluate(() => {
const map = window.__waterNetworkMap!;
const count = (id: string) => new Set(
map.queryRenderedFeatures({ layers: [id] }).map((feature) => feature.properties.fixture_id)
).size;
return { dots: count("density-valves"), symbols: count("density-valves-symbol"), hits: count("density-valves-hit"),
reservoir: count("density-reservoirs-symbol"), pump: count("density-pumps-symbol"),
tank: count("density-tanks-symbol"), waterQuality: count("density-water_quality_monitoring_points-symbol"),
waterQualityHit: count("density-water_quality_monitoring_points-hit"), pressure: count("density-scada-pressure-symbol"),
nodes: count("density-junctions"), nodeHits: count("density-junctions-hit"),
gisDetails: count("density-fx_20200423_hydrants-point"), gisDetailHits: count("density-fx_20200423_hydrants-hit"),
gisFlow: count("density-fx_20200423_flow_meters-point"), gisFlowHits: count("density-fx_20200423_flow_meters-hit") };
});
await expect.poll(renderedCounts).toMatchObject({ dots: 70, reservoir: 1, pump: 1, tank: 1, waterQuality: 1 });
const overview = await renderedCounts();
expect(overview.symbols).toBeLessThan(overview.dots / 3);
await page.screenshot({ path: testInfo.outputPath("dense-overview.png") });
await page.evaluate(() => window.__waterNetworkMap!.jumpTo({ zoom: 10.99 }));
await expect.poll(renderedCounts).toMatchObject({ waterQuality: 0, waterQualityHit: 0, pressure: 0, gisFlow: 0, gisFlowHits: 0 });
await page.evaluate(() => window.__waterNetworkMap!.jumpTo({ zoom: 11 }));
await expect.poll(renderedCounts).toMatchObject({ waterQuality: 1, waterQualityHit: 1, pressure: 1, gisFlow: 1, gisFlowHits: 1 });
await page.evaluate(() => window.__waterNetworkMap!.jumpTo({ zoom: 11.99 }));
await expect.poll(renderedCounts).toMatchObject({ nodes: 0, nodeHits: 0, gisDetails: 0, gisDetailHits: 0 });
await page.evaluate(() => window.__waterNetworkMap!.jumpTo({ zoom: 12 }));
await expect.poll(renderedCounts).toMatchObject({ nodes: 1, nodeHits: 1, gisDetails: 1, gisDetailHits: 1 });
await page.evaluate(() => window.__waterNetworkMap!.jumpTo({ zoom: 12.9 }));
await expect.poll(renderedCounts).toMatchObject({ dots: 0, symbols: 0, hits: 0, waterQuality: 1 });
await page.evaluate(() => window.__waterNetworkMap!.jumpTo({ zoom: 13 }));
await expect.poll(renderedCounts).toMatchObject({ dots: 70, hits: 70 });
await page.evaluate(() => window.__waterNetworkMap!.jumpTo({ zoom: 15.5 }));
await expect.poll(async () => (await renderedCounts()).symbols).toBeGreaterThan(overview.symbols);
await page.screenshot({ path: testInfo.outputPath("dense-detail.png") });
await page.evaluate(() => {
const map = window.__waterNetworkMap!;
map.setFeatureState({ source: "density-fixture", id: "valve-35" }, { selected: true });
map.jumpTo({ zoom: 13.1 });
});
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap!
.getFeatureState({ source: "density-fixture", id: "valve-35" }).selected)).toBe(true);
await page.screenshot({ path: testInfo.outputPath("dense-selected.png") });
});
}
+13
View File
@@ -36,6 +36,12 @@ test("loads SCADA from the backend API and aligns map features by device_id", as
const deviceRequest = await deviceRequestPromise;
expect(deviceRequest.headers()["x-project-id"]).toBe(TEST_PROJECT_ID);
await expect.poll(() => page.evaluate(() => Boolean(
globalThis.__waterNetworkMap?.getLayer("scada-pressure-symbol")
))).toBe(true);
await page.evaluate(() => globalThis.__waterNetworkMap!.jumpTo({
center: [121.5, 30.9], zoom: 13
}));
await expect.poll(async () => page.evaluate(() => {
const map = globalThis.__waterNetworkMap;
if (!map) return [];
@@ -74,6 +80,7 @@ test("shows data-dependent layer controls only when GeoServer reports features",
await expect(layerTool).toBeEnabled();
await layerTool.click();
await expect(page.getByRole("button", { name: /^阀门/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^阀门/ })).toContainText("符号自动避让");
await expect(page.getByRole("button", { name: /^水库/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^水泵/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^水箱/ })).toBeVisible();
@@ -111,6 +118,12 @@ test("shows data-dependent layer controls only when GeoServer reports features",
});
expect(layerOrder[0]).toBeGreaterThanOrEqual(0);
expect(layerOrder[0]).toBeLessThan(layerOrder[1]);
const valveMinZooms = await page.evaluate(() => {
const map = globalThis.__waterNetworkMap;
return ["valves-symbol", "valves-hit", "workbench-value-label-valves"]
.map((layerId) => map?.getLayer(layerId)?.minzoom);
});
expect(valveMinZooms).toEqual([13, 13, 13]);
});
test("loads required map layers while optional layer probes are still pending", async ({ page }) => {
@@ -0,0 +1,46 @@
import { createRequire } from "node:module";
import type { Page } from "@playwright/test";
const requireFromMapLibre = createRequire(import.meta.resolve("maplibre-gl"));
const { fromVectorTileJs } = requireFromMapLibre("@maplibre/vt-pbf") as {
fromVectorTileJs(tile: unknown): Uint8Array;
};
// Exercise the same MVT worker path as GeoServer, including a dense pipe inventory.
export async function mockPipeVectorTiles(page: Page) {
const tiles = new Map<string, Buffer>();
await page.route("**/tjwater_next:pipes/WebMercatorQuad/**", (route) => {
const path = new URL(route.request().url()).pathname.split("/");
const [zoom, tileY, tileX] = path.slice(-3).map(Number);
const key = `${zoom}/${tileY}/${tileX}`;
let body = tiles.get(key);
if (!body) {
const project = (longitude: number, latitude: number) => {
const sin = Math.sin(latitude * Math.PI / 180);
return {
x: Math.round(((longitude + 180) / 360 * 2 ** zoom - tileX) * 4096),
y: Math.round(((0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * 2 ** zoom - tileY) * 4096)
};
};
const features = Array.from({ length: 2001 }, (_, index) => {
const latitude = index === 0 ? 30.91 : 30.918 + Math.floor((index - 1) / 100) * 0.0004;
const longitude = index === 0 ? 121.45 : 121.45 + (index - 1) % 100 * 0.0003;
const geometry = [[project(longitude, latitude),
project(index === 0 ? 121.48 : longitude + 0.00025, latitude)]];
const [start, end] = geometry[0];
if (start.y < -256 || start.y > 4352 || end.x < -256 || start.x > 4352) return [];
return [{ type: 2, id: index + 1, extent: 4096,
properties: { id: index + 1, diameter: index === 0 ? 800 : 100,
status: index % 5 === 4 ? "closed" : "open" },
loadGeometry: () => geometry }];
}).flat();
body = Buffer.from(fromVectorTileJs({ layers: { pipes: {
version: 2, name: "pipes", extent: 4096, length: features.length,
feature: (index: number) => features[index]
} } }));
tiles.set(key, body);
}
return route.fulfill({ body, contentType: "application/vnd.mapbox-vector-tile",
headers: { "Access-Control-Allow-Origin": "*" } });
});
}