feat: initialize drainage network frontend

This commit is contained in:
2026-07-10 16:16:17 +08:00
commit 66de96d9e4
133 changed files with 33057 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl";
import { annotationPoints, impactAreaPolygon } from "../data/workbench-simulation";
export const SIMULATION_SOURCE_IDS = {
impactArea: "simulation-impact-area",
annotations: "simulation-annotations"
} as const;
export const simulationSources = {
impactArea: {
type: "geojson",
data: impactAreaPolygon
} satisfies GeoJSONSourceSpecification,
annotations: {
type: "geojson",
data: annotationPoints
} satisfies GeoJSONSourceSpecification
};
export const simulationAnnotationLayers: StyleSpecification["layers"] = [
{
id: "simulation-impact-fill",
type: "fill",
source: SIMULATION_SOURCE_IDS.impactArea,
paint: {
"fill-color": "#ef4444",
"fill-opacity": 0.18
}
},
{
id: "simulation-impact-outline",
type: "line",
source: SIMULATION_SOURCE_IDS.impactArea,
paint: {
"line-color": "#ef4444",
"line-width": 1.5,
"line-dasharray": [2, 2],
"line-opacity": 0.72
}
},
{
id: "simulation-burst-halo",
type: "circle",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
paint: {
"circle-color": "#fef2f2",
"circle-radius": 18,
"circle-stroke-color": "#ef4444",
"circle-stroke-width": 3,
"circle-opacity": 0.88
}
},
{
id: "simulation-burst-point",
type: "circle",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
paint: {
"circle-color": "#ef4444",
"circle-radius": 7,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 3
}
},
{
id: "simulation-pipe-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "pipe-label"],
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Open Sans Bold"],
"text-allow-overlap": true
},
paint: {
"text-color": "#ffffff",
"text-halo-color": "#2563eb",
"text-halo-width": 9
}
},
{
id: "simulation-impact-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "tooltip"],
layout: {
"text-field": ["get", "label"],
"text-size": 14,
"text-font": ["Open Sans Bold"],
"text-offset": [0, 0],
"text-allow-overlap": true
},
paint: {
"text-color": "#dc2626",
"text-halo-color": "#ffffff",
"text-halo-width": 4
}
},
{
id: "simulation-district-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "district"],
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Open Sans Regular"],
"text-allow-overlap": true
},
paint: {
"text-color": "#334155",
"text-halo-color": "#ffffff",
"text-halo-width": 3
}
},
{
id: "simulation-burst-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
layout: {
"text-field": "爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
"text-size": 12,
"text-font": ["Open Sans Regular"],
"text-offset": [5.2, -3.4],
"text-anchor": "left",
"text-allow-overlap": true
},
paint: {
"text-color": "#0f172a",
"text-halo-color": "#ffffff",
"text-halo-width": 5
}
}
];
export const simulationLayerIds = simulationAnnotationLayers.map((layer) => layer.id);
+35
View File
@@ -0,0 +1,35 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import { fitNetworkBounds } from "./camera";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
describe("fitNetworkBounds", () => {
it("fits the global WebMercator bbox through MapLibre lng/lat bounds", () => {
const fitBounds = vi.fn();
const map = {
fitBounds,
getCanvas: () => ({
clientWidth: 1280,
clientHeight: 720,
width: 1280,
height: 720
})
} as unknown as MapLibreMap;
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
expect(fitBounds).toHaveBeenCalledTimes(1);
const [bounds, options] = fitBounds.mock.calls[0] ?? [];
expect(bounds[0][0]).toBeCloseTo(121.351633, 6);
expect(bounds[0][1]).toBeCloseTo(30.810505, 6);
expect(bounds[1][0]).toBeCloseTo(121.772485, 6);
expect(bounds[1][1]).toBeCloseTo(31.007214, 6);
expect(options).toMatchObject({
duration: 600,
padding: { top: 0, right: 0, bottom: 0, left: 0 }
});
expect(options).not.toHaveProperty("zoom");
expect(options).not.toHaveProperty("maxZoom");
});
});
+80
View File
@@ -0,0 +1,80 @@
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
type LngLatBounds = [[number, number], [number, number]];
type WebMercatorBbox = readonly [number, number, number, number];
export type NetworkViewParams = {
bbox3857: WebMercatorBbox;
};
const WEB_MERCATOR_RADIUS = 6378137;
const NO_PADDING: PaddingOptions = { top: 0, right: 0, bottom: 0, left: 0 };
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
return {
top: 88,
left: leftPanelOpen ? 530 : 88,
right: rightPanelOpen ? 430 : 120,
bottom: 96
};
}
export function getResponsiveWorkbenchPadding(
map: MapLibreMap,
leftPanelOpen: boolean,
rightPanelOpen: boolean
): PaddingOptions {
return getResponsivePadding(map, getWorkbenchPadding(leftPanelOpen, rightPanelOpen));
}
export function fitNetworkBounds(
map: MapLibreMap,
viewParams: NetworkViewParams = WATER_NETWORK_GLOBAL_VIEW,
padding: PaddingOptions = NO_PADDING
) {
map.fitBounds(
getLngLatBoundsFromBbox3857(viewParams.bbox3857),
{ padding: getResponsivePadding(map, padding), duration: 600 }
);
}
function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
const canvas = map.getCanvas();
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
const horizontalPadding = padding.left + padding.right;
const verticalPadding = padding.top + padding.bottom;
const maxHorizontalPadding = width * 0.58;
const maxVerticalPadding = height * 0.58;
const horizontalScale = horizontalPadding > 0 ? maxHorizontalPadding / horizontalPadding : 1;
const verticalScale = verticalPadding > 0 ? maxVerticalPadding / verticalPadding : 1;
const scale = Math.min(1, horizontalScale, verticalScale);
if (scale >= 1) {
return padding;
}
return {
top: Math.round(padding.top * scale),
right: Math.round(padding.right * scale),
bottom: Math.round(padding.bottom * scale),
left: Math.round(padding.left * scale)
};
}
function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
const [minX, minY, maxX, maxY] = bbox3857;
return [
webMercatorToLngLat(minX, minY),
webMercatorToLngLat(maxX, maxY)
];
}
function webMercatorToLngLat(x: number, y: number): [number, number] {
const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
return [lng, lat];
}
+291
View File
@@ -0,0 +1,291 @@
import type {
FilterSpecification,
GeoJSONSource,
GeoJSONSourceSpecification,
StyleSpecification
} from "maplibre-gl";
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
import { createCircleCoordinates } from "./geo";
export const DRAWING_SOURCE_IDS = {
features: "workbench-drawing-features",
draft: "workbench-drawing-draft"
} as const;
export const DRAWING_INTERACTIVE_LAYER_IDS = [
"workbench-drawing-selected-polygon-outline",
"workbench-drawing-selected-line",
"workbench-drawing-selected-point",
"workbench-drawing-polygon-fill",
"workbench-drawing-polygon-outline",
"workbench-drawing-line",
"workbench-drawing-point"
];
export type DrawingFeatureProperties = {
id: string;
label: string;
kind: "point" | "line" | "polygon" | "circle";
createdAt: string;
};
export type DrawingFeatureCollection = FeatureCollection<Geometry, DrawingFeatureProperties>;
export const emptyDrawingFeatureCollection: DrawingFeatureCollection = {
type: "FeatureCollection",
features: []
};
export const drawingSources = {
features: {
type: "geojson",
data: emptyDrawingFeatureCollection
} satisfies GeoJSONSourceSpecification,
draft: {
type: "geojson",
data: emptyDrawingFeatureCollection
} satisfies GeoJSONSourceSpecification
};
export const drawingLayers: StyleSpecification["layers"] = [
{
id: "workbench-drawing-polygon-fill",
type: "fill",
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"fill-color": "#2563eb",
"fill-opacity": 0.16
}
},
{
id: "workbench-drawing-polygon-outline",
type: "line",
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"line-color": "#2563eb",
"line-width": 2,
"line-opacity": 0.82
}
},
{
id: "workbench-drawing-line",
type: "line",
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "LineString"],
paint: {
"line-color": "#2563eb",
"line-width": 3,
"line-opacity": 0.88
}
},
{
id: "workbench-drawing-point",
type: "circle",
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-color": "#2563eb",
"circle-radius": 6,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 2
}
},
{
id: "workbench-drawing-selected-polygon-outline",
type: "line",
source: DRAWING_SOURCE_IDS.features,
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", ["get", "id"], ""]],
paint: {
"line-color": "#ff7a45",
"line-width": 4,
"line-opacity": 0.86
}
},
{
id: "workbench-drawing-selected-line",
type: "line",
source: DRAWING_SOURCE_IDS.features,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "id"], ""]],
paint: {
"line-color": "#ff7a45",
"line-width": 6,
"line-opacity": 0.72
}
},
{
id: "workbench-drawing-selected-point",
type: "circle",
source: DRAWING_SOURCE_IDS.features,
filter: ["all", ["==", ["geometry-type"], "Point"], ["==", ["get", "id"], ""]],
paint: {
"circle-color": "#ff7a45",
"circle-radius": 10,
"circle-opacity": 0.2,
"circle-stroke-color": "#ff7a45",
"circle-stroke-width": 2
}
},
{
id: "workbench-drawing-draft-polygon-fill",
type: "fill",
source: DRAWING_SOURCE_IDS.draft,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"fill-color": "#ff7a45",
"fill-opacity": 0.12
}
},
{
id: "workbench-drawing-draft-line",
type: "line",
source: DRAWING_SOURCE_IDS.draft,
filter: ["any", ["==", ["geometry-type"], "LineString"], ["==", ["geometry-type"], "Polygon"]],
paint: {
"line-color": "#ff7a45",
"line-width": 2,
"line-dasharray": [2, 2],
"line-opacity": 0.9
}
},
{
id: "workbench-drawing-draft-vertex",
type: "circle",
source: DRAWING_SOURCE_IDS.draft,
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-color": "#ff7a45",
"circle-radius": 4,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1.5
}
}
];
export function ensureDrawingLayers(map: {
getSource: (id: string) => unknown;
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
getLayer: (id: string) => unknown;
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number]) => void;
}) {
if (!map.getSource(DRAWING_SOURCE_IDS.features)) {
map.addSource(DRAWING_SOURCE_IDS.features, drawingSources.features);
}
if (!map.getSource(DRAWING_SOURCE_IDS.draft)) {
map.addSource(DRAWING_SOURCE_IDS.draft, drawingSources.draft);
}
drawingLayers.forEach((layer) => {
if (!map.getLayer(layer.id)) {
map.addLayer(layer);
}
});
}
export function setDrawingSourceData(
map: { getSource: (id: string) => unknown },
sourceId: string,
data: DrawingFeatureCollection
) {
const source = map.getSource(sourceId) as GeoJSONSource | undefined;
source?.setData(data);
}
export function setSelectedDrawingFeatureId(
map: { getLayer: (id: string) => unknown; setFilter: (id: string, filter?: FilterSpecification) => void },
featureId: string | null
) {
const id = featureId ?? "";
[
["workbench-drawing-selected-polygon-outline", "Polygon"],
["workbench-drawing-selected-line", "LineString"],
["workbench-drawing-selected-point", "Point"]
].forEach(([layerId, geometryType]) => {
if (map.getLayer(layerId)) {
map.setFilter(layerId, ["all", ["==", ["geometry-type"], geometryType], ["==", ["get", "id"], id]]);
}
});
}
export function createPointFeature(
id: string,
coordinates: [number, number],
label: string
): DrawingFeatureCollection["features"][number] {
return {
type: "Feature",
id,
properties: createDrawingProperties(id, label, "point"),
geometry: {
type: "Point",
coordinates
} satisfies Point
};
}
export function createLineFeature(
id: string,
coordinates: [number, number][],
label: string
): DrawingFeatureCollection["features"][number] {
return {
type: "Feature",
id,
properties: createDrawingProperties(id, label, "line"),
geometry: {
type: "LineString",
coordinates
} satisfies LineString
};
}
export function createPolygonFeature(
id: string,
coordinates: [number, number][],
label: string
): DrawingFeatureCollection["features"][number] {
return {
type: "Feature",
id,
properties: createDrawingProperties(id, label, "polygon"),
geometry: {
type: "Polygon",
coordinates: [[...coordinates, coordinates[0]]]
} satisfies Polygon
};
}
export function createCircleFeature(
id: string,
center: [number, number],
radiusMeters: number,
label: string
): DrawingFeatureCollection["features"][number] {
const coordinates = createCircleCoordinates(center, radiusMeters);
return {
type: "Feature",
id,
properties: createDrawingProperties(id, label, "circle"),
geometry: {
type: "Polygon",
coordinates: [coordinates]
} satisfies Polygon
};
}
function createDrawingProperties(
id: string,
label: string,
kind: DrawingFeatureProperties["kind"]
): DrawingFeatureProperties {
return {
id,
label,
kind,
createdAt: new Date().toISOString()
};
}
+89
View File
@@ -0,0 +1,89 @@
import {
createCircleFeature,
createLineFeature,
createPolygonFeature,
type DrawingFeatureCollection
} from "./drawing-layers";
import { getDistanceMeters, type LngLatTuple } from "./geo";
export type WorkbenchDrawMode = "point" | "line" | "polygon" | "circle";
export type DrawingDraftState =
| {
mode: "line" | "polygon";
coordinates: LngLatTuple[];
}
| {
mode: "circle";
center: LngLatTuple;
edge?: LngLatTuple;
};
export function createDrawingId(mode: WorkbenchDrawMode, index: number) {
return `${mode}-${index.toString().padStart(3, "0")}`;
}
export function createDrawingLabel(mode: WorkbenchDrawMode, index: number) {
return `${getDrawingKindLabel(mode)} ${index.toString().padStart(2, "0")}`;
}
export function createFeatureFromDraft(
id: string,
draft: DrawingDraftState,
label: string
): DrawingFeatureCollection["features"][number] | null {
if (draft.mode === "line") {
return createLineFeature(id, draft.coordinates, label);
}
if (draft.mode === "polygon") {
return createPolygonFeature(id, draft.coordinates, label);
}
if (draft.mode !== "circle" || !draft.edge) {
return null;
}
return createCircleFeature(id, draft.center, getDistanceMeters(draft.center, draft.edge), label);
}
export function getDraftPointCount(draft: DrawingDraftState) {
if (draft.mode === "circle") {
return draft.edge ? 2 : 1;
}
return draft.coordinates.length;
}
export function getDrawingKindLabel(kind: WorkbenchDrawMode) {
if (kind === "point") {
return "点标注";
}
if (kind === "line") {
return "线标注";
}
if (kind === "circle") {
return "圆形标注";
}
return "范围标注";
}
export function getVisibleFeatureCollection(
featureCollection: DrawingFeatureCollection,
hiddenFeatureIds: Set<string>
): DrawingFeatureCollection {
return {
type: "FeatureCollection",
features: featureCollection.features.filter((feature) => !hiddenFeatureIds.has(feature.properties.id))
};
}
export function formatDrawingTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
+115
View File
@@ -0,0 +1,115 @@
import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl";
const MAX_EXPORT_DIMENSION = 4096;
const EXPORT_IDLE_TIMEOUT_MS = 6000;
export type ExportMapViewOptions = {
scale?: number;
targetLongEdge?: number;
filename?: string;
};
export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEdge, filename }: ExportMapViewOptions = {}) {
const canvas = map.getCanvas();
const sourceWidth = canvas.clientWidth || canvas.width;
const sourceHeight = canvas.clientHeight || canvas.height;
if (!sourceWidth || !sourceHeight) {
throw new Error("地图画布尺寸不可用。");
}
const requestedScale = targetLongEdge ? targetLongEdge / Math.max(sourceWidth, sourceHeight) : scale ?? 1;
const exportScale = Math.max(1, Math.min(requestedScale, MAX_EXPORT_DIMENSION / sourceWidth, MAX_EXPORT_DIMENSION / sourceHeight));
const exportWidth = Math.round(sourceWidth * exportScale);
const exportHeight = Math.round(sourceHeight * exportScale);
const container = createExportContainer(exportWidth, exportHeight);
const style = cloneMapStyle(map.getStyle());
const bounds = map.getBounds();
document.body.appendChild(container);
const exportMap = new maplibregl.Map({
container,
style,
center: map.getCenter(),
zoom: map.getZoom(),
pitch: map.getPitch(),
bearing: map.getBearing(),
preserveDrawingBuffer: true,
interactive: false,
attributionControl: false,
fadeDuration: 0
});
try {
await onceMapEvent(exportMap, "load");
exportMap.fitBounds(bounds, {
padding: 0,
duration: 0
});
await waitForMapIdle(exportMap);
const link = document.createElement("a");
link.download = filename ?? getDefaultExportFilename(getExportLabel(targetLongEdge, exportScale));
link.href = exportMap.getCanvas().toDataURL("image/png");
link.click();
return {
width: exportWidth,
height: exportHeight,
scale: exportScale
};
} finally {
exportMap.remove();
container.remove();
}
}
function createExportContainer(width: number, height: number) {
const container = document.createElement("div");
container.style.position = "fixed";
container.style.left = "-10000px";
container.style.top = "0";
container.style.width = `${width}px`;
container.style.height = `${height}px`;
container.style.pointerEvents = "none";
container.style.opacity = "0";
return container;
}
function cloneMapStyle(style: StyleSpecification) {
if (typeof structuredClone === "function") {
return structuredClone(style);
}
return JSON.parse(JSON.stringify(style)) as StyleSpecification;
}
function onceMapEvent(map: MapLibreMap, eventName: "load" | "idle") {
return new Promise<void>((resolve, reject) => {
map.once(eventName, () => resolve());
map.once("error", (event) => reject(event.error ?? new Error("地图导出渲染失败。")));
});
}
function waitForMapIdle(map: MapLibreMap) {
return Promise.race([
onceMapEvent(map, "idle"),
new Promise<void>((resolve) => {
window.setTimeout(resolve, EXPORT_IDLE_TIMEOUT_MS);
})
]);
}
function getExportLabel(targetLongEdge: number | undefined, scale: number) {
if (targetLongEdge === 3840) {
return "4k";
}
return `${scale.toFixed(1)}x`;
}
function getDefaultExportFilename(label: string) {
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
return `drainage-network-view-${label}-${timestamp}.png`;
}
+27
View File
@@ -0,0 +1,27 @@
import type { MapGeoJSONFeature } from "maplibre-gl";
import type { DetailFeature } from "../types";
import { formatValue } from "../utils/format-value";
import { SOURCE_LAYERS } from "./sources";
export function getFeatureId(feature: MapGeoJSONFeature) {
const raw = feature.properties?.id ?? feature.id;
return raw === undefined || raw === null ? "" : String(raw);
}
export function toDetailFeature(feature: MapGeoJSONFeature): DetailFeature {
const isPipe = feature.sourceLayer === SOURCE_LAYERS.pipes;
const id = getFeatureId(feature);
const diameter = feature.properties?.diameter;
const length = feature.properties?.length;
const demand = feature.properties?.demand;
return {
id,
layer: isPipe ? "pipes" : "junctions",
title: isPipe ? `管线 ${id || "未命名"}` : `节点 ${id || "未命名"}`,
subtitle: isPipe
? `DN${formatValue(diameter)} · ${formatValue(length)} m`
: `需水量 ${formatValue(demand)} · 高程 ${formatValue(feature.properties?.elevation)} m`,
properties: feature.properties ?? {}
};
}
+96
View File
@@ -0,0 +1,96 @@
export type LngLatTuple = [number, number];
const EARTH_RADIUS_METERS = 6371008.8;
const SAME_COORDINATE_EPSILON = 0.0000001;
export function getDistanceMeters(from: LngLatTuple, to: LngLatTuple) {
const fromLatitude = toRadians(from[1]);
const toLatitude = toRadians(to[1]);
const deltaLatitude = toRadians(to[1] - from[1]);
const deltaLongitude = toRadians(to[0] - from[0]);
const a =
Math.sin(deltaLatitude / 2) ** 2 +
Math.cos(fromLatitude) * Math.cos(toLatitude) * Math.sin(deltaLongitude / 2) ** 2;
return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export function getLineDistanceMeters(coordinates: LngLatTuple[]) {
return coordinates.reduce((total, coordinate, index) => {
if (index === 0) {
return total;
}
return total + getDistanceMeters(coordinates[index - 1], coordinate);
}, 0);
}
export function getLastSegmentDistanceMeters(coordinates: LngLatTuple[]) {
if (coordinates.length < 2) {
return 0;
}
return getDistanceMeters(coordinates[coordinates.length - 2], coordinates[coordinates.length - 1]);
}
export function getPolygonAreaSquareMeters(coordinates: LngLatTuple[]) {
if (coordinates.length < 3) {
return 0;
}
const closedCoordinates = [...coordinates, coordinates[0]];
const area = closedCoordinates.slice(0, -1).reduce((total, coordinate, index) => {
const nextCoordinate = closedCoordinates[index + 1];
return (
total +
toRadians(nextCoordinate[0] - coordinate[0]) *
(2 + Math.sin(toRadians(coordinate[1])) + Math.sin(toRadians(nextCoordinate[1])))
);
}, 0);
return Math.abs((area * EARTH_RADIUS_METERS * EARTH_RADIUS_METERS) / 2);
}
export function createCircleCoordinates(center: LngLatTuple, radiusMeters: number, steps = 48) {
const angularDistance = radiusMeters / EARTH_RADIUS_METERS;
const centerLongitude = toRadians(center[0]);
const centerLatitude = toRadians(center[1]);
const coordinates: LngLatTuple[] = [];
for (let index = 0; index <= steps; index += 1) {
const bearing = (2 * Math.PI * index) / steps;
const latitude = Math.asin(
Math.sin(centerLatitude) * Math.cos(angularDistance) +
Math.cos(centerLatitude) * Math.sin(angularDistance) * Math.cos(bearing)
);
const longitude =
centerLongitude +
Math.atan2(
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(centerLatitude),
Math.cos(angularDistance) - Math.sin(centerLatitude) * Math.sin(latitude)
);
coordinates.push([toDegrees(longitude), toDegrees(latitude)]);
}
return coordinates;
}
export function isSameCoordinate(previous: LngLatTuple | undefined, next: LngLatTuple) {
if (!previous) {
return false;
}
return (
Math.abs(previous[0] - next[0]) < SAME_COORDINATE_EPSILON &&
Math.abs(previous[1] - next[1]) < SAME_COORDINATE_EPSILON
);
}
export function toRadians(value: number) {
return (value * Math.PI) / 180;
}
function toDegrees(value: number) {
return (value * 180) / Math.PI;
}
+136
View File
@@ -0,0 +1,136 @@
import type { StyleSpecification } from "maplibre-gl";
import { SOURCE_LAYERS } from "./sources";
export const waterNetworkLayers: StyleSpecification["layers"] = [
{
id: "pipes-casing",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 9,
paint: {
"line-color": "rgba(255,255,255,0.86)",
"line-width": [
"interpolate",
["linear"],
["zoom"],
9,
1,
13,
2.4,
17,
["+", 5, ["/", ["coalesce", ["get", "diameter"], 100], 280]]
],
"line-opacity": 0.9
}
},
{
id: "pipes-flow",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 9,
paint: {
"line-color": [
"case",
["==", ["get", "status"], "Closed"],
"#9ca3af",
[">=", ["coalesce", ["get", "diameter"], 0], 600],
"#0477bf",
[">=", ["coalesce", ["get", "diameter"], 0], 300],
"#0aa6a6",
"#3dbf7f"
],
"line-width": [
"interpolate",
["linear"],
["zoom"],
9,
0.8,
13,
1.8,
17,
["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]]
],
"line-opacity": 0.82
}
},
{
id: "pipes-risk-glow",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 12,
filter: [">=", ["coalesce", ["get", "diameter"], 0], 600],
paint: {
"line-color": "#ff7a45",
"line-width": ["interpolate", ["linear"], ["zoom"], 12, 2.6, 17, 7.5],
"line-blur": 2.5,
"line-opacity": 0.22
}
},
{
id: "pipes-hover",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 9,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#111827",
"line-width": 7,
"line-opacity": 0.35
}
},
{
id: "junctions-halo",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: 13,
paint: {
"circle-color": "#ffffff",
"circle-radius": ["interpolate", ["linear"], ["zoom"], 13, 2.8, 17, 6],
"circle-opacity": 0.82,
"circle-stroke-color": "rgba(15,23,42,0.22)",
"circle-stroke-width": 1
}
},
{
id: "junctions",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: 13,
paint: {
"circle-color": [
"interpolate",
["linear"],
["coalesce", ["get", "demand"], 0],
0,
"#70c1b3",
20,
"#247ba0",
60,
"#f25f5c"
],
"circle-radius": ["interpolate", ["linear"], ["zoom"], 13, 1.5, 17, 3.8],
"circle-opacity": 0.86
}
},
{
id: "junctions-hover",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: 13,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": "#111827",
"circle-radius": ["interpolate", ["linear"], ["zoom"], 13, 5, 17, 10],
"circle-opacity": 0.2,
"circle-stroke-color": "#111827",
"circle-stroke-width": 2
}
}
];
@@ -0,0 +1,78 @@
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
import type { MapLegendItem } from "@/features/map/core/components/legend";
export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
pipes: ["pipes-casing", "pipes-flow", "pipes-risk-glow", "pipes-hover"],
junctions: ["junctions-halo", "junctions", "junctions-hover"],
simulation: [
"simulation-impact-fill",
"simulation-impact-outline",
"simulation-burst-halo",
"simulation-burst-point",
"simulation-pipe-label",
"simulation-impact-label",
"simulation-district-label",
"simulation-burst-label"
]
};
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
pipes: true,
junctions: true,
simulation: false
};
export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
{ id: "major-pipe", label: "DN600 及以上管线", color: "#0477bf", shape: "line" },
{ id: "medium-pipe", label: "DN300-DN600 管线", color: "#0aa6a6", shape: "line" },
{ id: "minor-pipe", label: "DN300 以下管线", color: "#3dbf7f", shape: "line" },
{ id: "junction-demand", label: "节点需水强度", color: "#f25f5c", shape: "dot" },
{ id: "impact-area", label: "影响范围", color: "#ef4444", shape: "square" }
];
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
{
id: "mapbox-light",
label: "浅色",
description: "道路底图",
swatchClassName: "bg-gradient-to-br from-slate-50 via-slate-100 to-blue-100"
},
{
id: "mapbox-satellite",
label: "影像",
description: "卫星街道",
swatchClassName: "bg-gradient-to-br from-emerald-950 via-emerald-700 to-yellow-200"
},
{
id: "none",
label: "无底图",
description: "仅业务图层",
swatchClassName: "bg-slate-100"
}
];
export function createLayerControlItems(layerVisibility: Record<string, boolean>): MapLayerControlItem[] {
return [
{
id: "pipes",
label: "管线",
description: "主干、支线与风险高亮",
visible: layerVisibility.pipes
},
{
id: "junctions",
label: "节点",
description: "用水节点与悬停反馈",
visible: layerVisibility.junctions
},
{
id: "simulation",
label: "模拟结果",
description: "影响范围与事故标注",
visible: layerVisibility.simulation
}
];
}
@@ -0,0 +1,153 @@
import type {
FilterSpecification,
GeoJSONSource,
GeoJSONSourceSpecification,
StyleSpecification
} from "maplibre-gl";
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
import { SOURCE_LAYERS } from "./sources";
export const MEASUREMENT_SOURCE_ID = "workbench-measurement";
export const MEASUREMENT_SELECTED_PIPES_LAYER_ID = "workbench-measurement-selected-pipes";
export type MeasurementFeatureCollection = FeatureCollection<Geometry, { kind: "vertex" | "line" | "polygon" }>;
export const emptyMeasurementFeatureCollection: MeasurementFeatureCollection = {
type: "FeatureCollection",
features: []
};
export const measurementSource = {
type: "geojson",
data: emptyMeasurementFeatureCollection
} satisfies GeoJSONSourceSpecification;
export const measurementLayers: StyleSpecification["layers"] = [
{
id: MEASUREMENT_SELECTED_PIPES_LAYER_ID,
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 9,
filter: ["in", ["get", "id"], ["literal", []]],
paint: {
"line-color": "#2563eb",
"line-width": ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 7, 17, 11],
"line-opacity": 0.68
}
},
{
id: "workbench-measurement-polygon-fill",
type: "fill",
source: MEASUREMENT_SOURCE_ID,
filter: ["==", ["get", "kind"], "polygon"],
paint: {
"fill-color": "#2563eb",
"fill-opacity": 0.12
}
},
{
id: "workbench-measurement-line",
type: "line",
source: MEASUREMENT_SOURCE_ID,
filter: ["any", ["==", ["get", "kind"], "line"], ["==", ["get", "kind"], "polygon"]],
paint: {
"line-color": "#2563eb",
"line-width": 2.5,
"line-dasharray": [2, 1.5],
"line-opacity": 0.9
}
},
{
id: "workbench-measurement-vertex",
type: "circle",
source: MEASUREMENT_SOURCE_ID,
filter: ["==", ["get", "kind"], "vertex"],
paint: {
"circle-color": "#2563eb",
"circle-radius": 4,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1.5
}
}
];
export function ensureMeasurementLayers(map: {
getSource: (id: string) => unknown;
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
getLayer: (id: string) => unknown;
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number]) => void;
}) {
if (!map.getSource(MEASUREMENT_SOURCE_ID)) {
map.addSource(MEASUREMENT_SOURCE_ID, measurementSource);
}
measurementLayers.forEach((layer) => {
if (!map.getLayer(layer.id)) {
map.addLayer(layer);
}
});
}
export function setMeasurementSourceData(
map: { getSource: (id: string) => unknown },
data: MeasurementFeatureCollection
) {
const source = map.getSource(MEASUREMENT_SOURCE_ID) as GeoJSONSource | undefined;
source?.setData(data);
}
export function setSelectedMeasurementPipeIds(
map: { getLayer: (id: string) => unknown; setFilter: (id: string, filter?: FilterSpecification) => void },
pipeIds: string[]
) {
if (!map.getLayer(MEASUREMENT_SELECTED_PIPES_LAYER_ID)) {
return;
}
map.setFilter(MEASUREMENT_SELECTED_PIPES_LAYER_ID, ["in", ["get", "id"], ["literal", pipeIds]]);
}
export function createMeasurementFeatureCollection(
coordinates: [number, number][],
mode: "distance" | "area" | "segment"
): MeasurementFeatureCollection {
const features: MeasurementFeatureCollection["features"] = coordinates.map((coordinate, index) => ({
type: "Feature",
id: `measurement-vertex-${index}`,
properties: { kind: "vertex" },
geometry: {
type: "Point",
coordinates: coordinate
} satisfies Point
}));
if (coordinates.length >= 2) {
features.push({
type: "Feature",
id: "measurement-line",
properties: { kind: "line" },
geometry: {
type: "LineString",
coordinates
} satisfies LineString
});
}
if (mode === "area" && coordinates.length >= 3) {
features.push({
type: "Feature",
id: "measurement-polygon",
properties: { kind: "polygon" },
geometry: {
type: "Polygon",
coordinates: [[...coordinates, coordinates[0]]]
} satisfies Polygon
});
}
return {
type: "FeatureCollection",
features
};
}
@@ -0,0 +1,12 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { simulationLayerIds } from "./annotation-layers";
export function setSimulationLayersVisibility(map: MapLibreMap, visible: boolean) {
const visibility = visible ? "visible" : "none";
simulationLayerIds.forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", visibility);
}
});
}
+101
View File
@@ -0,0 +1,101 @@
import type { StyleSpecification } from "maplibre-gl";
export const WATER_NETWORK_GLOBAL_VIEW = {
bbox3857: [
13508802,
3608164,
13555651,
3633686
]
} as const;
export const SOURCE_LAYERS = {
pipes: "geo_pipes_mat",
junctions: "geo_junctions_mat"
} as const;
export function createWaterNetworkSources() {
return {
pipes: {
type: "vector" as const,
tiles: [
"https://geoserver.waternetwork.cn/geoserver/gwc/service/wmts/rest/tjwater:geo_pipes_mat/line/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile"
],
minzoom: 0,
maxzoom: 24
},
junctions: {
type: "vector" as const,
tiles: [
"https://geoserver.waternetwork.cn/geoserver/gwc/service/wmts/rest/tjwater:geo_junctions_mat/generic/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile"
],
minzoom: 0,
maxzoom: 24
}
};
}
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
const sources: StyleSpecification["sources"] = {};
const layers: StyleSpecification["layers"] = [
{
id: "background",
type: "background",
paint: {
"background-color": "#eef3f7"
}
}
];
if (mapboxToken) {
sources["mapbox-light"] = {
type: "raster",
tiles: [
`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/512/{z}/{x}/{y}@2x?access_token=${mapboxToken}`
],
tileSize: 512,
attribution:
'© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
};
sources["mapbox-satellite"] = {
type: "raster",
tiles: [
`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/512/{z}/{x}/{y}@2x?access_token=${mapboxToken}`
],
tileSize: 512,
attribution:
'© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
};
layers.push({
id: "mapbox-light",
type: "raster",
source: "mapbox-light",
paint: {
"raster-opacity": 0.82,
"raster-saturation": -0.22,
"raster-contrast": 0.04
}
});
layers.push({
id: "mapbox-satellite",
type: "raster",
source: "mapbox-satellite",
layout: {
visibility: "none"
},
paint: {
"raster-opacity": 0.86,
"raster-saturation": -0.16,
"raster-contrast": 0.02
}
});
}
return {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
sources,
layers
};
}
+35
View File
@@ -0,0 +1,35 @@
import { BarChart3, Layers3, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
import type { MapToolbarItem } from "@/features/map/core/components/toolbar";
export const waterNetworkToolbarItems: MapToolbarItem[] = [
{
id: "layers",
label: "图层",
description: "管理地图图层",
icon: Layers3
},
{
id: "measure",
label: "测量",
description: "距离与范围测量",
icon: Ruler
},
{
id: "analysis",
label: "分析",
description: "查看地图分析图例",
icon: BarChart3
},
{
id: "annotation",
label: "标注",
description: "管理地图标注",
icon: MapPinned
},
{
id: "more",
label: "更多",
description: "更多地图操作",
icon: MoreHorizontal
}
];