feat: refine supply map layers and workbench branding

This commit is contained in:
2026-10-27 17:56:38 +08:00
parent 6983ba03f1
commit 4f0f34f460
23 changed files with 948 additions and 171 deletions
+2 -2
View File
@@ -17,8 +17,8 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
});
await page.goto("/");
await expect(page.getByText("供水管网智能调度系统")).toBeVisible();
await expect(page.getByText("调度工作台")).toBeVisible();
await expect(page.getByText("供水管网智慧运维系统")).toBeVisible();
await expect(page.getByText("运维工作台")).toBeVisible();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect(page.getByLabel("Agent 命令面板")).toHaveCount(0);
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
@@ -117,11 +117,11 @@ export function WorkbenchTopBar({
<Droplets size={18} aria-hidden="true" />
</div>
<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="hidden sm:inline">供水管网智能调度系统</span>
<span className="hidden sm:inline">供水管网智慧运维系统</span>
</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>
@@ -356,7 +356,7 @@ export function useWorkbenchMap({
).forEach((layer) => map.addLayer(layer));
scadaHitLayers.forEach((layer) => map.addLayer(layer));
removeCityScene = installCityScene(map);
setSimulationLayersVisibility(map, impactVisibleRef.current);
setSimulationLayersVisibility(map, impactVisibleRef.current, false);
setMapReady(true);
updateSourceStatus(
setSourceStatuses,
@@ -102,6 +102,7 @@ import {
} from "./layout/workbench-layout";
import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view";
import { setMapLayersVisibility } from "./map/layer-visibility";
import { parseScadaAnalysisItems } from "./map/scada-analysis";
import {
BASE_LAYER_OPTIONS,
@@ -724,9 +725,7 @@ function MapWorkbenchContent({
return;
}
getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => {
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
});
setMapLayersVisibility(map, getWorkbenchLayerIds(map, layerControlId), visible);
}
function handleTogglePresentationLayer(id: string, visible: boolean) {
const before = presentationLayerChangesRef.current.get(id) ?? (layerVisibility[id] !== false);
@@ -5,9 +5,7 @@ type LayoutPropertyName = Parameters<MapLibreMap["setLayoutProperty"]>[1];
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
createSupplyActiveOpacity,
createSupplyStateColor,
supplyFacilityOpacityExpression,
supplyPipeColorExpression,
supplyPipeDashExpression
} from "./supply-style-expressions";
@@ -60,8 +58,8 @@ const POINT_DEFAULT_COLORS = {
} as const;
const POINT_DEFAULT_OPACITIES = {
junctions: 0.9,
valves: supplyFacilityOpacityExpression,
junctions: SUPPLY_LAYER_VISUALS.junctions.opacity,
valves: SUPPLY_LAYER_VISUALS.valves.opacity,
reservoirs: 0
} as const;
@@ -107,7 +105,7 @@ function applyLineStyle(map: MapLibreMap, groupId: "pipes", patch: LineLayerGrou
const prefix = groupId;
const coreId = LINE_CORE_LAYERS[groupId];
if (patch.color !== undefined) setPaint(map, coreId, "line-color", createSupplyStateColor(patch.color));
if (patch.opacity !== undefined) setPaint(map, coreId, "line-opacity", createSupplyActiveOpacity(patch.opacity));
if (patch.opacity !== undefined) setPaint(map, coreId, "line-opacity", patch.opacity);
if (patch.dash !== undefined) {
const dash = patch.dash === "solid"
? [1, 0]
@@ -127,7 +125,7 @@ function applyLineStyle(map: MapLibreMap, groupId: "pipes", patch: LineLayerGrou
function resetLineStyle(map: MapLibreMap, groupId: "pipes") {
const coreId = LINE_CORE_LAYERS[groupId];
setPaint(map, coreId, "line-color", supplyPipeColorExpression);
setPaint(map, coreId, "line-opacity", createSupplyActiveOpacity(0.92));
setPaint(map, coreId, "line-opacity", 0.92);
setPaint(map, coreId, "line-dasharray", supplyPipeDashExpression);
setPaint(map, `${groupId}-casing`, "line-dasharray", supplyPipeDashExpression);
for (const [id, value] of Object.entries(LINE_DEFAULT_WIDTHS)) {
@@ -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];
}
+26 -19
View File
@@ -8,7 +8,7 @@ import {
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
import {
supplyFacilityOpacityExpression,
supplyInactiveExpression,
supplyPipeColorExpression,
supplyPipeDashExpression
} from "./supply-style-expressions";
@@ -21,6 +21,8 @@ describe("supply network layer styling", () => {
expect(paint?.["line-color"]).toEqual(supplyPipeColorExpression);
expect(paint?.["line-width"]).toEqual(SUPPLY_LAYER_VISUALS.pipes.width);
expect(paint?.["line-dasharray"]).toEqual(supplyPipeDashExpression);
expect(paint?.["line-opacity"]).toBe(0.92);
expect(pipe && "filter" in pipe ? pipe.filter : undefined).toEqual(["!", supplyInactiveExpression]);
expect(JSON.stringify(paint?.["line-width"])).toContain("diameter");
expect(JSON.stringify(paint?.["line-width"])).toContain("zoom");
expect(waterNetworkBusinessLayers.some((layer) => layer.id === "pipes-risk-glow")).toBe(false);
@@ -35,6 +37,8 @@ describe("supply network layer styling", () => {
const sourcePipePaint = sourcePipe?.paint as Record<string, unknown> | undefined;
const inventoryPipe = getLayer("fx_20200811_dn100_pipes-line");
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(JSON.stringify(sourcePipePaint?.["line-color"])).toContain("diameter");
@@ -49,55 +53,58 @@ describe("supply network layer styling", () => {
it("uses distinct engineering symbols for every source GIS facility type", () => {
const sourceFacilitySymbols = [
["fx_20200423_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceValve, 14],
["fx_20200423_drain_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve, 10],
["fx_20200423_flow_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter, 10],
["fx_20200423_hydrants-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceHydrant, 13],
["fx_20200423_service_connections-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceConnection, 13],
["fx_20200423_customer_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter, 13],
["fx_20200423_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceValve],
["fx_20200423_drain_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve],
["fx_20200423_flow_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter],
["fx_20200423_hydrants-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceHydrant],
["fx_20200423_service_connections-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceConnection],
["fx_20200423_customer_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter],
[
"fx_20200423_non_residential_meters-symbol",
SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter,
15
SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter
]
] as const;
sourceFacilitySymbols.forEach(([layerId, imageId, minZoom]) => {
sourceFacilitySymbols.forEach(([layerId, imageId]) => {
expect(getLayer(layerId)).toMatchObject({
type: "symbol",
layout: {
"icon-image": imageId,
"icon-size": ["interpolate", ["linear"], ["zoom"], minZoom, 0.36, 18, 0.54]
"icon-size": SUPPLY_LAYER_VISUALS.valves.iconSize,
"icon-allow-overlap": false
}
});
});
});
it("keeps facilities visible from overview while deferring ordinary junctions", () => {
expect(getLayer("valves")).toMatchObject({ minzoom: 8.5 });
expect(getLayer("valves-symbol")).toMatchObject({ minzoom: 8.5 });
it("keeps facilities available from overview with quieter detail markers", () => {
expect(getLayer("valves")).toMatchObject({ minzoom: 13 });
expect(getLayer("valves-symbol")).toMatchObject({ minzoom: 13 });
expect(getLayer("valves-hit")).toMatchObject({ minzoom: 13 });
expect(getLayer("fx_20200423_valves-symbol")).toMatchObject({ minzoom: 13 });
expect(getLayer("fx_20200423_drain_valves-symbol")).toMatchObject({ minzoom: 13 });
expect(getLayer("reservoirs")).toMatchObject({ minzoom: 8.5 });
expect(getLayer("reservoirs-symbol")).toMatchObject({
minzoom: 8.5,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir }
});
expect(getLayer("pumps-symbol")).toMatchObject({
minzoom: 11,
minzoom: 8.5,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.pump }
});
expect(getLayer("tanks-symbol")).toMatchObject({
minzoom: 11,
minzoom: 8.5,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.tank }
});
expect(getLayer("water_quality_monitoring_points-symbol")).toMatchObject({
type: "symbol",
minzoom: 8,
minzoom: 11,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.waterQuality }
});
expect(getLayer("junctions")).toMatchObject({ minzoom: 15 });
expect(getLayer("junctions")).toMatchObject({ minzoom: 12 });
const valvePaint = getLayer("valves")?.paint as Record<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;
expect(reservoirPaint?.["circle-opacity"]).toBe(0);
+21 -18
View File
@@ -14,11 +14,8 @@ import {
} from "./sources";
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
import {
createSupplyActiveOpacity,
createSupplyStateColor,
supplyFacilityOpacityExpression,
supplyInactiveExpression,
supplyIconOpacityExpression,
supplyPipeColorExpression,
supplyPipeDashExpression
} from "./supply-style-expressions";
@@ -66,10 +63,11 @@ layers.push(
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
filter: ["!", supplyInactiveExpression],
paint: {
"line-color": MAP_STYLE_TOKENS.canvas.casing,
"line-width": SUPPLY_LAYER_VISUALS.pipes.casingWidth,
"line-opacity": createSupplyActiveOpacity(0.86),
"line-opacity": 0.86,
"line-dasharray": supplyPipeDashExpression
}
},
@@ -79,10 +77,11 @@ layers.push(
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
filter: ["!", supplyInactiveExpression],
paint: {
"line-color": supplyPipeColorExpression,
"line-width": SUPPLY_LAYER_VISUALS.pipes.width,
"line-opacity": createSupplyActiveOpacity(0.92),
"line-opacity": 0.92,
"line-dasharray": supplyPipeDashExpression
}
},
@@ -136,7 +135,7 @@ layers.push(
paint: {
"circle-color": MAP_STYLE_TOKENS.supply.junction,
"circle-radius": SUPPLY_LAYER_VISUALS.junctions.radius,
"circle-opacity": 0.9
"circle-opacity": SUPPLY_LAYER_VISUALS.junctions.opacity
}
},
{
@@ -148,7 +147,7 @@ layers.push(
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": SUPPLY_LAYER_VISUALS.valves.haloRadius,
"circle-opacity": 0.94
"circle-opacity": SUPPLY_LAYER_VISUALS.valves.haloOpacity
}
},
{
@@ -160,7 +159,7 @@ layers.push(
paint: {
"circle-color": createSupplyStateColor(MAP_STYLE_TOKENS.supply.valve),
"circle-radius": SUPPLY_LAYER_VISUALS.valves.radius,
"circle-opacity": supplyFacilityOpacityExpression,
"circle-opacity": SUPPLY_LAYER_VISUALS.valves.opacity,
"circle-stroke-width": 0
}
},
@@ -173,11 +172,12 @@ layers.push(
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.valve,
"icon-size": SUPPLY_LAYER_VISUALS.valves.iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
"icon-padding": 5,
"icon-allow-overlap": false,
"icon-ignore-placement": false
},
paint: {
"icon-opacity": supplyIconOpacityExpression
"icon-opacity": SUPPLY_LAYER_VISUALS.valves.iconOpacity
}
},
{
@@ -189,7 +189,7 @@ layers.push(
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.haloRadius,
"circle-opacity": 0.94
"circle-opacity": SUPPLY_LAYER_VISUALS.reservoirs.haloOpacity
}
},
{
@@ -214,11 +214,12 @@ layers.push(
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir,
"icon-size": SUPPLY_LAYER_VISUALS.reservoirs.iconSize,
"icon-padding": 6,
"icon-allow-overlap": true,
"icon-ignore-placement": true
"icon-ignore-placement": false
},
paint: {
"icon-opacity": supplyIconOpacityExpression
"icon-opacity": SUPPLY_LAYER_VISUALS.reservoirs.iconOpacity
}
},
{
@@ -230,11 +231,12 @@ layers.push(
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.pump,
"icon-size": SUPPLY_LAYER_VISUALS.pumps.iconSize,
"icon-padding": 6,
"icon-allow-overlap": true,
"icon-ignore-placement": true
"icon-ignore-placement": false
},
paint: {
"icon-opacity": supplyIconOpacityExpression
"icon-opacity": SUPPLY_LAYER_VISUALS.pumps.iconOpacity
}
},
{
@@ -246,11 +248,12 @@ layers.push(
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.tank,
"icon-size": SUPPLY_LAYER_VISUALS.tanks.iconSize,
"icon-padding": 6,
"icon-allow-overlap": true,
"icon-ignore-placement": true
"icon-ignore-placement": false
},
paint: {
"icon-opacity": supplyIconOpacityExpression
"icon-opacity": SUPPLY_LAYER_VISUALS.tanks.iconOpacity
}
}
);
@@ -66,10 +66,11 @@ describe("workbench supply layer controls", () => {
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
featureCount: 215_797,
minZoom: 10,
minZoom: 8.5,
visible: false
});
expect(items.find((item) => item.id === "fx_20200811_dn100_pipes")).toMatchObject({
minZoom: 8.5,
collection: "fx-pipe-inventory-20200811",
featureCount: 122_686,
visual: { kind: "line", dashed: true }
@@ -77,9 +78,15 @@ describe("workbench supply layer controls", () => {
expect(items.find((item) => item.id === "water_quality_monitoring_points")).toMatchObject({
group: "monitoring",
groupLabel: "监测点",
minZoom: 11,
visual: { kind: "image", imageSrc: "/map/water-quality-monitoring.svg" },
visible: true
});
expect(items.find((item) => item.id === "fx_20200423_flow_meters")).toMatchObject({ minZoom: 11 });
for (const id of ["fx_20200423_hydrants", "fx_20200423_service_connections",
"fx_20200423_customer_meters", "fx_20200423_non_residential_meters"]) {
expect(items.find((item) => item.id === id)).toMatchObject({ minZoom: 12 });
}
});
it("omits data-dependent controls when their GeoServer layers are empty", () => {
@@ -6,6 +6,7 @@ import type {
MapLegendItem
} from "@/features/map/core";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SOURCE_GIS_MIN_ZOOM, SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG,
@@ -67,13 +68,13 @@ const LAYER_CONTROL_PRESENTATION: Record<
}
},
junctions: {
description: "拓扑连接点",
minZoom: 15,
description: "拓扑连接点 · 随缩放细化",
minZoom: SUPPLY_LAYER_VISUALS.junctions.minzoom,
visual: { kind: "point", color: MAP_STYLE_TOKENS.supply.junction }
},
valves: {
description: "控制设施",
minZoom: 8.5,
description: "控制设施 · 符号自动避让",
minZoom: SUPPLY_LAYER_VISUALS.valves.minzoom,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.valve }
},
reservoirs: {
@@ -83,22 +84,22 @@ const LAYER_CONTROL_PRESENTATION: Record<
},
pumps: {
description: "加压设施",
minZoom: 11,
minZoom: SUPPLY_LAYER_VISUALS.pumps.minzoom,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.pump }
},
tanks: {
description: "调蓄设施",
minZoom: 11,
minZoom: SUPPLY_LAYER_VISUALS.tanks.minzoom,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.tank }
},
water_quality_monitoring_points: {
description: "灵敏度分析选点",
minZoom: 8,
minZoom: SUPPLY_LAYER_VISUALS.waterQuality.minzoom,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality }
},
fx_20200423_pipes: {
description: "全口径管线",
minZoom: 10,
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_pipes,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: {
@@ -112,7 +113,7 @@ const LAYER_CONTROL_PRESENTATION: Record<
},
fx_20200811_dn100_pipes: {
description: "DN100 及以上",
minZoom: 9,
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200811_dn100_pipes,
collection: "fx-pipe-inventory-20200811",
collectionLabel: "2020-08-11 · 专项管线台账",
visual: {
@@ -123,49 +124,49 @@ const LAYER_CONTROL_PRESENTATION: Record<
},
fx_20200423_valves: {
description: "源设施台账",
minZoom: 14,
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_valves,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve }
},
fx_20200423_drain_valves: {
description: "源设施台账",
minZoom: 10,
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_drain_valves,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve }
},
fx_20200423_flow_meters: {
description: "源仪表台账",
minZoom: 10,
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_flow_meters,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter }
},
fx_20200423_hydrants: {
description: "源设施台账",
minZoom: 13,
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_hydrants,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant }
},
fx_20200423_service_connections: {
description: "源设施台账",
minZoom: 13,
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_service_connections,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection }
},
fx_20200423_customer_meters: {
description: "源水表台账",
minZoom: 13,
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_customer_meters,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter }
},
fx_20200423_non_residential_meters: {
description: "源水表台账",
minZoom: 15,
minZoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_non_residential_meters,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter }
@@ -526,7 +527,7 @@ export function createLayerControlItems(
id: item.id,
label: item.label,
description: item.description,
minZoom: 8.5,
minZoom: item.minZoom,
visual: {
kind: "image" as const,
imageSrc:
+83 -54
View File
@@ -1,4 +1,5 @@
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
import { createSupplyZoomOpacity } from "./supply-style-expressions";
type LayerSpecification = StyleSpecification["layers"][number];
type LineLayerSpecification = Extract<LayerSpecification, { type: "line" }>;
@@ -9,6 +10,7 @@ type CircleRadius = NonNullable<NonNullable<CircleLayerSpecification["paint"]>["
type IconSize = NonNullable<NonNullable<SymbolLayerSpecification["layout"]>["icon-size"]>;
export const MAP_MAX_ZOOM = 24;
export const MONITORING_POINT_MIN_ZOOM = 11;
const lineHitWidth = [
"interpolate",
@@ -58,17 +60,17 @@ const supplyPipeWidth = [
["linear"],
["zoom"],
9,
pipeWidthAtZoom(0.7, 1, 1.4),
pipeWidthAtZoom(0.4, 0.7, 1.1),
11,
pipeWidthAtZoom(0.9, 1.3, 1.8),
pipeWidthAtZoom(0.65, 1.05, 1.6),
14,
pipeWidthAtZoom(1.2, 1.8, 2.6),
pipeWidthAtZoom(1.3, 2, 2.9),
17,
pipeWidthAtZoom(1.8, 2.8, 4),
pipeWidthAtZoom(2.2, 3.4, 4.8),
20,
pipeWidthAtZoom(2.6, 4.2, 6),
pipeWidthAtZoom(3.2, 5, 7),
24,
pipeWidthAtZoom(3.2, 5.2, 7)
pipeWidthAtZoom(4.2, 6.8, 9.2)
] as unknown as LineWidth;
const supplyPipeCasingWidth = [
@@ -76,39 +78,19 @@ const supplyPipeCasingWidth = [
["linear"],
["zoom"],
9,
pipeWidthAtZoom(2.1, 2.4, 2.8),
pipeWidthAtZoom(1.2, 1.5, 1.9),
11,
pipeWidthAtZoom(2.3, 2.7, 3.2),
pipeWidthAtZoom(1.65, 2.05, 2.6),
14,
pipeWidthAtZoom(2.7, 3.3, 4.1),
pipeWidthAtZoom(2.7, 3.4, 4.3),
17,
pipeWidthAtZoom(3.6, 4.6, 5.8),
pipeWidthAtZoom(4, 5.2, 6.6),
20,
pipeWidthAtZoom(4.5, 6.1, 7.9),
pipeWidthAtZoom(5.2, 7, 9),
24,
pipeWidthAtZoom(5.2, 7.2, 9)
pipeWidthAtZoom(6.6, 9.2, 11.6)
] as unknown as LineWidth;
const facilityRadius = [
"interpolate", ["linear"], ["zoom"],
8.5, 3,
10, 4.2,
12, 5.5,
16, 7,
20, 9,
24, 11
] as CircleRadius;
const facilityCasingRadius = [
"interpolate", ["linear"], ["zoom"],
8.5, 4,
10, 5.4,
12, 7,
16, 8.5,
20, 10.5,
24, 12.5
] as CircleRadius;
const facilityHoverRadius = [
"interpolate", ["linear"], ["zoom"],
8.5, 5,
@@ -131,12 +113,26 @@ const facilitySelectedRadius = [
const facilityIconSize = [
"interpolate", ["linear"], ["zoom"],
8.5, 0.18,
10, 0.23,
12, 0.29,
16, 0.36,
20, 0.46,
24, 0.56
8.5, 0.1,
12, 0.16,
16, 0.28,
20, 0.4,
24, 0.5
] as IconSize;
const importantFacilityIconOpacity = createSupplyZoomOpacity([
[8, 0.78], [12, 0.88], [16, 0.98]
]);
const importantFacilityHaloOpacity = createSupplyZoomOpacity([
[8, 0.35], [12, 0.55], [16, 0.8]
]);
const monitoringPointRadius = ["interpolate", ["linear"], ["zoom"], 8.5, 1.5, 12, 2, 16, 3, 20, 4.5, 24, 5.5] as CircleRadius;
const monitoringPointHaloRadius = ["interpolate", ["linear"], ["zoom"], 8.5, 2.5, 12, 3.5, 16, 5, 20, 7, 24, 8] as CircleRadius;
const monitoringIconStops = [[8.5, 0.18], [12, 0.26], [16, 0.4], [20, 0.52], [24, 0.64]] as const;
const monitoringIconSize = (pixelRatio: number) => [
"interpolate", ["linear"], ["zoom"],
...monitoringIconStops.flatMap(([zoom, size]) => [zoom, size * pixelRatio])
] as IconSize;
export const SUPPLY_LAYER_VISUALS = {
@@ -151,20 +147,24 @@ export const SUPPLY_LAYER_VISUALS = {
hitWidth: lineHitWidth
},
junctions: {
minzoom: 15,
haloRadius: ["interpolate", ["linear"], ["zoom"], 13, 2.8, 17, 6] as CircleRadius,
haloOpacity: ["interpolate", ["linear"], ["zoom"], 15, 0, 17, 0, 19, 0.55, 24, 0.82] as const,
radius: ["interpolate", ["linear"], ["zoom"], 13, 1.5, 17, 3.8] as CircleRadius,
minzoom: 12,
haloRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 0.6, 13, 1.4, 17, 3.5, 24, 6] as CircleRadius,
haloOpacity: ["interpolate", ["linear"], ["zoom"], 8.5, 0, 15, 0.08, 19, 0.45, 24, 0.7] as const,
radius: ["interpolate", ["linear"], ["zoom"], 8.5, 0.15, 12, 0.4, 15, 1, 18, 2.6, 24, 3.8] as CircleRadius,
opacity: createSupplyZoomOpacity([[8.5, 0.08], [12, 0.18], [15, 0.45], [18, 0.82]]),
hoverRadius: ["interpolate", ["linear"], ["zoom"], 13, 5, 17, 10] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 13, 5.5, 17, 10.5] as CircleRadius,
hitRadius: pointHitRadius
hitRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 0.5, 12, 1, 15, 3, 18, 7, 24, 10] as CircleRadius
},
valves: {
minzoom: 8.5,
minzoom: 13,
casingWidth: ["interpolate", ["linear"], ["zoom"], 11, 3.2, 17, 7.5, 22, 9, 24, 10] as LineWidth,
width: ["interpolate", ["linear"], ["zoom"], 11, 1.6, 17, 4.4, 22, 6, 24, 7] as LineWidth,
haloRadius: facilityCasingRadius,
radius: facilityRadius,
haloRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 1, 12, 1.6, 16, 3, 20, 4.5, 24, 5.5] as CircleRadius,
haloOpacity: createSupplyZoomOpacity([[8.5, 0.12], [12, 0.25], [16, 0.5], [20, 0.7]]),
radius: ["interpolate", ["linear"], ["zoom"], 8.5, 0.6, 12, 1, 16, 2, 20, 3.2, 24, 4] as CircleRadius,
opacity: createSupplyZoomOpacity([[8.5, 0.18], [12, 0.32], [16, 0.6], [20, 0.8]]),
iconOpacity: createSupplyZoomOpacity([[8.5, 0.3], [12, 0.5], [16, 0.9], [20, 0.98]]),
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 6, 17, 10.5, 22, 12.5, 24, 14] as LineWidth,
hoverWidth: ["interpolate", ["linear"], ["zoom"], 11, 4, 17, 7, 22, 9, 24, 10.5] as LineWidth,
hoverRadius: facilityHoverRadius,
@@ -177,6 +177,8 @@ export const SUPPLY_LAYER_VISUALS = {
},
reservoirs: {
minzoom: 8.5,
iconOpacity: importantFacilityIconOpacity,
haloOpacity: importantFacilityHaloOpacity,
haloRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 7, 10, 8.5, 12, 9.5, 16, 11.5, 20, 13.5, 24, 15.5] as CircleRadius,
radius: ["interpolate", ["linear"], ["zoom"], 8.5, 5.5, 10, 7, 12, 8, 16, 10, 20, 12, 24, 14] as CircleRadius,
hoverRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 8, 10, 9.5, 12, 10.5, 16, 12.5, 20, 14.5, 24, 16.5] as CircleRadius,
@@ -185,26 +187,53 @@ export const SUPPLY_LAYER_VISUALS = {
iconSize: ["interpolate", ["linear"], ["zoom"], 8.5, 0.28, 10, 0.36, 12, 0.42, 16, 0.52, 20, 0.64, 24, 0.76] as IconSize
},
scada: {
minzoom: 8.5,
haloRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 5.5, 10, 7, 12, 8.5, 16, 10, 20, 12, 24, 14] as CircleRadius,
radius: ["interpolate", ["linear"], ["zoom"], 8.5, 4, 10, 5.5, 12, 7, 16, 8.5, 20, 10.5, 24, 12.5] as CircleRadius,
minzoom: MONITORING_POINT_MIN_ZOOM,
haloRadius: monitoringPointHaloRadius,
radius: monitoringPointRadius,
haloOpacity: createSupplyZoomOpacity([[8.5, 0.45], [12, 0.55], [16, 0.7]]),
opacity: createSupplyZoomOpacity([[8.5, 0.55], [12, 0.65], [16, 0.8]]),
iconOpacity: createSupplyZoomOpacity([[8.5, 0.75], [12, 0.85], [16, 0.98]]),
hoverRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 6.5, 10, 8, 12, 9.5, 16, 11, 20, 13, 24, 15] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 7, 10, 8.5, 12, 10, 16, 11.5, 20, 13.5, 24, 15.5] as CircleRadius,
hitRadius: pointHitRadius,
iconSize: ["interpolate", ["linear"], ["zoom"], 8.5, 0.25, 10, 0.32, 12, 0.4, 16, 0.48, 20, 0.6, 24, 0.72] as IconSize
iconSize: monitoringIconSize(1)
},
pumps: {
minzoom: 11,
minzoom: 8.5,
iconOpacity: importantFacilityIconOpacity,
hoverRadius: 14,
selectedRadius: 12.5,
hitRadius: 16,
iconSize: ["interpolate", ["linear"], ["zoom"], 11, 0.44, 16, 0.66, 20, 0.82, 24, 0.96] as const
iconSize: ["interpolate", ["linear"], ["zoom"], 8.5, 0.3, 12, 0.44, 16, 0.66, 20, 0.82, 24, 0.96] as const
},
tanks: {
minzoom: 11,
minzoom: 8.5,
iconOpacity: importantFacilityIconOpacity,
hoverRadius: 14,
selectedRadius: 12.5,
hitRadius: 16,
iconSize: ["interpolate", ["linear"], ["zoom"], 11, 0.44, 16, 0.66, 20, 0.82, 24, 0.96] as const
iconSize: ["interpolate", ["linear"], ["zoom"], 8.5, 0.3, 12, 0.44, 16, 0.66, 20, 0.82, 24, 0.96] as const
},
waterQuality: {
minzoom: MONITORING_POINT_MIN_ZOOM,
iconOpacity: importantFacilityIconOpacity,
haloOpacity: importantFacilityHaloOpacity,
opacity: createSupplyZoomOpacity([[8, 0.55], [12, 0.7], [16, 0.9]]),
radius: monitoringPointRadius,
haloRadius: monitoringPointHaloRadius,
// Water-quality artwork is 128px; SCADA artwork is 96px, both registered at 2x.
iconSize: monitoringIconSize(96 / 128)
}
} as const;
export const SOURCE_GIS_MIN_ZOOM = {
fx_20200423_pipes: SUPPLY_LAYER_VISUALS.pipes.minzoom,
fx_20200811_dn100_pipes: SUPPLY_LAYER_VISUALS.pipes.minzoom,
fx_20200423_valves: SUPPLY_LAYER_VISUALS.valves.minzoom,
fx_20200423_drain_valves: SUPPLY_LAYER_VISUALS.valves.minzoom,
fx_20200423_flow_meters: MONITORING_POINT_MIN_ZOOM,
fx_20200423_hydrants: SUPPLY_LAYER_VISUALS.junctions.minzoom,
fx_20200423_service_connections: SUPPLY_LAYER_VISUALS.junctions.minzoom,
fx_20200423_customer_meters: SUPPLY_LAYER_VISUALS.junctions.minzoom,
fx_20200423_non_residential_meters: SUPPLY_LAYER_VISUALS.junctions.minzoom
} as const;
@@ -1,6 +1,6 @@
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import { SOURCE_GIS_MIN_ZOOM, SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
SOURCE_LAYERS,
type GeoServerWaterNetworkSourceId
@@ -24,19 +24,19 @@ const definitions: PublishedLayerDefinition[] = [
sourceId: "water_quality_monitoring_points",
geometry: "point",
color: MAP_STYLE_TOKENS.supply.waterQuality,
minzoom: 8,
minzoom: SUPPLY_LAYER_VISUALS.waterQuality.minzoom,
radius: 5.5,
imageId: SUPPLY_ASSET_IMAGE_IDS.waterQuality
},
{ sourceId: "fx_20200423_pipes", geometry: "line", color: MAP_STYLE_TOKENS.supply.sourcePipe, minzoom: 10, lineVariant: "network" },
{ sourceId: "fx_20200811_dn100_pipes", geometry: "line", color: MAP_STYLE_TOKENS.supply.sourcePipeInventory, minzoom: 9, lineVariant: "inventory" },
{ sourceId: "fx_20200423_valves", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceValve, minzoom: 14, radius: 3.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceValve },
{ sourceId: "fx_20200423_drain_valves", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceDrainValve, minzoom: 10, radius: 3.6, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve },
{ sourceId: "fx_20200423_flow_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceFlowMeter, minzoom: 10, radius: 4.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter },
{ sourceId: "fx_20200423_hydrants", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceHydrant, minzoom: 13, radius: 3.5, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceHydrant },
{ sourceId: "fx_20200423_service_connections", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceConnection, minzoom: 13, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceConnection },
{ sourceId: "fx_20200423_customer_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter, minzoom: 13, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter },
{ sourceId: "fx_20200423_non_residential_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter, minzoom: 15, radius: 2.8, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter }
{ sourceId: "fx_20200423_pipes", geometry: "line", color: MAP_STYLE_TOKENS.supply.sourcePipe, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_pipes, lineVariant: "network" },
{ sourceId: "fx_20200811_dn100_pipes", geometry: "line", color: MAP_STYLE_TOKENS.supply.sourcePipeInventory, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200811_dn100_pipes, lineVariant: "inventory" },
{ sourceId: "fx_20200423_valves", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceValve, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_valves, radius: 3.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceValve },
{ sourceId: "fx_20200423_drain_valves", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceDrainValve, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_drain_valves, radius: 3.6, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve },
{ sourceId: "fx_20200423_flow_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceFlowMeter, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_flow_meters, radius: 4.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter },
{ sourceId: "fx_20200423_hydrants", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceHydrant, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_hydrants, radius: 3.5, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceHydrant },
{ sourceId: "fx_20200423_service_connections", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceConnection, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_service_connections, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceConnection },
{ sourceId: "fx_20200423_customer_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_customer_meters, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter },
{ sourceId: "fx_20200423_non_residential_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter, minzoom: SOURCE_GIS_MIN_ZOOM.fx_20200423_non_residential_meters, radius: 2.8, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter }
];
const sourcePipeColorExpression: ExpressionSpecification = [
@@ -102,9 +102,10 @@ function createBusinessLayers(definition: PublishedLayerDefinition): Layer[] {
}
const radius = definition.radius ?? 3.2;
const iconSize = definition.sourceId === "water_quality_monitoring_points"
? ["interpolate", ["linear"], ["zoom"], definition.minzoom, 0.25, 18, 0.42] as ExpressionSpecification
: ["interpolate", ["linear"], ["zoom"], definition.minzoom, 0.36, 18, 0.54] as ExpressionSpecification;
const important = definition.sourceId === "water_quality_monitoring_points";
const iconSize = important
? SUPPLY_LAYER_VISUALS.waterQuality.iconSize
: SUPPLY_LAYER_VISUALS.valves.iconSize;
const layers: Layer[] = [
{
id: `${definition.sourceId}-halo`,
@@ -114,8 +115,9 @@ function createBusinessLayers(definition: PublishedLayerDefinition): Layer[] {
minzoom: definition.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": radius + 1.8,
"circle-opacity": 0.9
"circle-radius": important ? SUPPLY_LAYER_VISUALS.waterQuality.haloRadius
: ["interpolate", ["linear"], ["zoom"], 8.5, radius * 0.3, 12, radius * 0.5, 18, radius + 1],
"circle-opacity": important ? SUPPLY_LAYER_VISUALS.waterQuality.haloOpacity : SUPPLY_LAYER_VISUALS.valves.haloOpacity
}
},
{
@@ -126,8 +128,10 @@ function createBusinessLayers(definition: PublishedLayerDefinition): Layer[] {
minzoom: definition.minzoom,
paint: {
"circle-color": definition.color,
"circle-radius": ["interpolate", ["linear"], ["zoom"], definition.minzoom, radius, 18, radius * 1.35],
"circle-opacity": 0.9,
"circle-radius": important
? SUPPLY_LAYER_VISUALS.waterQuality.radius
: ["interpolate", ["linear"], ["zoom"], 8.5, radius * 0.18, 12, radius * 0.3, 18, radius],
"circle-opacity": important ? SUPPLY_LAYER_VISUALS.waterQuality.opacity : SUPPLY_LAYER_VISUALS.valves.opacity,
"circle-stroke-color": definition.sourceId === "water_quality_monitoring_points"
? MAP_STYLE_TOKENS.canvas.casing
: definition.color,
@@ -145,11 +149,12 @@ function createBusinessLayers(definition: PublishedLayerDefinition): Layer[] {
layout: {
"icon-image": definition.imageId,
"icon-size": iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
"icon-padding": important ? 6 : 5,
"icon-allow-overlap": important,
"icon-ignore-placement": false
},
paint: {
"icon-opacity": 0.96
"icon-opacity": important ? SUPPLY_LAYER_VISUALS.waterQuality.iconOpacity : SUPPLY_LAYER_VISUALS.valves.iconOpacity
}
});
}
+16 -14
View File
@@ -3,7 +3,6 @@ import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
createSupplyStateColor,
supplyFacilityOpacityExpression,
supplyIconOpacityExpression
} from "./supply-style-expressions";
@@ -34,6 +33,7 @@ export const SCADA_LAYER_CONTROL_ITEMS = [
label: "压力监测点",
description: "83 个压力监测点",
deviceType: "pressure",
minZoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
imageId: SCADA_IMAGE_IDS.pressure,
color: MAP_STYLE_TOKENS.supply.scadaPressure
},
@@ -42,6 +42,7 @@ export const SCADA_LAYER_CONTROL_ITEMS = [
label: "流量监测点",
description: "35 个流量监测点",
deviceType: "pipe_flow",
minZoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
imageId: SCADA_IMAGE_IDS.flow,
color: MAP_STYLE_TOKENS.supply.scadaFlow
}
@@ -80,11 +81,11 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
minzoom: definition.minZoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-opacity": 0.94
"circle-opacity": SUPPLY_LAYER_VISUALS.scada.haloOpacity
}
},
{
@@ -92,11 +93,11 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
minzoom: definition.minZoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
"circle-color": color,
"circle-opacity": supplyFacilityOpacityExpression
"circle-opacity": SUPPLY_LAYER_VISUALS.scada.opacity
}
},
{
@@ -104,14 +105,15 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "symbol",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
minzoom: definition.minZoom,
layout: {
"icon-image": definition.imageId,
"icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
"icon-padding": 7,
"icon-allow-overlap": false,
"icon-ignore-placement": false
},
paint: { "icon-opacity": supplyIconOpacityExpression }
paint: { "icon-opacity": SUPPLY_LAYER_VISUALS.scada.iconOpacity }
}
];
const fallback: Layer[] = [
@@ -121,7 +123,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
minzoom: definition.minZoom,
paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2],
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
@@ -135,7 +137,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
minzoom: definition.minZoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
@@ -150,7 +152,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
minzoom: definition.minZoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
@@ -165,7 +167,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
minzoom: definition.minZoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
@@ -181,7 +183,7 @@ function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
minzoom: definition.minZoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
@@ -1,12 +1,7 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { simulationLayerIds } from "./annotation-layers";
import { setMapLayersVisibility } from "./layer-visibility";
export function setSimulationLayersVisibility(map: MapLibreMap, visible: boolean) {
const visibility = visible ? "visible" : "none";
simulationLayerIds.forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", visibility);
}
});
export function setSimulationLayersVisibility(map: MapLibreMap, visible: boolean, animate = true) {
setMapLayersVisibility(map, simulationLayerIds, visible, animate);
}
@@ -1,5 +1,8 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import { SCADA_ICON_PATHS } from "./scada";
import {
SUPPLY_ASSET_ICON_PATHS,
@@ -9,6 +12,19 @@ import {
} from "./supply-icons";
describe("supply facility map icons", () => {
it("matches water-quality and pressure icon pixel widths at every zoom stop", () => {
const imageWidth = (path: string) => readFileSync(`public${path}`).readUInt32BE(16);
const pressureWidth = imageWidth(SCADA_ICON_PATHS.pressure);
const waterQualityWidth = imageWidth(SUPPLY_ASSET_MAP_ICON_PATHS.waterQuality);
const pressure = SUPPLY_LAYER_VISUALS.scada.iconSize as unknown as unknown[];
const waterQuality = SUPPLY_LAYER_VISUALS.waterQuality.iconSize as unknown as unknown[];
for (let index = 3; index < pressure.length; index += 2) {
expect(waterQuality[index]).toBe(pressure[index]);
expect(Number(waterQuality[index + 1]) * waterQualityWidth)
.toBeCloseTo(Number(pressure[index + 1]) * pressureWidth);
}
});
it("uses MapLibre-compatible PNG assets from the public map directory", () => {
expect(Object.keys(SUPPLY_ASSET_ICON_PATHS)).toEqual([
"valve",
@@ -54,6 +54,17 @@ export const supplyIconOpacityExpression: ExpressionSpecification = [
0.98
];
export function createSupplyZoomOpacity(
stops: readonly (readonly [number, number])[]
): ExpressionSpecification {
return [
"interpolate", ["linear"], ["zoom"],
...stops.flatMap(([zoom, opacity]) => [
zoom, ["case", supplyInactiveExpression, opacity * 0.65, opacity]
])
] as ExpressionSpecification;
}
export function createSupplyStateColor(
activeColor: string | ExpressionSpecification
): ExpressionSpecification {
+9 -4
View File
@@ -4,6 +4,7 @@ import type {
SymbolLayerSpecification
} from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import { SOURCE_LAYERS } from "./sources";
export const VALUE_LABEL_LAYER_IDS = {
@@ -36,12 +37,12 @@ export function createValueLabelLayers(): SymbolLayerSpecification[] {
type: "symbol",
source: sourceId,
"source-layer": SOURCE_LAYERS[sourceId],
minzoom: sourceId === "junctions" ? 15 : 10,
minzoom: Math.max(10, SUPPLY_LAYER_VISUALS[sourceId].minzoom),
layout: {
visibility: "none",
"symbol-placement": line ? "line-center" : "point",
"text-field": "",
"text-size": 12,
"text-size": createValueLabelTextSize(12),
"text-offset": line ? [0, 0] : [0, -1.35],
"text-anchor": line ? "center" : "bottom",
"text-padding": 5,
@@ -54,7 +55,7 @@ export function createValueLabelLayers(): SymbolLayerSpecification[] {
"text-halo-color": MAP_STYLE_TOKENS.label.inverse,
"text-halo-width": 2,
"text-halo-blur": 0.4,
"text-opacity": 0.96
"text-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0.3, 14, 0.8, 17, 0.96]
}
};
});
@@ -80,7 +81,7 @@ export function applyValueLabelStyle(
"text-field",
createValueLabelTextExpression(style)
);
map.setLayoutProperty(layerId, "text-size", style.textSize);
map.setLayoutProperty(layerId, "text-size", createValueLabelTextSize(style.textSize));
map.setPaintProperty(layerId, "text-color", style.textColor);
map.setLayoutProperty(layerId, "visibility", "visible");
}
@@ -98,6 +99,10 @@ export function clearValueLabels(
});
}
function createValueLabelTextSize(textSize: number): ExpressionSpecification {
return ["interpolate", ["linear"], ["zoom"], 10, Math.max(10, textSize * 0.8), 16, textSize];
}
function createValueLabelTextExpression(
style: ValueLabelStyle
): ExpressionSpecification {