feat: initialize drainage network frontend
This commit is contained in:
@@ -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);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 ?? {}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
Reference in New Issue
Block a user