diff --git a/docs/style-design-guide.md b/docs/style-design-guide.md
index 8a3218a..659cbf5 100644
--- a/docs/style-design-guide.md
+++ b/docs/style-design-guide.md
@@ -77,6 +77,18 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
状态使用 `status-tone-neutral/info/success/warning/danger` 与共享 `StatusBadge`、`StatusIcon`、`StatusDot`,同时提供文字或图标。业务状态、交互选中和数据系列颜色各自独立;`material-tone-agent` 只标记 Agent 解释或建议,原始监测值和人工资料继续使用普通阅读面。
+地图中的管线一起显示,以管径对应的颜色和线宽体现差异。水库、泵站、水箱和水质监测点保持清晰;阀门、普通节点与常规 SCADA 使用随缩放连续变化的大小和透明度。密集设施的工程符号自动避让,底层保留轻量圆点以表达位置和支持点选。选中与异常分析标记单独突出,普通设施以连续变化为主;普通节点从 12 级、阀门从 13 级开始显示,图标、标注与命中范围同步限制。
+
+管道透明度不随缩放变化,缩放渐变体现在管线与描边的宽度上,远看纤细、放大清晰;图层开关时仍使用透明度淡入淡出。
+
+图层开关统一使用约 320ms 的透明度淡入淡出,覆盖管线、设施、监测点、GIS 原始数据和模拟结果。矢量瓦片可绘制后才开始淡入计时,避免加载期间耗尽过渡时间。淡入淡出只临时叠加透明度系数,结束后恢复图层原有样式;快速反向切换从当前进度继续。隐藏时立即关闭命中层与交互强调层,淡入结束后恢复;减少动态效果偏好下直接切换。管线用过滤器区分开关状态,透明度保持统一数值,避免逐帧重写属性表达式触发瓦片重算。
+
+重要设施也使用连续缩放插值,但图标的最低透明度保持较高,衬底更轻,保证概览可辨识。按需开启的数值标签随缩放渐变字号与透明度;使用者设置的字号作为放大后的目标字号。
+
+水质、压力和流量监测点统一从 11 级开始显示,图标、圆点、交互层和图层面板提示共用缩放起点。水质与压力监测点使用一致的圆点、衬底尺寸和图标屏幕像素宽度。图标缩放系数需结合原图尺寸换算,水质图标 128px、压力图标 96px,不直接使用相同系数。
+
+GIS 原始图层与对应业务类型共用起始级别:GIS 管线与 DN100+ 台账从 8.5 级显示,流量仪从 11 级显示,消火栓、接水点、用户及非居民水表从 12 级显示,GIS 阀门与排水阀从 13 级显示。图层提示、图形和点击范围保持一致。
+
Agent 使用 shadcn/ui 的实体表面和现有主题色,不将这种外观列为待迁移到 Acrylic 的差异。已有 Agent 专用配色、材质和控件 CSS 覆盖应在对应组件调整时收敛到本地 shadcn/ui 样式;是否完成以运行界面验收为准。
## 4. 材质与层级
diff --git a/src/app/app.e2e.ts b/src/app/app.e2e.ts
index 8c3f1bf..ad263d3 100644
--- a/src/app/app.e2e.ts
+++ b/src/app/app.e2e.ts
@@ -17,8 +17,8 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
});
await page.goto("/");
- await expect(page.getByText("供水管网智能调度系统")).toBeVisible();
- await expect(page.getByText("调度工作台")).toBeVisible();
+ await expect(page.getByText("供水管网智慧运维系统")).toBeVisible();
+ await expect(page.getByText("运维工作台")).toBeVisible();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect(page.getByLabel("Agent 命令面板")).toHaveCount(0);
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
diff --git a/src/features/workbench/components/workbench-top-bar.tsx b/src/features/workbench/components/workbench-top-bar.tsx
index 086af1e..4fcfbfb 100644
--- a/src/features/workbench/components/workbench-top-bar.tsx
+++ b/src/features/workbench/components/workbench-top-bar.tsx
@@ -117,11 +117,11 @@ export function WorkbenchTopBar({
-
+
供水
- 供水管网智能调度系统
+ 供水管网智慧运维系统
-
调度工作台
+
运维工作台
diff --git a/src/features/workbench/hooks/use-workbench-map.ts b/src/features/workbench/hooks/use-workbench-map.ts
index c909a59..6b9adfd 100644
--- a/src/features/workbench/hooks/use-workbench-map.ts
+++ b/src/features/workbench/hooks/use-workbench-map.ts
@@ -356,7 +356,7 @@ export function useWorkbenchMap({
).forEach((layer) => map.addLayer(layer));
scadaHitLayers.forEach((layer) => map.addLayer(layer));
removeCityScene = installCityScene(map);
- setSimulationLayersVisibility(map, impactVisibleRef.current);
+ setSimulationLayersVisibility(map, impactVisibleRef.current, false);
setMapReady(true);
updateSourceStatus(
setSourceStatuses,
diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx
index 4a70934..850d3a7 100644
--- a/src/features/workbench/map-workbench-page.tsx
+++ b/src/features/workbench/map-workbench-page.tsx
@@ -102,6 +102,7 @@ import {
} from "./layout/workbench-layout";
import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view";
+import { setMapLayersVisibility } from "./map/layer-visibility";
import { parseScadaAnalysisItems } from "./map/scada-analysis";
import {
BASE_LAYER_OPTIONS,
@@ -724,9 +725,7 @@ function MapWorkbenchContent({
return;
}
- getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => {
- map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
- });
+ setMapLayersVisibility(map, getWorkbenchLayerIds(map, layerControlId), visible);
}
function handleTogglePresentationLayer(id: string, visible: boolean) {
const before = presentationLayerChangesRef.current.get(id) ?? (layerVisibility[id] !== false);
diff --git a/src/features/workbench/map/layer-group-style.ts b/src/features/workbench/map/layer-group-style.ts
index 2c28b03..94ba945 100644
--- a/src/features/workbench/map/layer-group-style.ts
+++ b/src/features/workbench/map/layer-group-style.ts
@@ -5,9 +5,7 @@ type LayoutPropertyName = Parameters[1];
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
- createSupplyActiveOpacity,
createSupplyStateColor,
- supplyFacilityOpacityExpression,
supplyPipeColorExpression,
supplyPipeDashExpression
} from "./supply-style-expressions";
@@ -60,8 +58,8 @@ const POINT_DEFAULT_COLORS = {
} as const;
const POINT_DEFAULT_OPACITIES = {
- junctions: 0.9,
- valves: supplyFacilityOpacityExpression,
+ junctions: SUPPLY_LAYER_VISUALS.junctions.opacity,
+ valves: SUPPLY_LAYER_VISUALS.valves.opacity,
reservoirs: 0
} as const;
@@ -107,7 +105,7 @@ function applyLineStyle(map: MapLibreMap, groupId: "pipes", patch: LineLayerGrou
const prefix = groupId;
const coreId = LINE_CORE_LAYERS[groupId];
if (patch.color !== undefined) setPaint(map, coreId, "line-color", createSupplyStateColor(patch.color));
- if (patch.opacity !== undefined) setPaint(map, coreId, "line-opacity", createSupplyActiveOpacity(patch.opacity));
+ if (patch.opacity !== undefined) setPaint(map, coreId, "line-opacity", patch.opacity);
if (patch.dash !== undefined) {
const dash = patch.dash === "solid"
? [1, 0]
@@ -127,7 +125,7 @@ function applyLineStyle(map: MapLibreMap, groupId: "pipes", patch: LineLayerGrou
function resetLineStyle(map: MapLibreMap, groupId: "pipes") {
const coreId = LINE_CORE_LAYERS[groupId];
setPaint(map, coreId, "line-color", supplyPipeColorExpression);
- setPaint(map, coreId, "line-opacity", createSupplyActiveOpacity(0.92));
+ setPaint(map, coreId, "line-opacity", 0.92);
setPaint(map, coreId, "line-dasharray", supplyPipeDashExpression);
setPaint(map, `${groupId}-casing`, "line-dasharray", supplyPipeDashExpression);
for (const [id, value] of Object.entries(LINE_DEFAULT_WIDTHS)) {
diff --git a/src/features/workbench/map/layer-visibility.test.ts b/src/features/workbench/map/layer-visibility.test.ts
new file mode 100644
index 0000000..8949509
--- /dev/null
+++ b/src/features/workbench/map/layer-visibility.test.ts
@@ -0,0 +1,125 @@
+import type { Map as MapLibreMap } from "maplibre-gl";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { LAYER_VISIBILITY_FADE_MS, setMapLayersVisibility } from "./layer-visibility";
+
+describe("map layer visibility fades", () => {
+ let now = 0;
+ let nextFrame = 0;
+ let frames: Map;
+
+ beforeEach(() => {
+ now = 0;
+ nextFrame = 0;
+ frames = new Map();
+ vi.spyOn(performance, "now").mockImplementation(() => now);
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
+ frames.set(++nextFrame, callback);
+ return nextFrame;
+ });
+ vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame));
+ vi.stubGlobal("matchMedia", () => ({ matches: false }));
+ });
+
+ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
+
+ function advance(time: number) {
+ now = time;
+ const callbacks = [...frames.values()];
+ frames.clear();
+ callbacks.forEach((callback) => callback(time));
+ }
+
+ it("fades before hiding and restores the authored opacity and transition", () => {
+ const fixture = createMap();
+ setMapLayersVisibility(fixture.map, ["pipes", "pipes-hit"], false);
+ expect(fixture.visibility.get("pipes")).toBe("visible");
+ expect(fixture.visibility.get("pipes-hit")).toBe("none");
+ advance(LAYER_VISIBILITY_FADE_MS / 2);
+ expect(fixture.paint.get("pipes:line-opacity")).toBeGreaterThan(0);
+ expect(fixture.paint.get("pipes:line-opacity")).toBeLessThan(0.92);
+ advance(LAYER_VISIBILITY_FADE_MS);
+ expect(fixture.visibility.get("pipes")).toBe("none");
+ expect(fixture.paint.get("pipes:line-opacity")).toBe(0.92);
+ expect(fixture.paint.get("pipes:line-opacity-transition")).toEqual({ duration: 120 });
+ });
+
+ it("reverses an in-flight fade without hiding the layer or stacking expressions", () => {
+ const fixture = createMap();
+ const camera = ["interpolate", ["linear"], ["zoom"], 8, 0.5, 16, 1];
+ fixture.paint.set("pipes:line-opacity", camera);
+ setMapLayersVisibility(fixture.map, ["pipes"], false);
+ advance(80);
+ setMapLayersVisibility(fixture.map, ["pipes"], true);
+ advance(80);
+ advance(180);
+ expect((fixture.paint.get("pipes:line-opacity") as unknown[])[0]).toBe("interpolate");
+ expect(fixture.visibility.get("pipes")).toBe("visible");
+ advance(80 + LAYER_VISIBILITY_FADE_MS);
+ expect(fixture.paint.get("pipes:line-opacity")).toEqual(camera);
+ expect(frames.size).toBe(0);
+ });
+
+ it("enables picking after showing and respects reduced motion", () => {
+ const fixture = createMap();
+ fixture.visibility.set("pipes", "none");
+ fixture.visibility.set("pipes-hit", "none");
+ setMapLayersVisibility(fixture.map, ["pipes", "pipes-hit"], true);
+ advance(0);
+ expect(fixture.paint.get("pipes:line-opacity")).toBe(0);
+ expect(fixture.visibility.get("pipes-hit")).toBe("none");
+ advance(LAYER_VISIBILITY_FADE_MS);
+ expect(fixture.visibility.get("pipes-hit")).toBe("visible");
+ vi.stubGlobal("matchMedia", () => ({ matches: true }));
+ setMapLayersVisibility(fixture.map, ["pipes"], false);
+ expect(fixture.visibility.get("pipes")).toBe("none");
+ expect(frames.size).toBe(0);
+ });
+
+ it("cancels frames when the owning map is removed", () => {
+ const fixture = createMap();
+ setMapLayersVisibility(fixture.map, ["pipes"], false);
+ fixture.remove();
+ expect(frames.size).toBe(0);
+ });
+
+ it("starts fading in after vector tiles are ready", () => {
+ const fixture = createMap();
+ let loaded = false;
+ fixture.map.isSourceLoaded = () => loaded;
+ fixture.visibility.set("pipes", "none");
+ setMapLayersVisibility(fixture.map, ["pipes"], true);
+ advance(LAYER_VISIBILITY_FADE_MS * 2);
+ expect(fixture.paint.get("pipes:line-opacity")).toBe(0);
+ loaded = true;
+ advance(LAYER_VISIBILITY_FADE_MS * 3);
+ expect(fixture.paint.get("pipes:line-opacity")).toBe(0);
+ advance(LAYER_VISIBILITY_FADE_MS * 3.5);
+ expect(fixture.paint.get("pipes:line-opacity")).toBeGreaterThan(0);
+ expect(fixture.paint.get("pipes:line-opacity")).toBeLessThan(0.92);
+ advance(LAYER_VISIBILITY_FADE_MS * 4);
+ expect(fixture.paint.get("pipes:line-opacity")).toBe(0.92);
+ });
+});
+
+function createMap() {
+ const layers = new Map([ ["pipes", { type: "line", source: "pipes" }], ["pipes-hit", { type: "line", source: "pipes" }] ]);
+ const visibility = new Map([ ["pipes", "visible"], ["pipes-hit", "visible"] ]);
+ const paint = new Map([
+ ["pipes:line-opacity", 0.92], ["pipes:line-opacity-transition", { duration: 120 }],
+ ["pipes-hit:line-opacity", 0]
+ ]);
+ let remove = () => {};
+ const map = {
+ once: (event: string, callback: () => void) => {
+ if (event === "remove") remove = callback;
+ else if (event === "render") callback();
+ },
+ getLayer: (id: string) => layers.get(id),
+ isSourceLoaded: () => true,
+ getLayoutProperty: (id: string) => visibility.get(id),
+ getPaintProperty: (id: string, name: string) => paint.get(`${id}:${name}`),
+ setLayoutProperty: (id: string, _name: string, value: string) => visibility.set(id, value),
+ setPaintProperty: (id: string, name: string, value: unknown) => paint.set(`${id}:${name}`, value)
+ } as unknown as MapLibreMap;
+ return { map, visibility, paint, remove: () => remove() };
+}
diff --git a/src/features/workbench/map/layer-visibility.ts b/src/features/workbench/map/layer-visibility.ts
new file mode 100644
index 0000000..defe7d7
--- /dev/null
+++ b/src/features/workbench/map/layer-visibility.ts
@@ -0,0 +1,141 @@
+import type { Map as MapLibreMap } from "maplibre-gl";
+
+export const LAYER_VISIBILITY_FADE_MS = 320;
+
+type PaintProperty = Parameters[1];
+type OpacityProperty = { name: PaintProperty; transitionName: PaintProperty; value: unknown; transition: unknown };
+type LayerFade = {
+ visible: boolean;
+ opacity: number;
+ frame: number;
+ properties: OpacityProperty[];
+};
+
+const fadesByMap = new WeakMap>();
+const opacityProperties: Record = {
+ line: ["line-opacity"],
+ circle: ["circle-opacity", "circle-stroke-opacity"],
+ symbol: ["icon-opacity", "text-opacity"],
+ fill: ["fill-opacity"],
+ "fill-extrusion": ["fill-extrusion-opacity"],
+ raster: ["raster-opacity"],
+ heatmap: ["heatmap-opacity"]
+};
+
+export function setMapLayersVisibility(
+ map: MapLibreMap,
+ layerIds: readonly string[],
+ visible: boolean,
+ animate = true
+) {
+ let fades = fadesByMap.get(map);
+ if (!fades) {
+ fades = new Map();
+ fadesByMap.set(map, fades);
+ const ownedFades = fades;
+ map.once("remove", () => {
+ ownedFades.forEach((fade) => cancelAnimationFrame(fade.frame));
+ ownedFades.clear();
+ fadesByMap.delete(map);
+ });
+ }
+ const reducedMotion = typeof matchMedia === "function"
+ && matchMedia("(prefers-reduced-motion: reduce)").matches;
+ const activeFades = fades;
+
+ for (const layerId of layerIds) {
+ const layer = map.getLayer(layerId);
+ if (!layer) continue;
+ const previous = activeFades.get(layerId);
+ if (previous?.visible === visible) continue;
+ const wasVisible = map.getLayoutProperty(layerId, "visibility") !== "none";
+ if (!previous && wasVisible === visible) continue;
+ if (previous) cancelAnimationFrame(previous.frame);
+
+ const names = opacityProperties[layer.type] ?? [];
+ const properties = previous?.properties ?? names.map((name) => {
+ const transitionName = `${name}-transition` as PaintProperty;
+ return { name, transitionName, value: map.getPaintProperty(layerId, name),
+ transition: map.getPaintProperty(layerId, transitionName) };
+ });
+ const restorePaint = () => {
+ for (const property of properties) {
+ map.setPaintProperty(layerId, property.name, (property.value ?? null) as never);
+ map.setPaintProperty(layerId, property.transitionName, (property.transition ?? null) as never);
+ }
+ };
+ const finish = () => {
+ map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
+ restorePaint();
+ activeFades.delete(layerId);
+ };
+ // Picking stops immediately on hide and starts after the visual fade on show.
+ const hitTarget = layerId.endsWith("-hit");
+ const deferred = hitTarget || properties.some((property) => readsFeatureState(property.value));
+ if ((deferred && !visible) || !animate || reducedMotion || names.length === 0) {
+ finish();
+ continue;
+ }
+
+ const startOpacity = previous?.opacity ?? (wasVisible ? 1 : 0);
+ const targetOpacity = visible ? 1 : 0;
+ const fade: LayerFade = { visible, opacity: startOpacity, frame: 0, properties };
+ activeFades.set(layerId, fade);
+ const paint = () => {
+ if (deferred) return;
+ for (const property of properties) {
+ map.setPaintProperty(layerId, property.name, scaleOpacity(property.value ?? 1, fade.opacity) as never);
+ }
+ };
+ if (!deferred) {
+ for (const property of properties) {
+ map.setPaintProperty(layerId, property.transitionName, { duration: 0, delay: 0 });
+ }
+ }
+ paint();
+ map.setLayoutProperty(layerId, "visibility", deferred ? "none" : "visible");
+ let startedAt: number | undefined = visible ? undefined : performance.now();
+ let rendered = !visible;
+ if (visible) map.once("render", () => { rendered = true; });
+ const tick = (now: number) => {
+ if (map.getLayer(layerId) !== layer) {
+ activeFades.delete(layerId);
+ return;
+ }
+ // A hidden vector source may need its tiles again. Start the fade when it can draw,
+ // rather than spending the entire animation while its worker is still loading.
+ if (startedAt === undefined && (!rendered
+ || (typeof layer.source === "string" && !map.isSourceLoaded(layer.source)))) {
+ fade.frame = requestAnimationFrame(tick);
+ return;
+ }
+ startedAt ??= now;
+ const progress = Math.min(1, (now - startedAt) / LAYER_VISIBILITY_FADE_MS);
+ fade.opacity = startOpacity + (targetOpacity - startOpacity) * progress;
+ if (progress >= 1) finish();
+ else {
+ paint();
+ fade.frame = requestAnimationFrame(tick);
+ }
+ };
+ fade.frame = requestAnimationFrame(tick);
+ }
+}
+
+function readsFeatureState(value: unknown): boolean {
+ return Array.isArray(value) && (value[0] === "feature-state" || value.some(readsFeatureState));
+}
+
+function scaleOpacity(value: unknown, opacity: number): unknown {
+ if (typeof value === "number") return value * opacity;
+ if (Array.isArray(value)) {
+ // Camera expressions must keep zoom at the top level of the paint expression.
+ const cameraInterpolate = value[0] === "interpolate" && value[2]?.[0] === "zoom";
+ const cameraStep = value[0] === "step" && value[1]?.[0] === "zoom";
+ if (cameraInterpolate || cameraStep) {
+ return value.map((entry, index) => index >= (cameraStep ? 2 : 4) && index % 2 === 0
+ ? scaleOpacity(entry, opacity) : entry);
+ }
+ }
+ return ["*", value, opacity];
+}
diff --git a/src/features/workbench/map/layers.test.ts b/src/features/workbench/map/layers.test.ts
index 12d85f3..c68af5d 100644
--- a/src/features/workbench/map/layers.test.ts
+++ b/src/features/workbench/map/layers.test.ts
@@ -8,7 +8,7 @@ import {
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
import {
- supplyFacilityOpacityExpression,
+ supplyInactiveExpression,
supplyPipeColorExpression,
supplyPipeDashExpression
} from "./supply-style-expressions";
@@ -21,6 +21,8 @@ describe("supply network layer styling", () => {
expect(paint?.["line-color"]).toEqual(supplyPipeColorExpression);
expect(paint?.["line-width"]).toEqual(SUPPLY_LAYER_VISUALS.pipes.width);
expect(paint?.["line-dasharray"]).toEqual(supplyPipeDashExpression);
+ expect(paint?.["line-opacity"]).toBe(0.92);
+ expect(pipe && "filter" in pipe ? pipe.filter : undefined).toEqual(["!", supplyInactiveExpression]);
expect(JSON.stringify(paint?.["line-width"])).toContain("diameter");
expect(JSON.stringify(paint?.["line-width"])).toContain("zoom");
expect(waterNetworkBusinessLayers.some((layer) => layer.id === "pipes-risk-glow")).toBe(false);
@@ -35,6 +37,8 @@ describe("supply network layer styling", () => {
const sourcePipePaint = sourcePipe?.paint as Record | undefined;
const inventoryPipe = getLayer("fx_20200811_dn100_pipes-line");
const inventoryPaint = inventoryPipe?.paint as Record | undefined;
+ expect(sourcePipe).toMatchObject({ minzoom: 8.5 });
+ expect(inventoryPipe).toMatchObject({ minzoom: 8.5 });
expect(sourcePipePaint?.["line-width"]).toEqual(SUPPLY_LAYER_VISUALS.pipes.width);
expect(JSON.stringify(sourcePipePaint?.["line-color"])).toContain("diameter");
@@ -49,55 +53,58 @@ describe("supply network layer styling", () => {
it("uses distinct engineering symbols for every source GIS facility type", () => {
const sourceFacilitySymbols = [
- ["fx_20200423_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceValve, 14],
- ["fx_20200423_drain_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve, 10],
- ["fx_20200423_flow_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter, 10],
- ["fx_20200423_hydrants-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceHydrant, 13],
- ["fx_20200423_service_connections-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceConnection, 13],
- ["fx_20200423_customer_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter, 13],
+ ["fx_20200423_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceValve],
+ ["fx_20200423_drain_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve],
+ ["fx_20200423_flow_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter],
+ ["fx_20200423_hydrants-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceHydrant],
+ ["fx_20200423_service_connections-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceConnection],
+ ["fx_20200423_customer_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter],
[
"fx_20200423_non_residential_meters-symbol",
- SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter,
- 15
+ SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter
]
] as const;
- sourceFacilitySymbols.forEach(([layerId, imageId, minZoom]) => {
+ sourceFacilitySymbols.forEach(([layerId, imageId]) => {
expect(getLayer(layerId)).toMatchObject({
type: "symbol",
layout: {
"icon-image": imageId,
- "icon-size": ["interpolate", ["linear"], ["zoom"], minZoom, 0.36, 18, 0.54]
+ "icon-size": SUPPLY_LAYER_VISUALS.valves.iconSize,
+ "icon-allow-overlap": false
}
});
});
});
- it("keeps facilities visible from overview while deferring ordinary junctions", () => {
- expect(getLayer("valves")).toMatchObject({ minzoom: 8.5 });
- expect(getLayer("valves-symbol")).toMatchObject({ minzoom: 8.5 });
+ it("keeps facilities available from overview with quieter detail markers", () => {
+ expect(getLayer("valves")).toMatchObject({ minzoom: 13 });
+ expect(getLayer("valves-symbol")).toMatchObject({ minzoom: 13 });
+ expect(getLayer("valves-hit")).toMatchObject({ minzoom: 13 });
+ expect(getLayer("fx_20200423_valves-symbol")).toMatchObject({ minzoom: 13 });
+ expect(getLayer("fx_20200423_drain_valves-symbol")).toMatchObject({ minzoom: 13 });
expect(getLayer("reservoirs")).toMatchObject({ minzoom: 8.5 });
expect(getLayer("reservoirs-symbol")).toMatchObject({
minzoom: 8.5,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir }
});
expect(getLayer("pumps-symbol")).toMatchObject({
- minzoom: 11,
+ minzoom: 8.5,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.pump }
});
expect(getLayer("tanks-symbol")).toMatchObject({
- minzoom: 11,
+ minzoom: 8.5,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.tank }
});
expect(getLayer("water_quality_monitoring_points-symbol")).toMatchObject({
type: "symbol",
- minzoom: 8,
+ minzoom: 11,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.waterQuality }
});
- expect(getLayer("junctions")).toMatchObject({ minzoom: 15 });
+ expect(getLayer("junctions")).toMatchObject({ minzoom: 12 });
const valvePaint = getLayer("valves")?.paint as Record | undefined;
- expect(valvePaint?.["circle-opacity"]).toEqual(supplyFacilityOpacityExpression);
+ expect(valvePaint?.["circle-opacity"]).toEqual(SUPPLY_LAYER_VISUALS.valves.opacity);
const reservoirPaint = getLayer("reservoirs")?.paint as Record | undefined;
expect(reservoirPaint?.["circle-opacity"]).toBe(0);
diff --git a/src/features/workbench/map/layers.ts b/src/features/workbench/map/layers.ts
index eff51ed..8c717a3 100644
--- a/src/features/workbench/map/layers.ts
+++ b/src/features/workbench/map/layers.ts
@@ -14,11 +14,8 @@ import {
} from "./sources";
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
import {
- createSupplyActiveOpacity,
createSupplyStateColor,
- supplyFacilityOpacityExpression,
supplyInactiveExpression,
- supplyIconOpacityExpression,
supplyPipeColorExpression,
supplyPipeDashExpression
} from "./supply-style-expressions";
@@ -66,10 +63,11 @@ layers.push(
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
+ filter: ["!", supplyInactiveExpression],
paint: {
"line-color": MAP_STYLE_TOKENS.canvas.casing,
"line-width": SUPPLY_LAYER_VISUALS.pipes.casingWidth,
- "line-opacity": createSupplyActiveOpacity(0.86),
+ "line-opacity": 0.86,
"line-dasharray": supplyPipeDashExpression
}
},
@@ -79,10 +77,11 @@ layers.push(
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
+ filter: ["!", supplyInactiveExpression],
paint: {
"line-color": supplyPipeColorExpression,
"line-width": SUPPLY_LAYER_VISUALS.pipes.width,
- "line-opacity": createSupplyActiveOpacity(0.92),
+ "line-opacity": 0.92,
"line-dasharray": supplyPipeDashExpression
}
},
@@ -136,7 +135,7 @@ layers.push(
paint: {
"circle-color": MAP_STYLE_TOKENS.supply.junction,
"circle-radius": SUPPLY_LAYER_VISUALS.junctions.radius,
- "circle-opacity": 0.9
+ "circle-opacity": SUPPLY_LAYER_VISUALS.junctions.opacity
}
},
{
@@ -148,7 +147,7 @@ layers.push(
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": SUPPLY_LAYER_VISUALS.valves.haloRadius,
- "circle-opacity": 0.94
+ "circle-opacity": SUPPLY_LAYER_VISUALS.valves.haloOpacity
}
},
{
@@ -160,7 +159,7 @@ layers.push(
paint: {
"circle-color": createSupplyStateColor(MAP_STYLE_TOKENS.supply.valve),
"circle-radius": SUPPLY_LAYER_VISUALS.valves.radius,
- "circle-opacity": supplyFacilityOpacityExpression,
+ "circle-opacity": SUPPLY_LAYER_VISUALS.valves.opacity,
"circle-stroke-width": 0
}
},
@@ -173,11 +172,12 @@ layers.push(
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.valve,
"icon-size": SUPPLY_LAYER_VISUALS.valves.iconSize,
- "icon-allow-overlap": true,
- "icon-ignore-placement": true
+ "icon-padding": 5,
+ "icon-allow-overlap": false,
+ "icon-ignore-placement": false
},
paint: {
- "icon-opacity": supplyIconOpacityExpression
+ "icon-opacity": SUPPLY_LAYER_VISUALS.valves.iconOpacity
}
},
{
@@ -189,7 +189,7 @@ layers.push(
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.haloRadius,
- "circle-opacity": 0.94
+ "circle-opacity": SUPPLY_LAYER_VISUALS.reservoirs.haloOpacity
}
},
{
@@ -214,11 +214,12 @@ layers.push(
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir,
"icon-size": SUPPLY_LAYER_VISUALS.reservoirs.iconSize,
+ "icon-padding": 6,
"icon-allow-overlap": true,
- "icon-ignore-placement": true
+ "icon-ignore-placement": false
},
paint: {
- "icon-opacity": supplyIconOpacityExpression
+ "icon-opacity": SUPPLY_LAYER_VISUALS.reservoirs.iconOpacity
}
},
{
@@ -230,11 +231,12 @@ layers.push(
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.pump,
"icon-size": SUPPLY_LAYER_VISUALS.pumps.iconSize,
+ "icon-padding": 6,
"icon-allow-overlap": true,
- "icon-ignore-placement": true
+ "icon-ignore-placement": false
},
paint: {
- "icon-opacity": supplyIconOpacityExpression
+ "icon-opacity": SUPPLY_LAYER_VISUALS.pumps.iconOpacity
}
},
{
@@ -246,11 +248,12 @@ layers.push(
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.tank,
"icon-size": SUPPLY_LAYER_VISUALS.tanks.iconSize,
+ "icon-padding": 6,
"icon-allow-overlap": true,
- "icon-ignore-placement": true
+ "icon-ignore-placement": false
},
paint: {
- "icon-opacity": supplyIconOpacityExpression
+ "icon-opacity": SUPPLY_LAYER_VISUALS.tanks.iconOpacity
}
}
);
diff --git a/src/features/workbench/map/map-control-config.test.ts b/src/features/workbench/map/map-control-config.test.ts
index 5c7343a..e849729 100644
--- a/src/features/workbench/map/map-control-config.test.ts
+++ b/src/features/workbench/map/map-control-config.test.ts
@@ -66,10 +66,11 @@ describe("workbench supply layer controls", () => {
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
featureCount: 215_797,
- minZoom: 10,
+ minZoom: 8.5,
visible: false
});
expect(items.find((item) => item.id === "fx_20200811_dn100_pipes")).toMatchObject({
+ minZoom: 8.5,
collection: "fx-pipe-inventory-20200811",
featureCount: 122_686,
visual: { kind: "line", dashed: true }
@@ -77,9 +78,15 @@ describe("workbench supply layer controls", () => {
expect(items.find((item) => item.id === "water_quality_monitoring_points")).toMatchObject({
group: "monitoring",
groupLabel: "监测点",
+ minZoom: 11,
visual: { kind: "image", imageSrc: "/map/water-quality-monitoring.svg" },
visible: true
});
+ expect(items.find((item) => item.id === "fx_20200423_flow_meters")).toMatchObject({ minZoom: 11 });
+ for (const id of ["fx_20200423_hydrants", "fx_20200423_service_connections",
+ "fx_20200423_customer_meters", "fx_20200423_non_residential_meters"]) {
+ expect(items.find((item) => item.id === id)).toMatchObject({ minZoom: 12 });
+ }
});
it("omits data-dependent controls when their GeoServer layers are empty", () => {
diff --git a/src/features/workbench/map/map-control-config.ts b/src/features/workbench/map/map-control-config.ts
index 6363960..3b40860 100644
--- a/src/features/workbench/map/map-control-config.ts
+++ b/src/features/workbench/map/map-control-config.ts
@@ -6,6 +6,7 @@ import type {
MapLegendItem
} from "@/features/map/core";
import { MAP_STYLE_TOKENS } from "./map-colors";
+import { SOURCE_GIS_MIN_ZOOM, SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG,
@@ -67,13 +68,13 @@ const LAYER_CONTROL_PRESENTATION: Record<
}
},
junctions: {
- description: "拓扑连接点",
- minZoom: 15,
+ description: "拓扑连接点 · 随缩放细化",
+ minZoom: SUPPLY_LAYER_VISUALS.junctions.minzoom,
visual: { kind: "point", color: MAP_STYLE_TOKENS.supply.junction }
},
valves: {
- description: "控制设施",
- minZoom: 8.5,
+ description: "控制设施 · 符号自动避让",
+ minZoom: SUPPLY_LAYER_VISUALS.valves.minzoom,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.valve }
},
reservoirs: {
@@ -83,22 +84,22 @@ const LAYER_CONTROL_PRESENTATION: Record<
},
pumps: {
description: "加压设施",
- minZoom: 11,
+ minZoom: SUPPLY_LAYER_VISUALS.pumps.minzoom,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.pump }
},
tanks: {
description: "调蓄设施",
- minZoom: 11,
+ minZoom: SUPPLY_LAYER_VISUALS.tanks.minzoom,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.tank }
},
water_quality_monitoring_points: {
description: "灵敏度分析选点",
- minZoom: 8,
+ minZoom: SUPPLY_LAYER_VISUALS.waterQuality.minzoom,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality }
},
fx_20200423_pipes: {
description: "全口径管线",
- minZoom: 10,
+ minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_pipes,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: {
@@ -112,7 +113,7 @@ const LAYER_CONTROL_PRESENTATION: Record<
},
fx_20200811_dn100_pipes: {
description: "DN100 及以上",
- minZoom: 9,
+ minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200811_dn100_pipes,
collection: "fx-pipe-inventory-20200811",
collectionLabel: "2020-08-11 · 专项管线台账",
visual: {
@@ -123,49 +124,49 @@ const LAYER_CONTROL_PRESENTATION: Record<
},
fx_20200423_valves: {
description: "源设施台账",
- minZoom: 14,
+ minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_valves,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve }
},
fx_20200423_drain_valves: {
description: "源设施台账",
- minZoom: 10,
+ minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_drain_valves,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve }
},
fx_20200423_flow_meters: {
description: "源仪表台账",
- minZoom: 10,
+ minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_flow_meters,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter }
},
fx_20200423_hydrants: {
description: "源设施台账",
- minZoom: 13,
+ minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_hydrants,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant }
},
fx_20200423_service_connections: {
description: "源设施台账",
- minZoom: 13,
+ minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_service_connections,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection }
},
fx_20200423_customer_meters: {
description: "源水表台账",
- minZoom: 13,
+ minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_customer_meters,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter }
},
fx_20200423_non_residential_meters: {
description: "源水表台账",
- minZoom: 15,
+ minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_non_residential_meters,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter }
@@ -526,7 +527,7 @@ export function createLayerControlItems(
id: item.id,
label: item.label,
description: item.description,
- minZoom: 8.5,
+ minZoom: item.minZoom,
visual: {
kind: "image" as const,
imageSrc:
diff --git a/src/features/workbench/map/map-layer-visuals.ts b/src/features/workbench/map/map-layer-visuals.ts
index c392377..e8ab7bb 100644
--- a/src/features/workbench/map/map-layer-visuals.ts
+++ b/src/features/workbench/map/map-layer-visuals.ts
@@ -1,4 +1,5 @@
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
+import { createSupplyZoomOpacity } from "./supply-style-expressions";
type LayerSpecification = StyleSpecification["layers"][number];
type LineLayerSpecification = Extract;
@@ -9,6 +10,7 @@ type CircleRadius = NonNullable["
type IconSize = NonNullable["icon-size"]>;
export const MAP_MAX_ZOOM = 24;
+export const MONITORING_POINT_MIN_ZOOM = 11;
const lineHitWidth = [
"interpolate",
@@ -58,17 +60,17 @@ const supplyPipeWidth = [
["linear"],
["zoom"],
9,
- pipeWidthAtZoom(0.7, 1, 1.4),
+ pipeWidthAtZoom(0.4, 0.7, 1.1),
11,
- pipeWidthAtZoom(0.9, 1.3, 1.8),
+ pipeWidthAtZoom(0.65, 1.05, 1.6),
14,
- pipeWidthAtZoom(1.2, 1.8, 2.6),
+ pipeWidthAtZoom(1.3, 2, 2.9),
17,
- pipeWidthAtZoom(1.8, 2.8, 4),
+ pipeWidthAtZoom(2.2, 3.4, 4.8),
20,
- pipeWidthAtZoom(2.6, 4.2, 6),
+ pipeWidthAtZoom(3.2, 5, 7),
24,
- pipeWidthAtZoom(3.2, 5.2, 7)
+ pipeWidthAtZoom(4.2, 6.8, 9.2)
] as unknown as LineWidth;
const supplyPipeCasingWidth = [
@@ -76,39 +78,19 @@ const supplyPipeCasingWidth = [
["linear"],
["zoom"],
9,
- pipeWidthAtZoom(2.1, 2.4, 2.8),
+ pipeWidthAtZoom(1.2, 1.5, 1.9),
11,
- pipeWidthAtZoom(2.3, 2.7, 3.2),
+ pipeWidthAtZoom(1.65, 2.05, 2.6),
14,
- pipeWidthAtZoom(2.7, 3.3, 4.1),
+ pipeWidthAtZoom(2.7, 3.4, 4.3),
17,
- pipeWidthAtZoom(3.6, 4.6, 5.8),
+ pipeWidthAtZoom(4, 5.2, 6.6),
20,
- pipeWidthAtZoom(4.5, 6.1, 7.9),
+ pipeWidthAtZoom(5.2, 7, 9),
24,
- pipeWidthAtZoom(5.2, 7.2, 9)
+ pipeWidthAtZoom(6.6, 9.2, 11.6)
] as unknown as LineWidth;
-const facilityRadius = [
- "interpolate", ["linear"], ["zoom"],
- 8.5, 3,
- 10, 4.2,
- 12, 5.5,
- 16, 7,
- 20, 9,
- 24, 11
-] as CircleRadius;
-
-const facilityCasingRadius = [
- "interpolate", ["linear"], ["zoom"],
- 8.5, 4,
- 10, 5.4,
- 12, 7,
- 16, 8.5,
- 20, 10.5,
- 24, 12.5
-] as CircleRadius;
-
const facilityHoverRadius = [
"interpolate", ["linear"], ["zoom"],
8.5, 5,
@@ -131,12 +113,26 @@ const facilitySelectedRadius = [
const facilityIconSize = [
"interpolate", ["linear"], ["zoom"],
- 8.5, 0.18,
- 10, 0.23,
- 12, 0.29,
- 16, 0.36,
- 20, 0.46,
- 24, 0.56
+ 8.5, 0.1,
+ 12, 0.16,
+ 16, 0.28,
+ 20, 0.4,
+ 24, 0.5
+] as IconSize;
+
+const importantFacilityIconOpacity = createSupplyZoomOpacity([
+ [8, 0.78], [12, 0.88], [16, 0.98]
+]);
+const importantFacilityHaloOpacity = createSupplyZoomOpacity([
+ [8, 0.35], [12, 0.55], [16, 0.8]
+]);
+
+const monitoringPointRadius = ["interpolate", ["linear"], ["zoom"], 8.5, 1.5, 12, 2, 16, 3, 20, 4.5, 24, 5.5] as CircleRadius;
+const monitoringPointHaloRadius = ["interpolate", ["linear"], ["zoom"], 8.5, 2.5, 12, 3.5, 16, 5, 20, 7, 24, 8] as CircleRadius;
+const monitoringIconStops = [[8.5, 0.18], [12, 0.26], [16, 0.4], [20, 0.52], [24, 0.64]] as const;
+const monitoringIconSize = (pixelRatio: number) => [
+ "interpolate", ["linear"], ["zoom"],
+ ...monitoringIconStops.flatMap(([zoom, size]) => [zoom, size * pixelRatio])
] as IconSize;
export const SUPPLY_LAYER_VISUALS = {
@@ -151,20 +147,24 @@ export const SUPPLY_LAYER_VISUALS = {
hitWidth: lineHitWidth
},
junctions: {
- minzoom: 15,
- haloRadius: ["interpolate", ["linear"], ["zoom"], 13, 2.8, 17, 6] as CircleRadius,
- haloOpacity: ["interpolate", ["linear"], ["zoom"], 15, 0, 17, 0, 19, 0.55, 24, 0.82] as const,
- radius: ["interpolate", ["linear"], ["zoom"], 13, 1.5, 17, 3.8] as CircleRadius,
+ minzoom: 12,
+ haloRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 0.6, 13, 1.4, 17, 3.5, 24, 6] as CircleRadius,
+ haloOpacity: ["interpolate", ["linear"], ["zoom"], 8.5, 0, 15, 0.08, 19, 0.45, 24, 0.7] as const,
+ radius: ["interpolate", ["linear"], ["zoom"], 8.5, 0.15, 12, 0.4, 15, 1, 18, 2.6, 24, 3.8] as CircleRadius,
+ opacity: createSupplyZoomOpacity([[8.5, 0.08], [12, 0.18], [15, 0.45], [18, 0.82]]),
hoverRadius: ["interpolate", ["linear"], ["zoom"], 13, 5, 17, 10] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 13, 5.5, 17, 10.5] as CircleRadius,
- hitRadius: pointHitRadius
+ hitRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 0.5, 12, 1, 15, 3, 18, 7, 24, 10] as CircleRadius
},
valves: {
- minzoom: 8.5,
+ minzoom: 13,
casingWidth: ["interpolate", ["linear"], ["zoom"], 11, 3.2, 17, 7.5, 22, 9, 24, 10] as LineWidth,
width: ["interpolate", ["linear"], ["zoom"], 11, 1.6, 17, 4.4, 22, 6, 24, 7] as LineWidth,
- haloRadius: facilityCasingRadius,
- radius: facilityRadius,
+ haloRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 1, 12, 1.6, 16, 3, 20, 4.5, 24, 5.5] as CircleRadius,
+ haloOpacity: createSupplyZoomOpacity([[8.5, 0.12], [12, 0.25], [16, 0.5], [20, 0.7]]),
+ radius: ["interpolate", ["linear"], ["zoom"], 8.5, 0.6, 12, 1, 16, 2, 20, 3.2, 24, 4] as CircleRadius,
+ opacity: createSupplyZoomOpacity([[8.5, 0.18], [12, 0.32], [16, 0.6], [20, 0.8]]),
+ iconOpacity: createSupplyZoomOpacity([[8.5, 0.3], [12, 0.5], [16, 0.9], [20, 0.98]]),
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 6, 17, 10.5, 22, 12.5, 24, 14] as LineWidth,
hoverWidth: ["interpolate", ["linear"], ["zoom"], 11, 4, 17, 7, 22, 9, 24, 10.5] as LineWidth,
hoverRadius: facilityHoverRadius,
@@ -177,6 +177,8 @@ export const SUPPLY_LAYER_VISUALS = {
},
reservoirs: {
minzoom: 8.5,
+ iconOpacity: importantFacilityIconOpacity,
+ haloOpacity: importantFacilityHaloOpacity,
haloRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 7, 10, 8.5, 12, 9.5, 16, 11.5, 20, 13.5, 24, 15.5] as CircleRadius,
radius: ["interpolate", ["linear"], ["zoom"], 8.5, 5.5, 10, 7, 12, 8, 16, 10, 20, 12, 24, 14] as CircleRadius,
hoverRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 8, 10, 9.5, 12, 10.5, 16, 12.5, 20, 14.5, 24, 16.5] as CircleRadius,
@@ -185,26 +187,53 @@ export const SUPPLY_LAYER_VISUALS = {
iconSize: ["interpolate", ["linear"], ["zoom"], 8.5, 0.28, 10, 0.36, 12, 0.42, 16, 0.52, 20, 0.64, 24, 0.76] as IconSize
},
scada: {
- minzoom: 8.5,
- haloRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 5.5, 10, 7, 12, 8.5, 16, 10, 20, 12, 24, 14] as CircleRadius,
- radius: ["interpolate", ["linear"], ["zoom"], 8.5, 4, 10, 5.5, 12, 7, 16, 8.5, 20, 10.5, 24, 12.5] as CircleRadius,
+ minzoom: MONITORING_POINT_MIN_ZOOM,
+ haloRadius: monitoringPointHaloRadius,
+ radius: monitoringPointRadius,
+ haloOpacity: createSupplyZoomOpacity([[8.5, 0.45], [12, 0.55], [16, 0.7]]),
+ opacity: createSupplyZoomOpacity([[8.5, 0.55], [12, 0.65], [16, 0.8]]),
+ iconOpacity: createSupplyZoomOpacity([[8.5, 0.75], [12, 0.85], [16, 0.98]]),
hoverRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 6.5, 10, 8, 12, 9.5, 16, 11, 20, 13, 24, 15] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 7, 10, 8.5, 12, 10, 16, 11.5, 20, 13.5, 24, 15.5] as CircleRadius,
hitRadius: pointHitRadius,
- iconSize: ["interpolate", ["linear"], ["zoom"], 8.5, 0.25, 10, 0.32, 12, 0.4, 16, 0.48, 20, 0.6, 24, 0.72] as IconSize
+ iconSize: monitoringIconSize(1)
},
pumps: {
- minzoom: 11,
+ minzoom: 8.5,
+ iconOpacity: importantFacilityIconOpacity,
hoverRadius: 14,
selectedRadius: 12.5,
hitRadius: 16,
- iconSize: ["interpolate", ["linear"], ["zoom"], 11, 0.44, 16, 0.66, 20, 0.82, 24, 0.96] as const
+ iconSize: ["interpolate", ["linear"], ["zoom"], 8.5, 0.3, 12, 0.44, 16, 0.66, 20, 0.82, 24, 0.96] as const
},
tanks: {
- minzoom: 11,
+ minzoom: 8.5,
+ iconOpacity: importantFacilityIconOpacity,
hoverRadius: 14,
selectedRadius: 12.5,
hitRadius: 16,
- iconSize: ["interpolate", ["linear"], ["zoom"], 11, 0.44, 16, 0.66, 20, 0.82, 24, 0.96] as const
+ iconSize: ["interpolate", ["linear"], ["zoom"], 8.5, 0.3, 12, 0.44, 16, 0.66, 20, 0.82, 24, 0.96] as const
+ },
+ waterQuality: {
+ minzoom: MONITORING_POINT_MIN_ZOOM,
+ iconOpacity: importantFacilityIconOpacity,
+ haloOpacity: importantFacilityHaloOpacity,
+ opacity: createSupplyZoomOpacity([[8, 0.55], [12, 0.7], [16, 0.9]]),
+ radius: monitoringPointRadius,
+ haloRadius: monitoringPointHaloRadius,
+ // Water-quality artwork is 128px; SCADA artwork is 96px, both registered at 2x.
+ iconSize: monitoringIconSize(96 / 128)
}
} as const;
+
+export const SOURCE_GIS_MIN_ZOOM = {
+ fx_20200423_pipes: SUPPLY_LAYER_VISUALS.pipes.minzoom,
+ fx_20200811_dn100_pipes: SUPPLY_LAYER_VISUALS.pipes.minzoom,
+ fx_20200423_valves: SUPPLY_LAYER_VISUALS.valves.minzoom,
+ fx_20200423_drain_valves: SUPPLY_LAYER_VISUALS.valves.minzoom,
+ fx_20200423_flow_meters: MONITORING_POINT_MIN_ZOOM,
+ fx_20200423_hydrants: SUPPLY_LAYER_VISUALS.junctions.minzoom,
+ fx_20200423_service_connections: SUPPLY_LAYER_VISUALS.junctions.minzoom,
+ fx_20200423_customer_meters: SUPPLY_LAYER_VISUALS.junctions.minzoom,
+ fx_20200423_non_residential_meters: SUPPLY_LAYER_VISUALS.junctions.minzoom
+} as const;
diff --git a/src/features/workbench/map/published-gis-layers.ts b/src/features/workbench/map/published-gis-layers.ts
index ca7a54a..f3d4f7e 100644
--- a/src/features/workbench/map/published-gis-layers.ts
+++ b/src/features/workbench/map/published-gis-layers.ts
@@ -1,6 +1,6 @@
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
-import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
+import { SOURCE_GIS_MIN_ZOOM, SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
SOURCE_LAYERS,
type GeoServerWaterNetworkSourceId
@@ -24,19 +24,19 @@ const definitions: PublishedLayerDefinition[] = [
sourceId: "water_quality_monitoring_points",
geometry: "point",
color: MAP_STYLE_TOKENS.supply.waterQuality,
- minzoom: 8,
+ minzoom: SUPPLY_LAYER_VISUALS.waterQuality.minzoom,
radius: 5.5,
imageId: SUPPLY_ASSET_IMAGE_IDS.waterQuality
},
- { sourceId: "fx_20200423_pipes", geometry: "line", color: MAP_STYLE_TOKENS.supply.sourcePipe, minzoom: 10, lineVariant: "network" },
- { sourceId: "fx_20200811_dn100_pipes", geometry: "line", color: MAP_STYLE_TOKENS.supply.sourcePipeInventory, minzoom: 9, lineVariant: "inventory" },
- { sourceId: "fx_20200423_valves", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceValve, minzoom: 14, radius: 3.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceValve },
- { sourceId: "fx_20200423_drain_valves", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceDrainValve, minzoom: 10, radius: 3.6, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve },
- { sourceId: "fx_20200423_flow_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceFlowMeter, minzoom: 10, radius: 4.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter },
- { sourceId: "fx_20200423_hydrants", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceHydrant, minzoom: 13, radius: 3.5, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceHydrant },
- { sourceId: "fx_20200423_service_connections", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceConnection, minzoom: 13, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceConnection },
- { sourceId: "fx_20200423_customer_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter, minzoom: 13, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter },
- { sourceId: "fx_20200423_non_residential_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter, minzoom: 15, radius: 2.8, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter }
+ { sourceId: "fx_20200423_pipes", geometry: "line", color: MAP_STYLE_TOKENS.supply.sourcePipe, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_pipes, lineVariant: "network" },
+ { sourceId: "fx_20200811_dn100_pipes", geometry: "line", color: MAP_STYLE_TOKENS.supply.sourcePipeInventory, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200811_dn100_pipes, lineVariant: "inventory" },
+ { sourceId: "fx_20200423_valves", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceValve, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_valves, radius: 3.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceValve },
+ { sourceId: "fx_20200423_drain_valves", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceDrainValve, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_drain_valves, radius: 3.6, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve },
+ { sourceId: "fx_20200423_flow_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceFlowMeter, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_flow_meters, radius: 4.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter },
+ { sourceId: "fx_20200423_hydrants", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceHydrant, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_hydrants, radius: 3.5, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceHydrant },
+ { sourceId: "fx_20200423_service_connections", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceConnection, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_service_connections, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceConnection },
+ { sourceId: "fx_20200423_customer_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_customer_meters, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter },
+ { sourceId: "fx_20200423_non_residential_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_non_residential_meters, radius: 2.8, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter }
];
const sourcePipeColorExpression: ExpressionSpecification = [
@@ -102,9 +102,10 @@ function createBusinessLayers(definition: PublishedLayerDefinition): Layer[] {
}
const radius = definition.radius ?? 3.2;
- const iconSize = definition.sourceId === "water_quality_monitoring_points"
- ? ["interpolate", ["linear"], ["zoom"], definition.minzoom, 0.25, 18, 0.42] as ExpressionSpecification
- : ["interpolate", ["linear"], ["zoom"], definition.minzoom, 0.36, 18, 0.54] as ExpressionSpecification;
+ const important = definition.sourceId === "water_quality_monitoring_points";
+ const iconSize = important
+ ? SUPPLY_LAYER_VISUALS.waterQuality.iconSize
+ : SUPPLY_LAYER_VISUALS.valves.iconSize;
const layers: Layer[] = [
{
id: `${definition.sourceId}-halo`,
@@ -114,8 +115,9 @@ function createBusinessLayers(definition: PublishedLayerDefinition): Layer[] {
minzoom: definition.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
- "circle-radius": radius + 1.8,
- "circle-opacity": 0.9
+ "circle-radius": important ? SUPPLY_LAYER_VISUALS.waterQuality.haloRadius
+ : ["interpolate", ["linear"], ["zoom"], 8.5, radius * 0.3, 12, radius * 0.5, 18, radius + 1],
+ "circle-opacity": important ? SUPPLY_LAYER_VISUALS.waterQuality.haloOpacity : SUPPLY_LAYER_VISUALS.valves.haloOpacity
}
},
{
@@ -126,8 +128,10 @@ function createBusinessLayers(definition: PublishedLayerDefinition): Layer[] {
minzoom: definition.minzoom,
paint: {
"circle-color": definition.color,
- "circle-radius": ["interpolate", ["linear"], ["zoom"], definition.minzoom, radius, 18, radius * 1.35],
- "circle-opacity": 0.9,
+ "circle-radius": important
+ ? SUPPLY_LAYER_VISUALS.waterQuality.radius
+ : ["interpolate", ["linear"], ["zoom"], 8.5, radius * 0.18, 12, radius * 0.3, 18, radius],
+ "circle-opacity": important ? SUPPLY_LAYER_VISUALS.waterQuality.opacity : SUPPLY_LAYER_VISUALS.valves.opacity,
"circle-stroke-color": definition.sourceId === "water_quality_monitoring_points"
? MAP_STYLE_TOKENS.canvas.casing
: definition.color,
@@ -145,11 +149,12 @@ function createBusinessLayers(definition: PublishedLayerDefinition): Layer[] {
layout: {
"icon-image": definition.imageId,
"icon-size": iconSize,
- "icon-allow-overlap": true,
- "icon-ignore-placement": true
+ "icon-padding": important ? 6 : 5,
+ "icon-allow-overlap": important,
+ "icon-ignore-placement": false
},
paint: {
- "icon-opacity": 0.96
+ "icon-opacity": important ? SUPPLY_LAYER_VISUALS.waterQuality.iconOpacity : SUPPLY_LAYER_VISUALS.valves.iconOpacity
}
});
}
diff --git a/src/features/workbench/map/scada.ts b/src/features/workbench/map/scada.ts
index f50cbe7..bf5cab4 100644
--- a/src/features/workbench/map/scada.ts
+++ b/src/features/workbench/map/scada.ts
@@ -3,7 +3,6 @@ import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
createSupplyStateColor,
- supplyFacilityOpacityExpression,
supplyIconOpacityExpression
} from "./supply-style-expressions";
@@ -34,6 +33,7 @@ export const SCADA_LAYER_CONTROL_ITEMS = [
label: "压力监测点",
description: "83 个压力监测点",
deviceType: "pressure",
+ minZoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
imageId: SCADA_IMAGE_IDS.pressure,
color: MAP_STYLE_TOKENS.supply.scadaPressure
},
@@ -42,6 +42,7 @@ export const SCADA_LAYER_CONTROL_ITEMS = [
label: "流量监测点",
description: "35 个流量监测点",
deviceType: "pipe_flow",
+ minZoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
imageId: SCADA_IMAGE_IDS.flow,
color: MAP_STYLE_TOKENS.supply.scadaFlow
}
@@ -80,11 +81,11 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
- minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
+ minzoom: definition.minZoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
- "circle-opacity": 0.94
+ "circle-opacity": SUPPLY_LAYER_VISUALS.scada.haloOpacity
}
},
{
@@ -92,11 +93,11 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
- minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
+ minzoom: definition.minZoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
"circle-color": color,
- "circle-opacity": supplyFacilityOpacityExpression
+ "circle-opacity": SUPPLY_LAYER_VISUALS.scada.opacity
}
},
{
@@ -104,14 +105,15 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "symbol",
source: SCADA_SOURCE_ID,
filter,
- minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
+ minzoom: definition.minZoom,
layout: {
"icon-image": definition.imageId,
"icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize,
- "icon-allow-overlap": true,
- "icon-ignore-placement": true
+ "icon-padding": 7,
+ "icon-allow-overlap": false,
+ "icon-ignore-placement": false
},
- paint: { "icon-opacity": supplyIconOpacityExpression }
+ paint: { "icon-opacity": SUPPLY_LAYER_VISUALS.scada.iconOpacity }
}
];
const fallback: Layer[] = [
@@ -121,7 +123,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
- minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
+ minzoom: definition.minZoom,
paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2],
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
@@ -135,7 +137,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
- minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
+ minzoom: definition.minZoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
@@ -150,7 +152,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
- minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
+ minzoom: definition.minZoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
@@ -165,7 +167,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
- minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
+ minzoom: definition.minZoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
@@ -181,7 +183,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
- minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
+ minzoom: definition.minZoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
diff --git a/src/features/workbench/map/simulation-layers.ts b/src/features/workbench/map/simulation-layers.ts
index d41f912..e7c95c3 100644
--- a/src/features/workbench/map/simulation-layers.ts
+++ b/src/features/workbench/map/simulation-layers.ts
@@ -1,12 +1,7 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { simulationLayerIds } from "./annotation-layers";
+import { setMapLayersVisibility } from "./layer-visibility";
-export function setSimulationLayersVisibility(map: MapLibreMap, visible: boolean) {
- const visibility = visible ? "visible" : "none";
-
- simulationLayerIds.forEach((layerId) => {
- if (map.getLayer(layerId)) {
- map.setLayoutProperty(layerId, "visibility", visibility);
- }
- });
+export function setSimulationLayersVisibility(map: MapLibreMap, visible: boolean, animate = true) {
+ setMapLayersVisibility(map, simulationLayerIds, visible, animate);
}
diff --git a/src/features/workbench/map/supply-icons.test.ts b/src/features/workbench/map/supply-icons.test.ts
index ba27f03..632f410 100644
--- a/src/features/workbench/map/supply-icons.test.ts
+++ b/src/features/workbench/map/supply-icons.test.ts
@@ -1,5 +1,8 @@
import type { Map as MapLibreMap } from "maplibre-gl";
+import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
+import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
+import { SCADA_ICON_PATHS } from "./scada";
import {
SUPPLY_ASSET_ICON_PATHS,
@@ -9,6 +12,19 @@ import {
} from "./supply-icons";
describe("supply facility map icons", () => {
+ it("matches water-quality and pressure icon pixel widths at every zoom stop", () => {
+ const imageWidth = (path: string) => readFileSync(`public${path}`).readUInt32BE(16);
+ const pressureWidth = imageWidth(SCADA_ICON_PATHS.pressure);
+ const waterQualityWidth = imageWidth(SUPPLY_ASSET_MAP_ICON_PATHS.waterQuality);
+ const pressure = SUPPLY_LAYER_VISUALS.scada.iconSize as unknown as unknown[];
+ const waterQuality = SUPPLY_LAYER_VISUALS.waterQuality.iconSize as unknown as unknown[];
+ for (let index = 3; index < pressure.length; index += 2) {
+ expect(waterQuality[index]).toBe(pressure[index]);
+ expect(Number(waterQuality[index + 1]) * waterQualityWidth)
+ .toBeCloseTo(Number(pressure[index + 1]) * pressureWidth);
+ }
+ });
+
it("uses MapLibre-compatible PNG assets from the public map directory", () => {
expect(Object.keys(SUPPLY_ASSET_ICON_PATHS)).toEqual([
"valve",
diff --git a/src/features/workbench/map/supply-style-expressions.ts b/src/features/workbench/map/supply-style-expressions.ts
index 552449b..85173d2 100644
--- a/src/features/workbench/map/supply-style-expressions.ts
+++ b/src/features/workbench/map/supply-style-expressions.ts
@@ -54,6 +54,17 @@ export const supplyIconOpacityExpression: ExpressionSpecification = [
0.98
];
+export function createSupplyZoomOpacity(
+ stops: readonly (readonly [number, number])[]
+): ExpressionSpecification {
+ return [
+ "interpolate", ["linear"], ["zoom"],
+ ...stops.flatMap(([zoom, opacity]) => [
+ zoom, ["case", supplyInactiveExpression, opacity * 0.65, opacity]
+ ])
+ ] as ExpressionSpecification;
+}
+
export function createSupplyStateColor(
activeColor: string | ExpressionSpecification
): ExpressionSpecification {
diff --git a/src/features/workbench/map/value-label.ts b/src/features/workbench/map/value-label.ts
index 12812d8..838d998 100644
--- a/src/features/workbench/map/value-label.ts
+++ b/src/features/workbench/map/value-label.ts
@@ -4,6 +4,7 @@ import type {
SymbolLayerSpecification
} from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
+import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import { SOURCE_LAYERS } from "./sources";
export const VALUE_LABEL_LAYER_IDS = {
@@ -36,12 +37,12 @@ export function createValueLabelLayers(): SymbolLayerSpecification[] {
type: "symbol",
source: sourceId,
"source-layer": SOURCE_LAYERS[sourceId],
- minzoom: sourceId === "junctions" ? 15 : 10,
+ minzoom: Math.max(10, SUPPLY_LAYER_VISUALS[sourceId].minzoom),
layout: {
visibility: "none",
"symbol-placement": line ? "line-center" : "point",
"text-field": "",
- "text-size": 12,
+ "text-size": createValueLabelTextSize(12),
"text-offset": line ? [0, 0] : [0, -1.35],
"text-anchor": line ? "center" : "bottom",
"text-padding": 5,
@@ -54,7 +55,7 @@ export function createValueLabelLayers(): SymbolLayerSpecification[] {
"text-halo-color": MAP_STYLE_TOKENS.label.inverse,
"text-halo-width": 2,
"text-halo-blur": 0.4,
- "text-opacity": 0.96
+ "text-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0.3, 14, 0.8, 17, 0.96]
}
};
});
@@ -80,7 +81,7 @@ export function applyValueLabelStyle(
"text-field",
createValueLabelTextExpression(style)
);
- map.setLayoutProperty(layerId, "text-size", style.textSize);
+ map.setLayoutProperty(layerId, "text-size", createValueLabelTextSize(style.textSize));
map.setPaintProperty(layerId, "text-color", style.textColor);
map.setLayoutProperty(layerId, "visibility", "visible");
}
@@ -98,6 +99,10 @@ export function clearValueLabels(
});
}
+function createValueLabelTextSize(textSize: number): ExpressionSpecification {
+ return ["interpolate", ["linear"], ["zoom"], 10, Math.max(10, textSize * 0.8), 16, textSize];
+}
+
function createValueLabelTextExpression(
style: ValueLabelStyle
): ExpressionSpecification {
diff --git a/tests/browser/layer-visibility-fade.e2e.ts b/tests/browser/layer-visibility-fade.e2e.ts
new file mode 100644
index 0000000..4ed942c
--- /dev/null
+++ b/tests/browser/layer-visibility-fade.e2e.ts
@@ -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: `` });
+ });
+ 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((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"
+ });
+ });
+}
diff --git a/tests/browser/map-scale-visibility.e2e.ts b/tests/browser/map-scale-visibility.e2e.ts
new file mode 100644
index 0000000..33e9da9
--- /dev/null
+++ b/tests/browser/map-scale-visibility.e2e.ts
@@ -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 = {
+ "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") });
+ });
+}
diff --git a/tests/browser/scada-api-source.e2e.ts b/tests/browser/scada-api-source.e2e.ts
index b49f973..1f7036f 100644
--- a/tests/browser/scada-api-source.e2e.ts
+++ b/tests/browser/scada-api-source.e2e.ts
@@ -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 }) => {
diff --git a/tests/browser/support/mock-pipe-vector-tiles.ts b/tests/browser/support/mock-pipe-vector-tiles.ts
new file mode 100644
index 0000000..a28f7c4
--- /dev/null
+++ b/tests/browser/support/mock-pipe-vector-tiles.ts
@@ -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();
+ 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": "*" } });
+ });
+}