feat: refine supply map layers and workbench branding
This commit is contained in:
@@ -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 解释或建议,原始监测值和人工资料继续使用普通阅读面。
|
状态使用 `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 样式;是否完成以运行界面验收为准。
|
Agent 使用 shadcn/ui 的实体表面和现有主题色,不将这种外观列为待迁移到 Acrylic 的差异。已有 Agent 专用配色、材质和控件 CSS 覆盖应在对应组件调整时收敛到本地 shadcn/ui 样式;是否完成以运行界面验收为准。
|
||||||
|
|
||||||
## 4. 材质与层级
|
## 4. 材质与层级
|
||||||
|
|||||||
+2
-2
@@ -17,8 +17,8 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/");
|
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 折叠栏")).toBeVisible();
|
||||||
await expect(page.getByLabel("Agent 命令面板")).toHaveCount(0);
|
await expect(page.getByLabel("Agent 命令面板")).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||||
|
|||||||
@@ -117,11 +117,11 @@ export function WorkbenchTopBar({
|
|||||||
<Droplets size={18} aria-hidden="true" />
|
<Droplets size={18} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 aria-label="供水管网智能调度系统" className="truncate text-sm font-semibold leading-5 text-slate-950">
|
<h1 aria-label="供水管网智慧运维系统" className="truncate text-sm font-semibold leading-5 text-slate-950">
|
||||||
<span className="sm:hidden">供水</span>
|
<span className="sm:hidden">供水</span>
|
||||||
<span className="hidden sm:inline">供水管网智能调度系统</span>
|
<span className="hidden sm:inline">供水管网智慧运维系统</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="hidden truncate text-xs leading-4 text-[var(--ink-muted)] sm:block">调度工作台</p>
|
<p className="hidden truncate text-xs leading-4 text-[var(--ink-muted)] sm:block">运维工作台</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ export function useWorkbenchMap({
|
|||||||
).forEach((layer) => map.addLayer(layer));
|
).forEach((layer) => map.addLayer(layer));
|
||||||
scadaHitLayers.forEach((layer) => map.addLayer(layer));
|
scadaHitLayers.forEach((layer) => map.addLayer(layer));
|
||||||
removeCityScene = installCityScene(map);
|
removeCityScene = installCityScene(map);
|
||||||
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
setSimulationLayersVisibility(map, impactVisibleRef.current, false);
|
||||||
setMapReady(true);
|
setMapReady(true);
|
||||||
updateSourceStatus(
|
updateSourceStatus(
|
||||||
setSourceStatuses,
|
setSourceStatuses,
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ import {
|
|||||||
} from "./layout/workbench-layout";
|
} from "./layout/workbench-layout";
|
||||||
import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera";
|
import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera";
|
||||||
import { exportMapViewImage } from "./map/export-view";
|
import { exportMapViewImage } from "./map/export-view";
|
||||||
|
import { setMapLayersVisibility } from "./map/layer-visibility";
|
||||||
import { parseScadaAnalysisItems } from "./map/scada-analysis";
|
import { parseScadaAnalysisItems } from "./map/scada-analysis";
|
||||||
import {
|
import {
|
||||||
BASE_LAYER_OPTIONS,
|
BASE_LAYER_OPTIONS,
|
||||||
@@ -724,9 +725,7 @@ function MapWorkbenchContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => {
|
setMapLayersVisibility(map, getWorkbenchLayerIds(map, layerControlId), visible);
|
||||||
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function handleTogglePresentationLayer(id: string, visible: boolean) {
|
function handleTogglePresentationLayer(id: string, visible: boolean) {
|
||||||
const before = presentationLayerChangesRef.current.get(id) ?? (layerVisibility[id] !== false);
|
const before = presentationLayerChangesRef.current.get(id) ?? (layerVisibility[id] !== false);
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ type LayoutPropertyName = Parameters<MapLibreMap["setLayoutProperty"]>[1];
|
|||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
import {
|
import {
|
||||||
createSupplyActiveOpacity,
|
|
||||||
createSupplyStateColor,
|
createSupplyStateColor,
|
||||||
supplyFacilityOpacityExpression,
|
|
||||||
supplyPipeColorExpression,
|
supplyPipeColorExpression,
|
||||||
supplyPipeDashExpression
|
supplyPipeDashExpression
|
||||||
} from "./supply-style-expressions";
|
} from "./supply-style-expressions";
|
||||||
@@ -60,8 +58,8 @@ const POINT_DEFAULT_COLORS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const POINT_DEFAULT_OPACITIES = {
|
const POINT_DEFAULT_OPACITIES = {
|
||||||
junctions: 0.9,
|
junctions: SUPPLY_LAYER_VISUALS.junctions.opacity,
|
||||||
valves: supplyFacilityOpacityExpression,
|
valves: SUPPLY_LAYER_VISUALS.valves.opacity,
|
||||||
reservoirs: 0
|
reservoirs: 0
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -107,7 +105,7 @@ function applyLineStyle(map: MapLibreMap, groupId: "pipes", patch: LineLayerGrou
|
|||||||
const prefix = groupId;
|
const prefix = groupId;
|
||||||
const coreId = LINE_CORE_LAYERS[groupId];
|
const coreId = LINE_CORE_LAYERS[groupId];
|
||||||
if (patch.color !== undefined) setPaint(map, coreId, "line-color", createSupplyStateColor(patch.color));
|
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) {
|
if (patch.dash !== undefined) {
|
||||||
const dash = patch.dash === "solid"
|
const dash = patch.dash === "solid"
|
||||||
? [1, 0]
|
? [1, 0]
|
||||||
@@ -127,7 +125,7 @@ function applyLineStyle(map: MapLibreMap, groupId: "pipes", patch: LineLayerGrou
|
|||||||
function resetLineStyle(map: MapLibreMap, groupId: "pipes") {
|
function resetLineStyle(map: MapLibreMap, groupId: "pipes") {
|
||||||
const coreId = LINE_CORE_LAYERS[groupId];
|
const coreId = LINE_CORE_LAYERS[groupId];
|
||||||
setPaint(map, coreId, "line-color", supplyPipeColorExpression);
|
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, coreId, "line-dasharray", supplyPipeDashExpression);
|
||||||
setPaint(map, `${groupId}-casing`, "line-dasharray", supplyPipeDashExpression);
|
setPaint(map, `${groupId}-casing`, "line-dasharray", supplyPipeDashExpression);
|
||||||
for (const [id, value] of Object.entries(LINE_DEFAULT_WIDTHS)) {
|
for (const [id, value] of Object.entries(LINE_DEFAULT_WIDTHS)) {
|
||||||
|
|||||||
@@ -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<number, FrameRequestCallback>;
|
||||||
|
|
||||||
|
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<string, unknown>([
|
||||||
|
["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() };
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
|
|
||||||
|
export const LAYER_VISIBILITY_FADE_MS = 320;
|
||||||
|
|
||||||
|
type PaintProperty = Parameters<MapLibreMap["getPaintProperty"]>[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<MapLibreMap, Map<string, LayerFade>>();
|
||||||
|
const opacityProperties: Record<string, readonly PaintProperty[]> = {
|
||||||
|
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];
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
||||||
import {
|
import {
|
||||||
supplyFacilityOpacityExpression,
|
supplyInactiveExpression,
|
||||||
supplyPipeColorExpression,
|
supplyPipeColorExpression,
|
||||||
supplyPipeDashExpression
|
supplyPipeDashExpression
|
||||||
} from "./supply-style-expressions";
|
} from "./supply-style-expressions";
|
||||||
@@ -21,6 +21,8 @@ describe("supply network layer styling", () => {
|
|||||||
expect(paint?.["line-color"]).toEqual(supplyPipeColorExpression);
|
expect(paint?.["line-color"]).toEqual(supplyPipeColorExpression);
|
||||||
expect(paint?.["line-width"]).toEqual(SUPPLY_LAYER_VISUALS.pipes.width);
|
expect(paint?.["line-width"]).toEqual(SUPPLY_LAYER_VISUALS.pipes.width);
|
||||||
expect(paint?.["line-dasharray"]).toEqual(supplyPipeDashExpression);
|
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("diameter");
|
||||||
expect(JSON.stringify(paint?.["line-width"])).toContain("zoom");
|
expect(JSON.stringify(paint?.["line-width"])).toContain("zoom");
|
||||||
expect(waterNetworkBusinessLayers.some((layer) => layer.id === "pipes-risk-glow")).toBe(false);
|
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<string, unknown> | undefined;
|
const sourcePipePaint = sourcePipe?.paint as Record<string, unknown> | undefined;
|
||||||
const inventoryPipe = getLayer("fx_20200811_dn100_pipes-line");
|
const inventoryPipe = getLayer("fx_20200811_dn100_pipes-line");
|
||||||
const inventoryPaint = inventoryPipe?.paint as Record<string, unknown> | undefined;
|
const inventoryPaint = inventoryPipe?.paint as Record<string, unknown> | undefined;
|
||||||
|
expect(sourcePipe).toMatchObject({ minzoom: 8.5 });
|
||||||
|
expect(inventoryPipe).toMatchObject({ minzoom: 8.5 });
|
||||||
|
|
||||||
expect(sourcePipePaint?.["line-width"]).toEqual(SUPPLY_LAYER_VISUALS.pipes.width);
|
expect(sourcePipePaint?.["line-width"]).toEqual(SUPPLY_LAYER_VISUALS.pipes.width);
|
||||||
expect(JSON.stringify(sourcePipePaint?.["line-color"])).toContain("diameter");
|
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", () => {
|
it("uses distinct engineering symbols for every source GIS facility type", () => {
|
||||||
const sourceFacilitySymbols = [
|
const sourceFacilitySymbols = [
|
||||||
["fx_20200423_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceValve, 14],
|
["fx_20200423_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceValve],
|
||||||
["fx_20200423_drain_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve, 10],
|
["fx_20200423_drain_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve],
|
||||||
["fx_20200423_flow_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter, 10],
|
["fx_20200423_flow_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter],
|
||||||
["fx_20200423_hydrants-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceHydrant, 13],
|
["fx_20200423_hydrants-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceHydrant],
|
||||||
["fx_20200423_service_connections-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceConnection, 13],
|
["fx_20200423_service_connections-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceConnection],
|
||||||
["fx_20200423_customer_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter, 13],
|
["fx_20200423_customer_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter],
|
||||||
[
|
[
|
||||||
"fx_20200423_non_residential_meters-symbol",
|
"fx_20200423_non_residential_meters-symbol",
|
||||||
SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter,
|
SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter
|
||||||
15
|
|
||||||
]
|
]
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
sourceFacilitySymbols.forEach(([layerId, imageId, minZoom]) => {
|
sourceFacilitySymbols.forEach(([layerId, imageId]) => {
|
||||||
expect(getLayer(layerId)).toMatchObject({
|
expect(getLayer(layerId)).toMatchObject({
|
||||||
type: "symbol",
|
type: "symbol",
|
||||||
layout: {
|
layout: {
|
||||||
"icon-image": imageId,
|
"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", () => {
|
it("keeps facilities available from overview with quieter detail markers", () => {
|
||||||
expect(getLayer("valves")).toMatchObject({ minzoom: 8.5 });
|
expect(getLayer("valves")).toMatchObject({ minzoom: 13 });
|
||||||
expect(getLayer("valves-symbol")).toMatchObject({ minzoom: 8.5 });
|
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")).toMatchObject({ minzoom: 8.5 });
|
||||||
expect(getLayer("reservoirs-symbol")).toMatchObject({
|
expect(getLayer("reservoirs-symbol")).toMatchObject({
|
||||||
minzoom: 8.5,
|
minzoom: 8.5,
|
||||||
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir }
|
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir }
|
||||||
});
|
});
|
||||||
expect(getLayer("pumps-symbol")).toMatchObject({
|
expect(getLayer("pumps-symbol")).toMatchObject({
|
||||||
minzoom: 11,
|
minzoom: 8.5,
|
||||||
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.pump }
|
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.pump }
|
||||||
});
|
});
|
||||||
expect(getLayer("tanks-symbol")).toMatchObject({
|
expect(getLayer("tanks-symbol")).toMatchObject({
|
||||||
minzoom: 11,
|
minzoom: 8.5,
|
||||||
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.tank }
|
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.tank }
|
||||||
});
|
});
|
||||||
expect(getLayer("water_quality_monitoring_points-symbol")).toMatchObject({
|
expect(getLayer("water_quality_monitoring_points-symbol")).toMatchObject({
|
||||||
type: "symbol",
|
type: "symbol",
|
||||||
minzoom: 8,
|
minzoom: 11,
|
||||||
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.waterQuality }
|
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<string, unknown> | undefined;
|
const valvePaint = getLayer("valves")?.paint as Record<string, unknown> | undefined;
|
||||||
expect(valvePaint?.["circle-opacity"]).toEqual(supplyFacilityOpacityExpression);
|
expect(valvePaint?.["circle-opacity"]).toEqual(SUPPLY_LAYER_VISUALS.valves.opacity);
|
||||||
|
|
||||||
const reservoirPaint = getLayer("reservoirs")?.paint as Record<string, unknown> | undefined;
|
const reservoirPaint = getLayer("reservoirs")?.paint as Record<string, unknown> | undefined;
|
||||||
expect(reservoirPaint?.["circle-opacity"]).toBe(0);
|
expect(reservoirPaint?.["circle-opacity"]).toBe(0);
|
||||||
|
|||||||
@@ -14,11 +14,8 @@ import {
|
|||||||
} from "./sources";
|
} from "./sources";
|
||||||
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
||||||
import {
|
import {
|
||||||
createSupplyActiveOpacity,
|
|
||||||
createSupplyStateColor,
|
createSupplyStateColor,
|
||||||
supplyFacilityOpacityExpression,
|
|
||||||
supplyInactiveExpression,
|
supplyInactiveExpression,
|
||||||
supplyIconOpacityExpression,
|
|
||||||
supplyPipeColorExpression,
|
supplyPipeColorExpression,
|
||||||
supplyPipeDashExpression
|
supplyPipeDashExpression
|
||||||
} from "./supply-style-expressions";
|
} from "./supply-style-expressions";
|
||||||
@@ -66,10 +63,11 @@ layers.push(
|
|||||||
source: "pipes",
|
source: "pipes",
|
||||||
"source-layer": SOURCE_LAYERS.pipes,
|
"source-layer": SOURCE_LAYERS.pipes,
|
||||||
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
|
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
|
||||||
|
filter: ["!", supplyInactiveExpression],
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": MAP_STYLE_TOKENS.canvas.casing,
|
"line-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
"line-width": SUPPLY_LAYER_VISUALS.pipes.casingWidth,
|
"line-width": SUPPLY_LAYER_VISUALS.pipes.casingWidth,
|
||||||
"line-opacity": createSupplyActiveOpacity(0.86),
|
"line-opacity": 0.86,
|
||||||
"line-dasharray": supplyPipeDashExpression
|
"line-dasharray": supplyPipeDashExpression
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -79,10 +77,11 @@ layers.push(
|
|||||||
source: "pipes",
|
source: "pipes",
|
||||||
"source-layer": SOURCE_LAYERS.pipes,
|
"source-layer": SOURCE_LAYERS.pipes,
|
||||||
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
|
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
|
||||||
|
filter: ["!", supplyInactiveExpression],
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": supplyPipeColorExpression,
|
"line-color": supplyPipeColorExpression,
|
||||||
"line-width": SUPPLY_LAYER_VISUALS.pipes.width,
|
"line-width": SUPPLY_LAYER_VISUALS.pipes.width,
|
||||||
"line-opacity": createSupplyActiveOpacity(0.92),
|
"line-opacity": 0.92,
|
||||||
"line-dasharray": supplyPipeDashExpression
|
"line-dasharray": supplyPipeDashExpression
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -136,7 +135,7 @@ layers.push(
|
|||||||
paint: {
|
paint: {
|
||||||
"circle-color": MAP_STYLE_TOKENS.supply.junction,
|
"circle-color": MAP_STYLE_TOKENS.supply.junction,
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.junctions.radius,
|
"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: {
|
paint: {
|
||||||
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.valves.haloRadius,
|
"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: {
|
paint: {
|
||||||
"circle-color": createSupplyStateColor(MAP_STYLE_TOKENS.supply.valve),
|
"circle-color": createSupplyStateColor(MAP_STYLE_TOKENS.supply.valve),
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.valves.radius,
|
"circle-radius": SUPPLY_LAYER_VISUALS.valves.radius,
|
||||||
"circle-opacity": supplyFacilityOpacityExpression,
|
"circle-opacity": SUPPLY_LAYER_VISUALS.valves.opacity,
|
||||||
"circle-stroke-width": 0
|
"circle-stroke-width": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -173,11 +172,12 @@ layers.push(
|
|||||||
layout: {
|
layout: {
|
||||||
"icon-image": SUPPLY_ASSET_IMAGE_IDS.valve,
|
"icon-image": SUPPLY_ASSET_IMAGE_IDS.valve,
|
||||||
"icon-size": SUPPLY_LAYER_VISUALS.valves.iconSize,
|
"icon-size": SUPPLY_LAYER_VISUALS.valves.iconSize,
|
||||||
"icon-allow-overlap": true,
|
"icon-padding": 5,
|
||||||
"icon-ignore-placement": true
|
"icon-allow-overlap": false,
|
||||||
|
"icon-ignore-placement": false
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
"icon-opacity": supplyIconOpacityExpression
|
"icon-opacity": SUPPLY_LAYER_VISUALS.valves.iconOpacity
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -189,7 +189,7 @@ layers.push(
|
|||||||
paint: {
|
paint: {
|
||||||
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.haloRadius,
|
"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: {
|
layout: {
|
||||||
"icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir,
|
"icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir,
|
||||||
"icon-size": SUPPLY_LAYER_VISUALS.reservoirs.iconSize,
|
"icon-size": SUPPLY_LAYER_VISUALS.reservoirs.iconSize,
|
||||||
|
"icon-padding": 6,
|
||||||
"icon-allow-overlap": true,
|
"icon-allow-overlap": true,
|
||||||
"icon-ignore-placement": true
|
"icon-ignore-placement": false
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
"icon-opacity": supplyIconOpacityExpression
|
"icon-opacity": SUPPLY_LAYER_VISUALS.reservoirs.iconOpacity
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -230,11 +231,12 @@ layers.push(
|
|||||||
layout: {
|
layout: {
|
||||||
"icon-image": SUPPLY_ASSET_IMAGE_IDS.pump,
|
"icon-image": SUPPLY_ASSET_IMAGE_IDS.pump,
|
||||||
"icon-size": SUPPLY_LAYER_VISUALS.pumps.iconSize,
|
"icon-size": SUPPLY_LAYER_VISUALS.pumps.iconSize,
|
||||||
|
"icon-padding": 6,
|
||||||
"icon-allow-overlap": true,
|
"icon-allow-overlap": true,
|
||||||
"icon-ignore-placement": true
|
"icon-ignore-placement": false
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
"icon-opacity": supplyIconOpacityExpression
|
"icon-opacity": SUPPLY_LAYER_VISUALS.pumps.iconOpacity
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -246,11 +248,12 @@ layers.push(
|
|||||||
layout: {
|
layout: {
|
||||||
"icon-image": SUPPLY_ASSET_IMAGE_IDS.tank,
|
"icon-image": SUPPLY_ASSET_IMAGE_IDS.tank,
|
||||||
"icon-size": SUPPLY_LAYER_VISUALS.tanks.iconSize,
|
"icon-size": SUPPLY_LAYER_VISUALS.tanks.iconSize,
|
||||||
|
"icon-padding": 6,
|
||||||
"icon-allow-overlap": true,
|
"icon-allow-overlap": true,
|
||||||
"icon-ignore-placement": true
|
"icon-ignore-placement": false
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
"icon-opacity": supplyIconOpacityExpression
|
"icon-opacity": SUPPLY_LAYER_VISUALS.tanks.iconOpacity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,10 +66,11 @@ describe("workbench supply layer controls", () => {
|
|||||||
collection: "fx-water-assets-20200423",
|
collection: "fx-water-assets-20200423",
|
||||||
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
||||||
featureCount: 215_797,
|
featureCount: 215_797,
|
||||||
minZoom: 10,
|
minZoom: 8.5,
|
||||||
visible: false
|
visible: false
|
||||||
});
|
});
|
||||||
expect(items.find((item) => item.id === "fx_20200811_dn100_pipes")).toMatchObject({
|
expect(items.find((item) => item.id === "fx_20200811_dn100_pipes")).toMatchObject({
|
||||||
|
minZoom: 8.5,
|
||||||
collection: "fx-pipe-inventory-20200811",
|
collection: "fx-pipe-inventory-20200811",
|
||||||
featureCount: 122_686,
|
featureCount: 122_686,
|
||||||
visual: { kind: "line", dashed: true }
|
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({
|
expect(items.find((item) => item.id === "water_quality_monitoring_points")).toMatchObject({
|
||||||
group: "monitoring",
|
group: "monitoring",
|
||||||
groupLabel: "监测点",
|
groupLabel: "监测点",
|
||||||
|
minZoom: 11,
|
||||||
visual: { kind: "image", imageSrc: "/map/water-quality-monitoring.svg" },
|
visual: { kind: "image", imageSrc: "/map/water-quality-monitoring.svg" },
|
||||||
visible: true
|
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", () => {
|
it("omits data-dependent controls when their GeoServer layers are empty", () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
MapLegendItem
|
MapLegendItem
|
||||||
} from "@/features/map/core";
|
} from "@/features/map/core";
|
||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
|
import { SOURCE_GIS_MIN_ZOOM, SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
import {
|
import {
|
||||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||||
SUPPLY_LAYER_CATALOG,
|
SUPPLY_LAYER_CATALOG,
|
||||||
@@ -67,13 +68,13 @@ const LAYER_CONTROL_PRESENTATION: Record<
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
junctions: {
|
junctions: {
|
||||||
description: "拓扑连接点",
|
description: "拓扑连接点 · 随缩放细化",
|
||||||
minZoom: 15,
|
minZoom: SUPPLY_LAYER_VISUALS.junctions.minzoom,
|
||||||
visual: { kind: "point", color: MAP_STYLE_TOKENS.supply.junction }
|
visual: { kind: "point", color: MAP_STYLE_TOKENS.supply.junction }
|
||||||
},
|
},
|
||||||
valves: {
|
valves: {
|
||||||
description: "控制设施",
|
description: "控制设施 · 符号自动避让",
|
||||||
minZoom: 8.5,
|
minZoom: SUPPLY_LAYER_VISUALS.valves.minzoom,
|
||||||
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.valve }
|
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.valve }
|
||||||
},
|
},
|
||||||
reservoirs: {
|
reservoirs: {
|
||||||
@@ -83,22 +84,22 @@ const LAYER_CONTROL_PRESENTATION: Record<
|
|||||||
},
|
},
|
||||||
pumps: {
|
pumps: {
|
||||||
description: "加压设施",
|
description: "加压设施",
|
||||||
minZoom: 11,
|
minZoom: SUPPLY_LAYER_VISUALS.pumps.minzoom,
|
||||||
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.pump }
|
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.pump }
|
||||||
},
|
},
|
||||||
tanks: {
|
tanks: {
|
||||||
description: "调蓄设施",
|
description: "调蓄设施",
|
||||||
minZoom: 11,
|
minZoom: SUPPLY_LAYER_VISUALS.tanks.minzoom,
|
||||||
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.tank }
|
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.tank }
|
||||||
},
|
},
|
||||||
water_quality_monitoring_points: {
|
water_quality_monitoring_points: {
|
||||||
description: "灵敏度分析选点",
|
description: "灵敏度分析选点",
|
||||||
minZoom: 8,
|
minZoom: SUPPLY_LAYER_VISUALS.waterQuality.minzoom,
|
||||||
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality }
|
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality }
|
||||||
},
|
},
|
||||||
fx_20200423_pipes: {
|
fx_20200423_pipes: {
|
||||||
description: "全口径管线",
|
description: "全口径管线",
|
||||||
minZoom: 10,
|
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_pipes,
|
||||||
collection: "fx-water-assets-20200423",
|
collection: "fx-water-assets-20200423",
|
||||||
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
||||||
visual: {
|
visual: {
|
||||||
@@ -112,7 +113,7 @@ const LAYER_CONTROL_PRESENTATION: Record<
|
|||||||
},
|
},
|
||||||
fx_20200811_dn100_pipes: {
|
fx_20200811_dn100_pipes: {
|
||||||
description: "DN100 及以上",
|
description: "DN100 及以上",
|
||||||
minZoom: 9,
|
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200811_dn100_pipes,
|
||||||
collection: "fx-pipe-inventory-20200811",
|
collection: "fx-pipe-inventory-20200811",
|
||||||
collectionLabel: "2020-08-11 · 专项管线台账",
|
collectionLabel: "2020-08-11 · 专项管线台账",
|
||||||
visual: {
|
visual: {
|
||||||
@@ -123,49 +124,49 @@ const LAYER_CONTROL_PRESENTATION: Record<
|
|||||||
},
|
},
|
||||||
fx_20200423_valves: {
|
fx_20200423_valves: {
|
||||||
description: "源设施台账",
|
description: "源设施台账",
|
||||||
minZoom: 14,
|
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_valves,
|
||||||
collection: "fx-water-assets-20200423",
|
collection: "fx-water-assets-20200423",
|
||||||
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
||||||
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve }
|
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve }
|
||||||
},
|
},
|
||||||
fx_20200423_drain_valves: {
|
fx_20200423_drain_valves: {
|
||||||
description: "源设施台账",
|
description: "源设施台账",
|
||||||
minZoom: 10,
|
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_drain_valves,
|
||||||
collection: "fx-water-assets-20200423",
|
collection: "fx-water-assets-20200423",
|
||||||
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
||||||
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve }
|
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve }
|
||||||
},
|
},
|
||||||
fx_20200423_flow_meters: {
|
fx_20200423_flow_meters: {
|
||||||
description: "源仪表台账",
|
description: "源仪表台账",
|
||||||
minZoom: 10,
|
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_flow_meters,
|
||||||
collection: "fx-water-assets-20200423",
|
collection: "fx-water-assets-20200423",
|
||||||
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
||||||
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter }
|
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter }
|
||||||
},
|
},
|
||||||
fx_20200423_hydrants: {
|
fx_20200423_hydrants: {
|
||||||
description: "源设施台账",
|
description: "源设施台账",
|
||||||
minZoom: 13,
|
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_hydrants,
|
||||||
collection: "fx-water-assets-20200423",
|
collection: "fx-water-assets-20200423",
|
||||||
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
||||||
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant }
|
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant }
|
||||||
},
|
},
|
||||||
fx_20200423_service_connections: {
|
fx_20200423_service_connections: {
|
||||||
description: "源设施台账",
|
description: "源设施台账",
|
||||||
minZoom: 13,
|
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_service_connections,
|
||||||
collection: "fx-water-assets-20200423",
|
collection: "fx-water-assets-20200423",
|
||||||
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
||||||
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection }
|
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection }
|
||||||
},
|
},
|
||||||
fx_20200423_customer_meters: {
|
fx_20200423_customer_meters: {
|
||||||
description: "源水表台账",
|
description: "源水表台账",
|
||||||
minZoom: 13,
|
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_customer_meters,
|
||||||
collection: "fx-water-assets-20200423",
|
collection: "fx-water-assets-20200423",
|
||||||
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
||||||
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter }
|
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter }
|
||||||
},
|
},
|
||||||
fx_20200423_non_residential_meters: {
|
fx_20200423_non_residential_meters: {
|
||||||
description: "源水表台账",
|
description: "源水表台账",
|
||||||
minZoom: 15,
|
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_non_residential_meters,
|
||||||
collection: "fx-water-assets-20200423",
|
collection: "fx-water-assets-20200423",
|
||||||
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
collectionLabel: "2020-04-23 · 全网除头桥快照",
|
||||||
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter }
|
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter }
|
||||||
@@ -526,7 +527,7 @@ export function createLayerControlItems(
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
minZoom: 8.5,
|
minZoom: item.minZoom,
|
||||||
visual: {
|
visual: {
|
||||||
kind: "image" as const,
|
kind: "image" as const,
|
||||||
imageSrc:
|
imageSrc:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
|
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
|
||||||
|
import { createSupplyZoomOpacity } from "./supply-style-expressions";
|
||||||
|
|
||||||
type LayerSpecification = StyleSpecification["layers"][number];
|
type LayerSpecification = StyleSpecification["layers"][number];
|
||||||
type LineLayerSpecification = Extract<LayerSpecification, { type: "line" }>;
|
type LineLayerSpecification = Extract<LayerSpecification, { type: "line" }>;
|
||||||
@@ -9,6 +10,7 @@ type CircleRadius = NonNullable<NonNullable<CircleLayerSpecification["paint"]>["
|
|||||||
type IconSize = NonNullable<NonNullable<SymbolLayerSpecification["layout"]>["icon-size"]>;
|
type IconSize = NonNullable<NonNullable<SymbolLayerSpecification["layout"]>["icon-size"]>;
|
||||||
|
|
||||||
export const MAP_MAX_ZOOM = 24;
|
export const MAP_MAX_ZOOM = 24;
|
||||||
|
export const MONITORING_POINT_MIN_ZOOM = 11;
|
||||||
|
|
||||||
const lineHitWidth = [
|
const lineHitWidth = [
|
||||||
"interpolate",
|
"interpolate",
|
||||||
@@ -58,17 +60,17 @@ const supplyPipeWidth = [
|
|||||||
["linear"],
|
["linear"],
|
||||||
["zoom"],
|
["zoom"],
|
||||||
9,
|
9,
|
||||||
pipeWidthAtZoom(0.7, 1, 1.4),
|
pipeWidthAtZoom(0.4, 0.7, 1.1),
|
||||||
11,
|
11,
|
||||||
pipeWidthAtZoom(0.9, 1.3, 1.8),
|
pipeWidthAtZoom(0.65, 1.05, 1.6),
|
||||||
14,
|
14,
|
||||||
pipeWidthAtZoom(1.2, 1.8, 2.6),
|
pipeWidthAtZoom(1.3, 2, 2.9),
|
||||||
17,
|
17,
|
||||||
pipeWidthAtZoom(1.8, 2.8, 4),
|
pipeWidthAtZoom(2.2, 3.4, 4.8),
|
||||||
20,
|
20,
|
||||||
pipeWidthAtZoom(2.6, 4.2, 6),
|
pipeWidthAtZoom(3.2, 5, 7),
|
||||||
24,
|
24,
|
||||||
pipeWidthAtZoom(3.2, 5.2, 7)
|
pipeWidthAtZoom(4.2, 6.8, 9.2)
|
||||||
] as unknown as LineWidth;
|
] as unknown as LineWidth;
|
||||||
|
|
||||||
const supplyPipeCasingWidth = [
|
const supplyPipeCasingWidth = [
|
||||||
@@ -76,39 +78,19 @@ const supplyPipeCasingWidth = [
|
|||||||
["linear"],
|
["linear"],
|
||||||
["zoom"],
|
["zoom"],
|
||||||
9,
|
9,
|
||||||
pipeWidthAtZoom(2.1, 2.4, 2.8),
|
pipeWidthAtZoom(1.2, 1.5, 1.9),
|
||||||
11,
|
11,
|
||||||
pipeWidthAtZoom(2.3, 2.7, 3.2),
|
pipeWidthAtZoom(1.65, 2.05, 2.6),
|
||||||
14,
|
14,
|
||||||
pipeWidthAtZoom(2.7, 3.3, 4.1),
|
pipeWidthAtZoom(2.7, 3.4, 4.3),
|
||||||
17,
|
17,
|
||||||
pipeWidthAtZoom(3.6, 4.6, 5.8),
|
pipeWidthAtZoom(4, 5.2, 6.6),
|
||||||
20,
|
20,
|
||||||
pipeWidthAtZoom(4.5, 6.1, 7.9),
|
pipeWidthAtZoom(5.2, 7, 9),
|
||||||
24,
|
24,
|
||||||
pipeWidthAtZoom(5.2, 7.2, 9)
|
pipeWidthAtZoom(6.6, 9.2, 11.6)
|
||||||
] as unknown as LineWidth;
|
] 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 = [
|
const facilityHoverRadius = [
|
||||||
"interpolate", ["linear"], ["zoom"],
|
"interpolate", ["linear"], ["zoom"],
|
||||||
8.5, 5,
|
8.5, 5,
|
||||||
@@ -131,12 +113,26 @@ const facilitySelectedRadius = [
|
|||||||
|
|
||||||
const facilityIconSize = [
|
const facilityIconSize = [
|
||||||
"interpolate", ["linear"], ["zoom"],
|
"interpolate", ["linear"], ["zoom"],
|
||||||
8.5, 0.18,
|
8.5, 0.1,
|
||||||
10, 0.23,
|
12, 0.16,
|
||||||
12, 0.29,
|
16, 0.28,
|
||||||
16, 0.36,
|
20, 0.4,
|
||||||
20, 0.46,
|
24, 0.5
|
||||||
24, 0.56
|
] 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;
|
] as IconSize;
|
||||||
|
|
||||||
export const SUPPLY_LAYER_VISUALS = {
|
export const SUPPLY_LAYER_VISUALS = {
|
||||||
@@ -151,20 +147,24 @@ export const SUPPLY_LAYER_VISUALS = {
|
|||||||
hitWidth: lineHitWidth
|
hitWidth: lineHitWidth
|
||||||
},
|
},
|
||||||
junctions: {
|
junctions: {
|
||||||
minzoom: 15,
|
minzoom: 12,
|
||||||
haloRadius: ["interpolate", ["linear"], ["zoom"], 13, 2.8, 17, 6] as CircleRadius,
|
haloRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 0.6, 13, 1.4, 17, 3.5, 24, 6] as CircleRadius,
|
||||||
haloOpacity: ["interpolate", ["linear"], ["zoom"], 15, 0, 17, 0, 19, 0.55, 24, 0.82] as const,
|
haloOpacity: ["interpolate", ["linear"], ["zoom"], 8.5, 0, 15, 0.08, 19, 0.45, 24, 0.7] as const,
|
||||||
radius: ["interpolate", ["linear"], ["zoom"], 13, 1.5, 17, 3.8] as CircleRadius,
|
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,
|
hoverRadius: ["interpolate", ["linear"], ["zoom"], 13, 5, 17, 10] as CircleRadius,
|
||||||
selectedRadius: ["interpolate", ["linear"], ["zoom"], 13, 5.5, 17, 10.5] 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: {
|
valves: {
|
||||||
minzoom: 8.5,
|
minzoom: 13,
|
||||||
casingWidth: ["interpolate", ["linear"], ["zoom"], 11, 3.2, 17, 7.5, 22, 9, 24, 10] as LineWidth,
|
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,
|
width: ["interpolate", ["linear"], ["zoom"], 11, 1.6, 17, 4.4, 22, 6, 24, 7] as LineWidth,
|
||||||
haloRadius: facilityCasingRadius,
|
haloRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 1, 12, 1.6, 16, 3, 20, 4.5, 24, 5.5] as CircleRadius,
|
||||||
radius: facilityRadius,
|
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,
|
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,
|
hoverWidth: ["interpolate", ["linear"], ["zoom"], 11, 4, 17, 7, 22, 9, 24, 10.5] as LineWidth,
|
||||||
hoverRadius: facilityHoverRadius,
|
hoverRadius: facilityHoverRadius,
|
||||||
@@ -177,6 +177,8 @@ export const SUPPLY_LAYER_VISUALS = {
|
|||||||
},
|
},
|
||||||
reservoirs: {
|
reservoirs: {
|
||||||
minzoom: 8.5,
|
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,
|
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,
|
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,
|
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
|
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: {
|
scada: {
|
||||||
minzoom: 8.5,
|
minzoom: MONITORING_POINT_MIN_ZOOM,
|
||||||
haloRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 5.5, 10, 7, 12, 8.5, 16, 10, 20, 12, 24, 14] as CircleRadius,
|
haloRadius: monitoringPointHaloRadius,
|
||||||
radius: ["interpolate", ["linear"], ["zoom"], 8.5, 4, 10, 5.5, 12, 7, 16, 8.5, 20, 10.5, 24, 12.5] as CircleRadius,
|
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,
|
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,
|
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,
|
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: {
|
pumps: {
|
||||||
minzoom: 11,
|
minzoom: 8.5,
|
||||||
|
iconOpacity: importantFacilityIconOpacity,
|
||||||
hoverRadius: 14,
|
hoverRadius: 14,
|
||||||
selectedRadius: 12.5,
|
selectedRadius: 12.5,
|
||||||
hitRadius: 16,
|
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: {
|
tanks: {
|
||||||
minzoom: 11,
|
minzoom: 8.5,
|
||||||
|
iconOpacity: importantFacilityIconOpacity,
|
||||||
hoverRadius: 14,
|
hoverRadius: 14,
|
||||||
selectedRadius: 12.5,
|
selectedRadius: 12.5,
|
||||||
hitRadius: 16,
|
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;
|
} 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;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
|
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
|
||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
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 {
|
import {
|
||||||
SOURCE_LAYERS,
|
SOURCE_LAYERS,
|
||||||
type GeoServerWaterNetworkSourceId
|
type GeoServerWaterNetworkSourceId
|
||||||
@@ -24,19 +24,19 @@ const definitions: PublishedLayerDefinition[] = [
|
|||||||
sourceId: "water_quality_monitoring_points",
|
sourceId: "water_quality_monitoring_points",
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
color: MAP_STYLE_TOKENS.supply.waterQuality,
|
color: MAP_STYLE_TOKENS.supply.waterQuality,
|
||||||
minzoom: 8,
|
minzoom: SUPPLY_LAYER_VISUALS.waterQuality.minzoom,
|
||||||
radius: 5.5,
|
radius: 5.5,
|
||||||
imageId: SUPPLY_ASSET_IMAGE_IDS.waterQuality
|
imageId: SUPPLY_ASSET_IMAGE_IDS.waterQuality
|
||||||
},
|
},
|
||||||
{ sourceId: "fx_20200423_pipes", geometry: "line", color: MAP_STYLE_TOKENS.supply.sourcePipe, minzoom: 10, lineVariant: "network" },
|
{ 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: 9, lineVariant: "inventory" },
|
{ 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: 14, radius: 3.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceValve },
|
{ 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: 10, radius: 3.6, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve },
|
{ 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: 10, radius: 4.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter },
|
{ 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: 13, radius: 3.5, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceHydrant },
|
{ 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: 13, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceConnection },
|
{ 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: 13, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter },
|
{ 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: 15, radius: 2.8, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter }
|
{ 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 = [
|
const sourcePipeColorExpression: ExpressionSpecification = [
|
||||||
@@ -102,9 +102,10 @@ function createBusinessLayers(definition: PublishedLayerDefinition): Layer[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const radius = definition.radius ?? 3.2;
|
const radius = definition.radius ?? 3.2;
|
||||||
const iconSize = definition.sourceId === "water_quality_monitoring_points"
|
const important = definition.sourceId === "water_quality_monitoring_points";
|
||||||
? ["interpolate", ["linear"], ["zoom"], definition.minzoom, 0.25, 18, 0.42] as ExpressionSpecification
|
const iconSize = important
|
||||||
: ["interpolate", ["linear"], ["zoom"], definition.minzoom, 0.36, 18, 0.54] as ExpressionSpecification;
|
? SUPPLY_LAYER_VISUALS.waterQuality.iconSize
|
||||||
|
: SUPPLY_LAYER_VISUALS.valves.iconSize;
|
||||||
const layers: Layer[] = [
|
const layers: Layer[] = [
|
||||||
{
|
{
|
||||||
id: `${definition.sourceId}-halo`,
|
id: `${definition.sourceId}-halo`,
|
||||||
@@ -114,8 +115,9 @@ function createBusinessLayers(definition: PublishedLayerDefinition): Layer[] {
|
|||||||
minzoom: definition.minzoom,
|
minzoom: definition.minzoom,
|
||||||
paint: {
|
paint: {
|
||||||
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
"circle-radius": radius + 1.8,
|
"circle-radius": important ? SUPPLY_LAYER_VISUALS.waterQuality.haloRadius
|
||||||
"circle-opacity": 0.9
|
: ["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,
|
minzoom: definition.minzoom,
|
||||||
paint: {
|
paint: {
|
||||||
"circle-color": definition.color,
|
"circle-color": definition.color,
|
||||||
"circle-radius": ["interpolate", ["linear"], ["zoom"], definition.minzoom, radius, 18, radius * 1.35],
|
"circle-radius": important
|
||||||
"circle-opacity": 0.9,
|
? 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"
|
"circle-stroke-color": definition.sourceId === "water_quality_monitoring_points"
|
||||||
? MAP_STYLE_TOKENS.canvas.casing
|
? MAP_STYLE_TOKENS.canvas.casing
|
||||||
: definition.color,
|
: definition.color,
|
||||||
@@ -145,11 +149,12 @@ function createBusinessLayers(definition: PublishedLayerDefinition): Layer[] {
|
|||||||
layout: {
|
layout: {
|
||||||
"icon-image": definition.imageId,
|
"icon-image": definition.imageId,
|
||||||
"icon-size": iconSize,
|
"icon-size": iconSize,
|
||||||
"icon-allow-overlap": true,
|
"icon-padding": important ? 6 : 5,
|
||||||
"icon-ignore-placement": true
|
"icon-allow-overlap": important,
|
||||||
|
"icon-ignore-placement": false
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
"icon-opacity": 0.96
|
"icon-opacity": important ? SUPPLY_LAYER_VISUALS.waterQuality.iconOpacity : SUPPLY_LAYER_VISUALS.valves.iconOpacity
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { MAP_STYLE_TOKENS } from "./map-colors";
|
|||||||
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
import {
|
import {
|
||||||
createSupplyStateColor,
|
createSupplyStateColor,
|
||||||
supplyFacilityOpacityExpression,
|
|
||||||
supplyIconOpacityExpression
|
supplyIconOpacityExpression
|
||||||
} from "./supply-style-expressions";
|
} from "./supply-style-expressions";
|
||||||
|
|
||||||
@@ -34,6 +33,7 @@ export const SCADA_LAYER_CONTROL_ITEMS = [
|
|||||||
label: "压力监测点",
|
label: "压力监测点",
|
||||||
description: "83 个压力监测点",
|
description: "83 个压力监测点",
|
||||||
deviceType: "pressure",
|
deviceType: "pressure",
|
||||||
|
minZoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
||||||
imageId: SCADA_IMAGE_IDS.pressure,
|
imageId: SCADA_IMAGE_IDS.pressure,
|
||||||
color: MAP_STYLE_TOKENS.supply.scadaPressure
|
color: MAP_STYLE_TOKENS.supply.scadaPressure
|
||||||
},
|
},
|
||||||
@@ -42,6 +42,7 @@ export const SCADA_LAYER_CONTROL_ITEMS = [
|
|||||||
label: "流量监测点",
|
label: "流量监测点",
|
||||||
description: "35 个流量监测点",
|
description: "35 个流量监测点",
|
||||||
deviceType: "pipe_flow",
|
deviceType: "pipe_flow",
|
||||||
|
minZoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
||||||
imageId: SCADA_IMAGE_IDS.flow,
|
imageId: SCADA_IMAGE_IDS.flow,
|
||||||
color: MAP_STYLE_TOKENS.supply.scadaFlow
|
color: MAP_STYLE_TOKENS.supply.scadaFlow
|
||||||
}
|
}
|
||||||
@@ -80,11 +81,11 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
|
|||||||
type: "circle",
|
type: "circle",
|
||||||
source: SCADA_SOURCE_ID,
|
source: SCADA_SOURCE_ID,
|
||||||
filter,
|
filter,
|
||||||
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
minzoom: definition.minZoom,
|
||||||
paint: {
|
paint: {
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
|
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
|
||||||
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
"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",
|
type: "circle",
|
||||||
source: SCADA_SOURCE_ID,
|
source: SCADA_SOURCE_ID,
|
||||||
filter,
|
filter,
|
||||||
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
minzoom: definition.minZoom,
|
||||||
paint: {
|
paint: {
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
|
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
|
||||||
"circle-color": color,
|
"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",
|
type: "symbol",
|
||||||
source: SCADA_SOURCE_ID,
|
source: SCADA_SOURCE_ID,
|
||||||
filter,
|
filter,
|
||||||
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
minzoom: definition.minZoom,
|
||||||
layout: {
|
layout: {
|
||||||
"icon-image": definition.imageId,
|
"icon-image": definition.imageId,
|
||||||
"icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize,
|
"icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize,
|
||||||
"icon-allow-overlap": true,
|
"icon-padding": 7,
|
||||||
"icon-ignore-placement": true
|
"icon-allow-overlap": false,
|
||||||
|
"icon-ignore-placement": false
|
||||||
},
|
},
|
||||||
paint: { "icon-opacity": supplyIconOpacityExpression }
|
paint: { "icon-opacity": SUPPLY_LAYER_VISUALS.scada.iconOpacity }
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
const fallback: Layer[] = [
|
const fallback: Layer[] = [
|
||||||
@@ -121,7 +123,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
|
|||||||
type: "circle",
|
type: "circle",
|
||||||
source: SCADA_SOURCE_ID,
|
source: SCADA_SOURCE_ID,
|
||||||
filter,
|
filter,
|
||||||
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
minzoom: definition.minZoom,
|
||||||
paint: {
|
paint: {
|
||||||
"circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2],
|
"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,
|
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
@@ -135,7 +137,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
|
|||||||
type: "circle",
|
type: "circle",
|
||||||
source: SCADA_SOURCE_ID,
|
source: SCADA_SOURCE_ID,
|
||||||
filter,
|
filter,
|
||||||
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
minzoom: definition.minZoom,
|
||||||
paint: {
|
paint: {
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius,
|
"circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius,
|
||||||
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
|
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
|
||||||
@@ -150,7 +152,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
|
|||||||
type: "circle",
|
type: "circle",
|
||||||
source: SCADA_SOURCE_ID,
|
source: SCADA_SOURCE_ID,
|
||||||
filter,
|
filter,
|
||||||
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
minzoom: definition.minZoom,
|
||||||
paint: {
|
paint: {
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius,
|
"circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius,
|
||||||
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
|
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
|
||||||
@@ -165,7 +167,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
|
|||||||
type: "circle",
|
type: "circle",
|
||||||
source: SCADA_SOURCE_ID,
|
source: SCADA_SOURCE_ID,
|
||||||
filter,
|
filter,
|
||||||
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
minzoom: definition.minZoom,
|
||||||
paint: {
|
paint: {
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius,
|
"circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius,
|
||||||
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
|
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
|
||||||
@@ -181,7 +183,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
|
|||||||
type: "circle",
|
type: "circle",
|
||||||
source: SCADA_SOURCE_ID,
|
source: SCADA_SOURCE_ID,
|
||||||
filter,
|
filter,
|
||||||
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
minzoom: definition.minZoom,
|
||||||
paint: {
|
paint: {
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius,
|
"circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius,
|
||||||
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
|
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
import { simulationLayerIds } from "./annotation-layers";
|
import { simulationLayerIds } from "./annotation-layers";
|
||||||
|
import { setMapLayersVisibility } from "./layer-visibility";
|
||||||
|
|
||||||
export function setSimulationLayersVisibility(map: MapLibreMap, visible: boolean) {
|
export function setSimulationLayersVisibility(map: MapLibreMap, visible: boolean, animate = true) {
|
||||||
const visibility = visible ? "visible" : "none";
|
setMapLayersVisibility(map, simulationLayerIds, visible, animate);
|
||||||
|
|
||||||
simulationLayerIds.forEach((layerId) => {
|
|
||||||
if (map.getLayer(layerId)) {
|
|
||||||
map.setLayoutProperty(layerId, "visibility", visibility);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
|
import { SCADA_ICON_PATHS } from "./scada";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SUPPLY_ASSET_ICON_PATHS,
|
SUPPLY_ASSET_ICON_PATHS,
|
||||||
@@ -9,6 +12,19 @@ import {
|
|||||||
} from "./supply-icons";
|
} from "./supply-icons";
|
||||||
|
|
||||||
describe("supply facility map 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", () => {
|
it("uses MapLibre-compatible PNG assets from the public map directory", () => {
|
||||||
expect(Object.keys(SUPPLY_ASSET_ICON_PATHS)).toEqual([
|
expect(Object.keys(SUPPLY_ASSET_ICON_PATHS)).toEqual([
|
||||||
"valve",
|
"valve",
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ export const supplyIconOpacityExpression: ExpressionSpecification = [
|
|||||||
0.98
|
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(
|
export function createSupplyStateColor(
|
||||||
activeColor: string | ExpressionSpecification
|
activeColor: string | ExpressionSpecification
|
||||||
): ExpressionSpecification {
|
): ExpressionSpecification {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
SymbolLayerSpecification
|
SymbolLayerSpecification
|
||||||
} from "maplibre-gl";
|
} from "maplibre-gl";
|
||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
|
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
import { SOURCE_LAYERS } from "./sources";
|
import { SOURCE_LAYERS } from "./sources";
|
||||||
|
|
||||||
export const VALUE_LABEL_LAYER_IDS = {
|
export const VALUE_LABEL_LAYER_IDS = {
|
||||||
@@ -36,12 +37,12 @@ export function createValueLabelLayers(): SymbolLayerSpecification[] {
|
|||||||
type: "symbol",
|
type: "symbol",
|
||||||
source: sourceId,
|
source: sourceId,
|
||||||
"source-layer": SOURCE_LAYERS[sourceId],
|
"source-layer": SOURCE_LAYERS[sourceId],
|
||||||
minzoom: sourceId === "junctions" ? 15 : 10,
|
minzoom: Math.max(10, SUPPLY_LAYER_VISUALS[sourceId].minzoom),
|
||||||
layout: {
|
layout: {
|
||||||
visibility: "none",
|
visibility: "none",
|
||||||
"symbol-placement": line ? "line-center" : "point",
|
"symbol-placement": line ? "line-center" : "point",
|
||||||
"text-field": "",
|
"text-field": "",
|
||||||
"text-size": 12,
|
"text-size": createValueLabelTextSize(12),
|
||||||
"text-offset": line ? [0, 0] : [0, -1.35],
|
"text-offset": line ? [0, 0] : [0, -1.35],
|
||||||
"text-anchor": line ? "center" : "bottom",
|
"text-anchor": line ? "center" : "bottom",
|
||||||
"text-padding": 5,
|
"text-padding": 5,
|
||||||
@@ -54,7 +55,7 @@ export function createValueLabelLayers(): SymbolLayerSpecification[] {
|
|||||||
"text-halo-color": MAP_STYLE_TOKENS.label.inverse,
|
"text-halo-color": MAP_STYLE_TOKENS.label.inverse,
|
||||||
"text-halo-width": 2,
|
"text-halo-width": 2,
|
||||||
"text-halo-blur": 0.4,
|
"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",
|
"text-field",
|
||||||
createValueLabelTextExpression(style)
|
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.setPaintProperty(layerId, "text-color", style.textColor);
|
||||||
map.setLayoutProperty(layerId, "visibility", "visible");
|
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(
|
function createValueLabelTextExpression(
|
||||||
style: ValueLabelStyle
|
style: ValueLabelStyle
|
||||||
): ExpressionSpecification {
|
): ExpressionSpecification {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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") });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -36,6 +36,12 @@ test("loads SCADA from the backend API and aligns map features by device_id", as
|
|||||||
|
|
||||||
const deviceRequest = await deviceRequestPromise;
|
const deviceRequest = await deviceRequestPromise;
|
||||||
expect(deviceRequest.headers()["x-project-id"]).toBe(TEST_PROJECT_ID);
|
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(() => {
|
await expect.poll(async () => page.evaluate(() => {
|
||||||
const map = globalThis.__waterNetworkMap;
|
const map = globalThis.__waterNetworkMap;
|
||||||
if (!map) return [];
|
if (!map) return [];
|
||||||
@@ -74,6 +80,7 @@ test("shows data-dependent layer controls only when GeoServer reports features",
|
|||||||
await expect(layerTool).toBeEnabled();
|
await expect(layerTool).toBeEnabled();
|
||||||
await layerTool.click();
|
await layerTool.click();
|
||||||
await expect(page.getByRole("button", { name: /^阀门/ })).toBeVisible();
|
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();
|
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]).toBeGreaterThanOrEqual(0);
|
||||||
expect(layerOrder[0]).toBeLessThan(layerOrder[1]);
|
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 }) => {
|
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": "*" } });
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user