From 589cf45aa7b39da368d8e058c683cf4b5e39f3d3 Mon Sep 17 00:00:00 2001 From: Huarch Date: Fri, 17 Jul 2026 15:12:10 +0800 Subject: [PATCH] fix(map): stabilize tiled style rendering --- .../chat/toolCallStyleHelpers.test.ts | 50 + src/components/chat/toolCallStyleHelpers.ts | 89 +- .../applyJunctionAreaRender.ts | 72 +- .../olmap/HealthRiskAnalysis/Timeline.tsx | 225 +- .../olmap/core/Controls/BaseLayers.test.ts | 63 + .../olmap/core/Controls/BaseLayers.tsx | 96 +- .../olmap/core/Controls/StyleEditorForm.tsx | 953 ++++----- .../olmap/core/Controls/StyleEditorPanel.tsx | 62 +- .../olmap/core/Controls/StyleLegend.tsx | 55 +- .../olmap/core/Controls/Timeline.tsx | 81 +- .../olmap/core/Controls/Toolbar.tsx | 5 +- .../olmap/core/Controls/styleEditorPresets.ts | 4 +- .../olmap/core/Controls/styleEditorTypes.ts | 32 +- .../core/Controls/styleEditorUtils.test.ts | 107 + .../olmap/core/Controls/styleEditorUtils.ts | 530 ++--- .../olmap/core/Controls/useStyleEditor.ts | 1811 +++++++---------- src/components/olmap/core/MapComponent.tsx | 610 +++--- .../olmap/core/layerStyleController.test.ts | 87 + .../olmap/core/layerStyleController.ts | 126 ++ .../olmap/core/mapLifecycle.test.ts | 25 + src/components/olmap/core/mapLifecycle.ts | 39 +- .../olmap/core/operationalLayers.test.ts | 84 + .../olmap/core/operationalLayers.ts | 33 +- .../olmap/core/tileFeatureIndex.test.ts | 101 + src/components/olmap/core/tileFeatureIndex.ts | 322 +++ .../olmap/core/vectorTileStyleSession.test.ts | 245 +++ .../olmap/core/vectorTileStyleSession.ts | 414 ++++ src/components/olmap/core/vectorTileUtils.ts | 24 + 28 files changed, 3884 insertions(+), 2461 deletions(-) create mode 100644 src/components/chat/toolCallStyleHelpers.test.ts create mode 100644 src/components/olmap/core/Controls/BaseLayers.test.ts create mode 100644 src/components/olmap/core/Controls/styleEditorUtils.test.ts create mode 100644 src/components/olmap/core/layerStyleController.test.ts create mode 100644 src/components/olmap/core/layerStyleController.ts create mode 100644 src/components/olmap/core/operationalLayers.test.ts create mode 100644 src/components/olmap/core/tileFeatureIndex.test.ts create mode 100644 src/components/olmap/core/tileFeatureIndex.ts create mode 100644 src/components/olmap/core/vectorTileStyleSession.test.ts create mode 100644 src/components/olmap/core/vectorTileStyleSession.ts create mode 100644 src/components/olmap/core/vectorTileUtils.ts diff --git a/src/components/chat/toolCallStyleHelpers.test.ts b/src/components/chat/toolCallStyleHelpers.test.ts new file mode 100644 index 0000000..76bdd9a --- /dev/null +++ b/src/components/chat/toolCallStyleHelpers.test.ts @@ -0,0 +1,50 @@ +import { parseApplyLayerStylePayload } from "./toolCallStyleHelpers"; + +describe("parseApplyLayerStylePayload", () => { + it("accepts a valid snake_case interval contract", () => { + expect( + parseApplyLayerStylePayload({ + layer_id: "pipes", + style_config: { + property: "velocity", + classification_method: "custom_breaks", + segments: 3, + custom_breaks: [0, 1, 2, 3], + color_type: "custom", + custom_colors: ["#000000", "#777777", "#ffffff"], + }, + }), + ).toMatchObject({ + layerId: "pipes", + resetToDefault: false, + styleConfig: { segments: 3, customBreaks: [0, 1, 2, 3] }, + }); + }); + + it("rejects invalid class counts and array cardinalities", () => { + expect( + parseApplyLayerStylePayload({ + layer_id: "pipes", + style_config: { segments: 1, property: "velocity" }, + }), + ).toBeNull(); + expect( + parseApplyLayerStylePayload({ + layer_id: "junctions", + style_config: { + segments: 3, + custom_breaks: [0, 1, 2], + }, + }), + ).toBeNull(); + }); + + it("keeps camelCase compatibility", () => { + expect( + parseApplyLayerStylePayload({ + layerId: "junctions", + styleConfig: { opacity: 0.5, colorType: "gradient" }, + }), + ).toMatchObject({ layerId: "junctions", styleConfig: { opacity: 0.5 } }); + }); +}); diff --git a/src/components/chat/toolCallStyleHelpers.ts b/src/components/chat/toolCallStyleHelpers.ts index 578c097..f62fafc 100644 --- a/src/components/chat/toolCallStyleHelpers.ts +++ b/src/components/chat/toolCallStyleHelpers.ts @@ -1,4 +1,9 @@ -import type { StyleConfig, DefaultLayerStyleId } from "@components/olmap/core/Controls/styleEditorTypes"; +import type { + ClassificationMethod, + ColorType, + StyleConfig, + DefaultLayerStyleId, +} from "@components/olmap/core/Controls/styleEditorTypes"; export type ApplyLayerStyleActionPayload = { layerId: DefaultLayerStyleId; @@ -48,6 +53,23 @@ const asStringArray = (value: unknown): string[] | undefined => .filter((item): item is string => item !== undefined) : undefined; +const asClassificationMethod = (value: unknown): ClassificationMethod | undefined => { + const normalized = asString(value); + return normalized === "pretty_breaks" || normalized === "custom_breaks" + ? normalized + : undefined; +}; + +const asColorType = (value: unknown): ColorType | undefined => { + const normalized = asString(value); + return normalized === "single" || + normalized === "gradient" || + normalized === "rainbow" || + normalized === "custom" + ? normalized + : undefined; +}; + export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => { const normalized = asString(value)?.toLowerCase(); if (normalized === "junctions" || normalized === "pipes") { @@ -77,13 +99,25 @@ export const parseApplyLayerStylePayload = ( ? (params.styleConfig as Record) : null; + const classificationValue = + rawStyleConfig?.classification_method ?? rawStyleConfig?.classificationMethod; + const colorTypeValue = rawStyleConfig?.color_type ?? rawStyleConfig?.colorType; + const segmentsValue = rawStyleConfig?.segments; + const segments = asNumber(segmentsValue); + if ( + (classificationValue !== undefined && !asClassificationMethod(classificationValue)) || + (colorTypeValue !== undefined && !asColorType(colorTypeValue)) || + (segmentsValue !== undefined && + (!Number.isInteger(segments) || (segments as number) < 2 || (segments as number) > 10)) + ) { + return null; + } + const styleConfig: Partial | undefined = rawStyleConfig ? { property: asString(rawStyleConfig.property), - classificationMethod: asString( - rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod, - ), - segments: asNumber(rawStyleConfig.segments), + classificationMethod: asClassificationMethod(classificationValue), + segments, minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize), maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize), minStrokeWidth: asNumber( @@ -95,7 +129,7 @@ export const parseApplyLayerStylePayload = ( fixedStrokeWidth: asNumber( rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth, ), - colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType), + colorType: asColorType(colorTypeValue), singlePaletteIndex: asNumber( rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex, ), @@ -121,6 +155,49 @@ export const parseApplyLayerStylePayload = ( } : undefined; + if (styleConfig) { + const numericValues = [ + styleConfig.minSize, + styleConfig.maxSize, + styleConfig.minStrokeWidth, + styleConfig.maxStrokeWidth, + styleConfig.fixedStrokeWidth, + ].filter((value): value is number => value !== undefined); + if (numericValues.some((value) => value <= 0)) return null; + const paletteIndexes: Array<[number | undefined, number]> = [ + [styleConfig.singlePaletteIndex, 7], + [styleConfig.gradientPaletteIndex, 3], + [styleConfig.rainbowPaletteIndex, 2], + ]; + if ( + paletteIndexes.some( + ([index, length]) => + index !== undefined && + (!Number.isInteger(index) || index < 0 || index >= length), + ) + ) return null; + if ( + styleConfig.opacity !== undefined && + (styleConfig.opacity < 0 || styleConfig.opacity > 1) + ) return null; + if ( + styleConfig.customBreaks && + styleConfig.customBreaks.some( + (value, index, values) => index > 0 && value <= values[index - 1], + ) + ) return null; + if ( + styleConfig.segments !== undefined && + styleConfig.customBreaks && + styleConfig.customBreaks.length !== styleConfig.segments + 1 + ) return null; + if ( + styleConfig.segments !== undefined && + styleConfig.customColors && + styleConfig.customColors.length !== styleConfig.segments + ) return null; + } + const hasStyleOverrides = styleConfig && Object.values(styleConfig).some((value) => diff --git a/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts b/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts index bd3c7c4..b98099c 100644 --- a/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts +++ b/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts @@ -1,9 +1,13 @@ -import { Map as OlMap, VectorTile } from "ol"; +import { Map as OlMap } from "ol"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; import VectorTileSource from "ol/source/VectorTile"; import { FlatStyleLike } from "ol/style/flat"; import { config } from "@/config/config"; +import { + VectorTileStyleSession, + versionedPropertyCase, +} from "@components/olmap/core/vectorTileStyleSession"; import { getAreaColor } from "./utils"; const JUNCTION_LAYER_VALUE = "junctions"; @@ -83,40 +87,6 @@ export const applyJunctionAreaRender = ( } }); - const applyFeatureAreaIndex = (renderFeature: any) => { - const featureId = String(renderFeature.get("id") ?? ""); - const areaIndex = nodeAreaIndexMap.get(featureId); - if (areaIndex !== undefined) { - renderFeature.properties_[propertyKey] = areaIndex; - } - }; - - const sourceTiles = (source as any).sourceTiles_; - if (sourceTiles) { - Object.values(sourceTiles).forEach((vectorTile: any) => { - const renderFeatures = vectorTile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - renderFeatures.forEach((renderFeature: any) => { - applyFeatureAreaIndex(renderFeature); - }); - }); - } - - const listener = (event: any) => { - try { - if (!(event.tile instanceof VectorTile)) return; - const renderFeatures = event.tile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - renderFeatures.forEach((renderFeature: any) => { - applyFeatureAreaIndex(renderFeature); - }); - } catch (error) { - console.error("Error applying junction area render:", error); - } - }; - - source.on("tileloadend", listener); - const fillCases: any[] = []; areaIds.forEach((areaId, index) => { fillCases.push( @@ -131,14 +101,34 @@ export const applyJunctionAreaRender = ( ); junctionLayer.set(RENDER_OWNER_KEY, ownerId); - junctionLayer.setStyle({ - ...config.MAP_DEFAULT_STYLE, - "circle-fill-color": ["case", ...fillCases, defaultFillColor], - "circle-stroke-color": ["case", ...fillCases, defaultStrokeColor], - } as FlatStyleLike); + const session = new VectorTileStyleSession({ + layer: junctionLayer, + source, + propertyKey, + defaultStyle: config.MAP_DEFAULT_STYLE as FlatStyleLike, + buildStyle: (statePropertyKey, versionKey, version) => + ({ + ...config.MAP_DEFAULT_STYLE, + "circle-fill-color": versionedPropertyCase( + statePropertyKey, + versionKey, + version, + fillCases, + defaultFillColor, + ), + "circle-stroke-color": versionedPropertyCase( + statePropertyKey, + versionKey, + version, + fillCases, + defaultStrokeColor, + ), + }) as FlatStyleLike, + }); + session.commit(nodeAreaIndexMap); return () => { - source.un("tileloadend", listener); + session.dispose(); if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) { junctionLayer.unset(RENDER_OWNER_KEY, true); junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); diff --git a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx index c169883..b0bb85a 100644 --- a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx +++ b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx @@ -3,6 +3,8 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useNotification } from "@refinedev/core"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import type VectorTileSource from "ol/source/VectorTile"; +import type { FlatStyleLike } from "ol/style/flat"; import { Box, @@ -31,6 +33,10 @@ import { config, NETWORK_NAME } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; import { useMap } from "@components/olmap/core/MapComponent"; import { useTimelineTimeConfig } from "@components/olmap/core/Controls/useTimelineTimeConfig"; +import { + VectorTileStyleSession, + versionedPropertyCase, +} from "@components/olmap/core/vectorTileStyleSession"; import { useHealthRisk } from "./HealthRiskContext"; import { PredictionResult, @@ -54,6 +60,50 @@ const getRoundedDate = (date: Date, stepMinutes: number): Date => { return roundedDate; }; +const buildHealthRiskStyle = ( + propertyKey: string, + versionKey: string, + version: number, +): FlatStyleLike => { + const colorCases: any[] = []; + const widthCases: any[] = []; + RISK_BREAKS.forEach((breakValue, index) => { + colorCases.push( + ["<=", ["get", propertyKey], breakValue], + RAINBOW_COLORS[index], + ); + widthCases.push( + ["<=", ["get", propertyKey], breakValue], + 2 + (1 - index / (RISK_BREAKS.length - 1)) * 4, + ); + }); + return { + "stroke-color": versionedPropertyCase( + propertyKey, + versionKey, + version, + colorCases, + "rgba(128, 128, 128, 1)", + ), + "stroke-width": versionedPropertyCase( + propertyKey, + versionKey, + version, + widthCases, + 2, + ), + }; +}; + +const getSurvivalProbabilityAtYear = ( + survivalFunction: SurvivalFunction, + index: number, +) => { + const values = survivalFunction.y; + if (values.length === 0) return 1; + return values[Math.max(0, Math.min(index, values.length - 1))]; +}; + interface TimelineProps { schemeDate?: Date; timeRange?: { start: Date; end: Date }; @@ -93,10 +143,10 @@ const Timeline: React.FC = ({ const [isPlaying, setIsPlaying] = useState(false); const [playInterval, setPlayInterval] = useState(5000); // 毫秒 const [isPredicting, setIsPredicting] = useState(false); + const [sliderPreviewYear, setSliderPreviewYear] = useState(null); const { stepMinutes } = useTimelineTimeConfig(); - // 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定 - const healthDataRef = useRef>(new Map()); + const healthStyleSessionRef = useRef(null); // 计算时间轴范围 (4-73) const minTime = 4; @@ -105,9 +155,6 @@ const Timeline: React.FC = ({ const intervalRef = useRef(null); const timelineRef = useRef(null); - // 添加防抖引用 - const debounceRef = useRef(null); - // 时间刻度数组 (4-73,每3个单位一个刻度) const valueMarks = Array.from({ length: 24 }, (_, i) => ({ value: 4 + i * 3, @@ -129,15 +176,18 @@ const Timeline: React.FC = ({ if (value < minTime || value > maxTime) { return; } - // 防抖设置currentYear,避免频繁触发数据获取 - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - debounceRef.current = setTimeout(() => { - setCurrentYear(value); - }, 500); // 500ms 防抖延迟 + setSliderPreviewYear(value); }, - [minTime, maxTime, setCurrentYear], + [minTime, maxTime], + ); + + const handleSliderChangeCommitted = useCallback( + (_event: Event | React.SyntheticEvent, newValue: number | number[]) => { + const value = Array.isArray(newValue) ? newValue[0] : newValue; + setSliderPreviewYear(null); + setCurrentYear(value); + }, + [setCurrentYear], ); // 播放控制 @@ -241,9 +291,6 @@ const Timeline: React.FC = ({ if (intervalRef.current) { clearInterval(intervalRef.current); } - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } }; }, [stepMinutes]); @@ -261,143 +308,50 @@ const Timeline: React.FC = ({ ) ?? null; }, [map]); - // 根据索引从 survival_function 中获取生存概率 - const getSurvivalProbabilityAtYear = useCallback( - (survivalFunc: SurvivalFunction, index: number): number => { - const { y } = survivalFunc; - if (y.length === 0) return 1; - - // 确保索引在范围内 - const safeIndex = Math.max(0, Math.min(index, y.length - 1)); - return y[safeIndex]; - }, - [], - ); - - // 更新管道图层中的 healthRisk 属性 - const updatePipeHealthData = useCallback( - (healthData: Map) => { - if (!pipeLayer) return; - const source = pipeLayer.getSource() as any; - if (!source) return; - - const sourceTiles = source.sourceTiles_; - if (!sourceTiles) return; - - Object.values(sourceTiles).forEach((vectorTile: any) => { - const renderFeatures = vectorTile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - renderFeatures.forEach((renderFeature: any) => { - const featureId = renderFeature.get("id"); - const value = healthData.get(featureId); - if (value !== undefined) { - renderFeature.properties_["healthRisk"] = value; - } - }); - }); - }, - [pipeLayer], - ); - - // 监听瓦片加载,为新瓦片设置 healthRisk 属性 - // 只在 pipeLayer 变化时绑定一次,通过 ref 获取最新数据 useEffect(() => { if (!pipeLayer) return; - const source = pipeLayer.getSource() as any; + const source = pipeLayer.getSource() as VectorTileSource | null; if (!source) return; - const listener = (event: any) => { - const vectorTile = event.tile; - const renderFeatures = vectorTile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - - const healthData = healthDataRef.current; - renderFeatures.forEach((renderFeature: any) => { - const featureId = renderFeature.get("id"); - const value = healthData.get(featureId); - if (value !== undefined) { - renderFeature.properties_["healthRisk"] = value; - } - }); - }; - - source.on("tileloadend", listener); + const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike; + healthStyleSessionRef.current?.dispose(); + healthStyleSessionRef.current = new VectorTileStyleSession({ + layer: pipeLayer, + source, + propertyKey: "healthRisk", + defaultStyle: defaultFlatStyle, + buildStyle: buildHealthRiskStyle, + map, + buffered: true, + }); return () => { - source.un("tileloadend", listener); + healthStyleSessionRef.current?.dispose(); + healthStyleSessionRef.current = null; + pipeLayer.setStyle(defaultFlatStyle); }; - }, [pipeLayer]); + }, [map, pipeLayer]); - // 应用样式到管道图层 - const applyPipeHealthStyle = useCallback(() => { - if (!pipeLayer || predictionResults.length === 0) { + useEffect(() => { + const session = healthStyleSessionRef.current; + if (!session) return; + if (predictionResults.length === 0) { + session.reset(); return; } - // 为每条管道计算当前年份的生存概率 const pipeHealthData = new Map(); predictionResults.forEach((result) => { const probability = getSurvivalProbabilityAtYear( result.survival_function, - currentYear - 4, // 使用索引 (0-based) + currentYear - 4, ); pipeHealthData.set(result.link_id, probability); }); + session.commit(pipeHealthData); + }, [currentYear, pipeLayer, predictionResults]); - // 更新 ref 数据 - healthDataRef.current = pipeHealthData; - - // 更新图层数据 - updatePipeHealthData(pipeHealthData); - - // 获取所有概率值用于分类 - const probabilities = Array.from(pipeHealthData.values()); - if (probabilities.length === 0) return; - - // 使用等距分段,从0-1分为十类 - const breaks = RISK_BREAKS; - - // 生成彩虹色(从紫色到红色,低生存概率=高风险=红色) - const colors = RAINBOW_COLORS; - - // 构建 WebGL 样式表达式 - const colorCases: any[] = []; - const widthCases: any[] = []; - - breaks.forEach((breakValue, index) => { - const colorStr = colors[index]; - // 线宽根据健康风险调整:低生存概率(高风险)用粗线 - const width = 2 + (1 - index / (breaks.length - 1)) * 4; - - colorCases.push(["<=", ["get", "healthRisk"], breakValue], colorStr); - widthCases.push(["<=", ["get", "healthRisk"], breakValue], width); - }); - // console.log( - // `应用健康风险样式,年份: ${currentYear}, 分段: ${breaks.length}`, - // ); - // console.log("颜色表达式:", colorCases); - // console.log("宽度表达式:", widthCases); - // 应用样式到图层 - pipeLayer.setStyle({ - "stroke-color": ["case", ...colorCases, "rgba(128, 128, 128, 1)"], - "stroke-width": ["case", ...widthCases, 2], - }); - }, [ - pipeLayer, - predictionResults, - currentYear, - getSurvivalProbabilityAtYear, - updatePipeHealthData, - ]); - - // 监听依赖变化,更新样式 - useEffect(() => { - if (predictionResults.length > 0 && pipeLayer) { - applyPipeHealthStyle(); - } - }, [applyPipeHealthStyle, pipeLayer, predictionResults.length]); - - // 这里防止地图缩放时,瓦片重新加载引起的属性更新出错 + // 缩放期间暂停时间轴,避免视图变化与自动播放同时推进。 useEffect(() => { // 监听地图缩放事件,缩放时停止播放 if (map) { @@ -640,12 +594,13 @@ const Timeline: React.FC = ({ ({ + useData: jest.fn(), + useMap: jest.fn(), +})); +jest.mock("../mapLifecycle", () => ({ + markMapResourcePersistent: (resource: T) => resource, +})); + +jest.mock("ol/source/XYZ.js", () => ({ + __esModule: true, + default: class MockXyzSource { + constructor(readonly options: unknown) {} + }, +})); +jest.mock("ol/layer/Tile.js", () => ({ + __esModule: true, + default: class MockTileLayer { + private readonly source: unknown; + constructor(options: any) { + this.source = options.source; + } + getSource() { return this.source; } + }, +})); +jest.mock("ol/layer/Group", () => ({ + __esModule: true, + default: class MockGroup { + private readonly layers: unknown[]; + constructor(options: any) { + this.layers = options.layers; + } + getLayers() { return { getArray: () => this.layers }; } + }, +})); + +import { + createBaseLayerEntries, + createBaseLayerSources, +} from "./BaseLayers"; + +const getLeafSources = (layer: any): unknown[] => { + const childLayers = layer.getLayers?.().getArray?.(); + if (Array.isArray(childLayers)) { + return childLayers.flatMap(getLeafSources); + } + return [layer.getSource?.()]; +}; + +describe("base layer resources", () => { + it("creates independent layers backed by one shared source pool", () => { + const sources = createBaseLayerSources(); + const primary = createBaseLayerEntries(sources); + const compare = createBaseLayerEntries(sources); + + expect(primary).toHaveLength(compare.length); + primary.forEach((entry, index) => { + expect(entry.layer).not.toBe(compare[index].layer); + expect(getLeafSources(entry.layer)).toEqual( + getLeafSources(compare[index].layer), + ); + }); + }); +}); diff --git a/src/components/olmap/core/Controls/BaseLayers.tsx b/src/components/olmap/core/Controls/BaseLayers.tsx index 8685f67..64483a6 100644 --- a/src/components/olmap/core/Controls/BaseLayers.tsx +++ b/src/components/olmap/core/Controls/BaseLayers.tsx @@ -30,91 +30,92 @@ const BASE_LAYER_METADATA = [ { id: "tianditu-image", name: "天地图影像", img: mapboxSatellite.src }, ] as const; -const createTileLayer = (url: string, attributions: string) => - new TileLayer({ - source: new XYZ({ - url, - tileSize: 512, - maxZoom: 20, - projection: "EPSG:3857", - attributions, - }), +const createTileSource = (url: string, attributions: string) => + new XYZ({ + url, + tileSize: 512, + maxZoom: 20, + projection: "EPSG:3857", + attributions, }); -const createBaseLayerEntries = () => { - const streetsLayer = createTileLayer( +export const createBaseLayerSources = () => ({ + streets: createTileSource( `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - '数据来源:Mapbox & OpenStreetMap' - ); - const lightMapLayer = createTileLayer( + '数据来源:Mapbox & OpenStreetMap', + ), + light: createTileSource( `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - '数据来源:Mapbox & OpenStreetMap' - ); - const satelliteLayer = createTileLayer( + '数据来源:Mapbox & OpenStreetMap', + ), + satellite: createTileSource( `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - '数据来源:Mapbox & OpenStreetMap' - ); - const satelliteStreetsLayer = createTileLayer( + '数据来源:Mapbox & OpenStreetMap', + ), + satelliteStreets: createTileSource( `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - '数据来源:Mapbox & OpenStreetMap' - ); - - const tiandituVectorLayer = new TileLayer({ - source: new XYZ({ + '数据来源:Mapbox & OpenStreetMap', + ), + tiandituVector: new XYZ({ url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, projection: "EPSG:3857", attributions: '数据来源:天地图', - }), - }); - const tiandituVectorAnnotationLayer = new TileLayer({ - source: new XYZ({ + }), + tiandituVectorAnnotation: new XYZ({ url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, projection: "EPSG:3857", attributions: '数据来源:天地图', - }), - }); - const tiandituImageLayer = new TileLayer({ - source: new XYZ({ + }), + tiandituImage: new XYZ({ url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, projection: "EPSG:3857", attributions: '数据来源:天地图', - }), - }); - const tiandituImageAnnotationLayer = new TileLayer({ - source: new XYZ({ + }), + tiandituImageAnnotation: new XYZ({ url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, projection: "EPSG:3857", attributions: '数据来源:天地图', - }), - }); + }), +}); + +export type BaseLayerSources = ReturnType; + +export const createBaseLayerEntries = (sources: BaseLayerSources) => { + const tileLayer = (source: XYZ) => new TileLayer({ source }); return [ { ...BASE_LAYER_METADATA[0], - layer: lightMapLayer, + layer: tileLayer(sources.light), }, { ...BASE_LAYER_METADATA[1], - layer: satelliteLayer, + layer: tileLayer(sources.satellite), }, { ...BASE_LAYER_METADATA[2], - layer: satelliteStreetsLayer, + layer: tileLayer(sources.satelliteStreets), }, { ...BASE_LAYER_METADATA[3], - layer: streetsLayer, + layer: tileLayer(sources.streets), }, { ...BASE_LAYER_METADATA[4], layer: new Group({ - layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer], + layers: [ + tileLayer(sources.tiandituVector), + tileLayer(sources.tiandituVectorAnnotation), + ], }), }, { ...BASE_LAYER_METADATA[5], layer: new Group({ - layers: [tiandituImageLayer, tiandituImageAnnotationLayer], + layers: [ + tileLayer(sources.tiandituImage), + tileLayer(sources.tiandituImageAnnotation), + ], }), }, ].map((entry) => ({ @@ -130,6 +131,7 @@ const BaseLayers: React.FC = () => { if (data?.maps?.length) return data.maps; return map ? [map] : []; }, [data?.maps, map]); + const sharedSources = useMemo(() => createBaseLayerSources(), []); const layerSetsRef = useRef(new WeakMap>()); const [isShow, setShow] = useState(false); const [isExpanded, setExpanded] = useState(false); @@ -139,7 +141,7 @@ const BaseLayers: React.FC = () => { maps.forEach((targetMap) => { let layerEntries = layerSetsRef.current.get(targetMap); if (!layerEntries) { - layerEntries = createBaseLayerEntries(); + layerEntries = createBaseLayerEntries(sharedSources); layerSetsRef.current.set(targetMap, layerEntries); } @@ -151,7 +153,7 @@ const BaseLayers: React.FC = () => { layerInfo.layer.setVisible(layerInfo.id === activeId); }); }); - }, [activeId, maps]); + }, [activeId, maps, sharedSources]); const changeMapLayers = (id: string) => { maps.forEach((targetMap) => { diff --git a/src/components/olmap/core/Controls/StyleEditorForm.tsx b/src/components/olmap/core/Controls/StyleEditorForm.tsx index eabf1ea..4ea0627 100644 --- a/src/components/olmap/core/Controls/StyleEditorForm.tsx +++ b/src/components/olmap/core/Controls/StyleEditorForm.tsx @@ -1,17 +1,21 @@ import ApplyIcon from "@mui/icons-material/Check"; -import ColorLensIcon from "@mui/icons-material/ColorLens"; +import PaletteIcon from "@mui/icons-material/PaletteOutlined"; import ResetIcon from "@mui/icons-material/Refresh"; import { Box, Button, Checkbox, + Chip, + Divider, FormControl, FormControlLabel, + IconButton, InputLabel, MenuItem, Select, Slider, TextField, + Tooltip, Typography, } from "@mui/material"; import React from "react"; @@ -23,14 +27,74 @@ import { RAINBOW_PALETTES, SINGLE_COLOR_PALETTES, } from "./styleEditorPresets"; -import { StyleEditorFormProps } from "./styleEditorTypes"; +import type { + ClassificationMethod, + ColorType, + StyleEditorFormProps, +} from "./styleEditorTypes"; import { - getSizePreviewColors, + getDefaultCustomColors, hexToRgba, resolveStyleColors, rgbaToHex, } from "./styleEditorUtils"; +const sectionSx = { px: 2, py: 1.5 } as const; +const sliderSx = { mx: 1, width: "calc(100% - 16px)" } as const; + +type PalettePreview = { name: string; colors: string[]; index: number }; + +const getPalettePreviews = (colorType: ColorType): PalettePreview[] => { + switch (colorType) { + case "single": + return SINGLE_COLOR_PALETTES.map((palette, index) => ({ + name: `单色 ${index + 1}`, + colors: [palette.color], + index, + })); + case "gradient": + return GRADIENT_PALETTES.map((palette, index) => ({ + name: palette.name, + colors: [palette.start, palette.end], + index, + })); + case "rainbow": + return RAINBOW_PALETTES.map((palette, index) => ({ + name: palette.name, + colors: palette.colors, + index, + })); + default: + return []; + } +}; + +const getSelectedPaletteIndex = (styleConfig: StyleEditorFormProps["styleConfig"]) => { + switch (styleConfig.colorType) { + case "single": + return styleConfig.singlePaletteIndex; + case "gradient": + return styleConfig.gradientPaletteIndex; + case "rainbow": + return styleConfig.rainbowPaletteIndex; + default: + return -1; + } +}; + +const selectPalette = (colorType: ColorType, index: number) => { + switch (colorType) { + case "single": + return { singlePaletteIndex: index }; + case "gradient": + return { gradientPaletteIndex: index }; + case "rainbow": + return { rainbowPaletteIndex: index }; + default: + return {}; + } +}; + const StyleEditorForm: React.FC = ({ renderLayers, selectedRenderLayer, @@ -42,548 +106,399 @@ const StyleEditorForm: React.FC = ({ onClassificationMethodChange, onSegmentsChange, onCustomBreakChange, - onCustomBreakBlur, onColorTypeChange, onApply, onReset, + validationErrors, + isDirty, + isApplying, }) => { - const renderColorSetting = () => { - if (styleConfig.colorType === "single") { - return ( - - 单一色方案 - - - ); - } + const layerType = selectedRenderLayer?.get("type"); + const previewColors = resolveStyleColors(styleConfig); + const selectedProperty = availableProperties.some( + (property) => property.value === styleConfig.property, + ) + ? styleConfig.property + : ""; - if (styleConfig.colorType === "gradient") { - return ( - - 渐进色方案 - - + const setCustomColor = (index: number, hex: string) => { + setStyleConfig((previous) => { + const customColors = getDefaultCustomColors( + previous.segments, + previous.customColors, ); - } - - if (styleConfig.colorType === "rainbow") { - return ( - - 离散彩虹方案 - - - ); - } - - if (styleConfig.colorType === "custom") { - return ( - - - 自定义颜色 - - - {Array.from({ length: styleConfig.segments }).map((_, index) => { - const color = styleConfig.customColors?.[index] || "rgba(0,0,0,1)"; - return ( - - - 分段{index + 1} - - { - const nextColor = hexToRgba(e.target.value); - setStyleConfig((prev) => { - const nextColors = [...(prev.customColors || [])]; - while (nextColors.length < prev.segments) { - nextColors.push("rgba(0,0,0,1)"); - } - nextColors[index] = nextColor; - return { ...prev, customColors: nextColors }; - }); - }} - style={{ - width: "100%", - height: "32px", - cursor: "pointer", - border: "1px solid #ccc", - borderRadius: "4px", - }} - /> - - ); - })} - - - ); - } - - return null; + customColors[index] = hexToRgba(hex); + return { ...previous, customColors }; + }); }; - const renderSizeSetting = () => { - const previewColors = getSizePreviewColors(styleConfig); + const renderPalette = () => { + const palettes = getPalettePreviews(styleConfig.colorType); + if (palettes.length === 0) return null; + const selectedIndex = getSelectedPaletteIndex(styleConfig); - if (selectedRenderLayer?.get("type") === "point") { - return ( - - - 点大小范围: {styleConfig.minSize} - {styleConfig.maxSize} 像素 - - - - - 最小值 - - - setStyleConfig((prev) => ({ ...prev, minSize: value as number })) - } - min={2} - max={8} - step={1} - size="small" - /> - - - - 最大值 - - - setStyleConfig((prev) => ({ ...prev, maxSize: value as number })) - } - min={10} - max={16} - step={1} - size="small" - /> - - - - 预览: + return ( + + {palettes.map((palette) => ( + + setStyleConfig((previous) => ({ + ...previous, + ...selectPalette(previous.colorType, palette.index), + })) + } sx={{ - width: styleConfig.minSize, - height: styleConfig.minSize, - borderRadius: "50%", - backgroundColor: previewColors[0], + display: "flex", + height: 30, + p: 0.5, + overflow: "hidden", + cursor: "pointer", + bgcolor: "background.paper", + border: "1px solid", + borderColor: selectedIndex === palette.index ? "primary.main" : "divider", + borderRadius: 1, + boxShadow: selectedIndex === palette.index ? "0 0 0 1px currentColor" : "none", }} - /> - - - - - ); - } - - if (selectedRenderLayer?.get("type") === "linestring") { - return ( - - - setStyleConfig((prev) => ({ - ...prev, - adjustWidthByProperty: e.target.checked, - })) - } - disabled={styleConfig.colorType === "single"} - /> - } - label="根据数值分段调整线条宽度" - /> - {styleConfig.adjustWidthByProperty ? ( - <> - - 线条宽度范围: {styleConfig.minStrokeWidth} - {styleConfig.maxStrokeWidth} - px - - - - - 最小值 - - - setStyleConfig((prev) => ({ - ...prev, - minStrokeWidth: value as number, - })) - } - min={1} - max={4} - step={0.5} - size="small" - /> - - - - 最大值 - - - setStyleConfig((prev) => ({ - ...prev, - maxStrokeWidth: value as number, - })) - } - min={6} - max={12} - step={0.5} - size="small" - /> - - - - 预览: - - - - - - ) : ( - <> - - 固定线条宽度: {styleConfig.fixedStrokeWidth}px - - - setStyleConfig((prev) => ({ - ...prev, - fixedStrokeWidth: value as number, - })) - } - min={1} - max={10} - step={0.5} - size="small" - /> - - 预览: - - - - )} - - ); - } - - return null; + > + {palette.colors.map((color, index) => ( + + ))} + + + ))} + + ); }; return ( -
- - 选择图层 - - - - - 分级属性 - - - - - 分类方法 - - - - - 分类数量: {styleConfig.segments} - onSegmentsChange(value as number)} - min={2} - max={10} - step={1} - marks - size="small" - /> + + + + + 图层样式 + + {isDirty && ( + + )} - {styleConfig.classificationMethod === "custom_breaks" && ( - - - 手动设置区间阈值(按升序填写,最小值 {">="} 0) + + + + 图层与数据 - - {Array.from({ length: styleConfig.segments }).map((_, index) => ( - onCustomBreakChange(index, e.target.value)} - onBlur={onCustomBreakBlur} - /> - ))} + + + 图层 + + + + 属性 + + + + 分级方法 + + + onSegmentsChange(Number(event.target.value))} + slotProps={{ htmlInput: { min: 2, max: 10, step: 1 } }} + /> - )} - - - - 颜色方案 - - - {renderColorSetting()} - + + + + 分级与色带 + + + 颜色方案 + + + {renderPalette()} - {renderSizeSetting()} + {styleConfig.classificationMethod === "custom_breaks" && ( + + {Array.from({ length: styleConfig.segments }, (_, index) => ( + + {styleConfig.colorType === "custom" ? ( + + ) => + setCustomColor(index, event.target.value) + } + sx={{ width: 32, height: 30, p: 0, border: 0, bgcolor: "transparent" }} + /> + + ) : ( + + )} + onCustomBreakChange(index, event.target.value)} + inputProps={{ "aria-label": `区间 ${index + 1} 下界`, step: 0.1 }} + /> + + 至 + + onCustomBreakChange(index + 1, event.target.value)} + inputProps={{ "aria-label": `区间 ${index + 1} 上界`, step: 0.1 }} + /> + + ))} + + )} + - - - 透明度: {(styleConfig.opacity * 100).toFixed(0)}% - - - setStyleConfig((prev) => ({ ...prev, opacity: value as number })) - } - min={0.1} - max={1} - step={0.05} - size="small" - /> + + + + 符号 + + {layerType === "point" ? ( + + + 节点半径 {styleConfig.minSize} - {styleConfig.maxSize}px + + { + const [minSize, maxSize] = value as number[]; + setStyleConfig((previous) => ({ ...previous, minSize, maxSize })); + }} + min={2} + max={16} + step={1} + size="small" + sx={sliderSx} + /> + + ) : ( + + + setStyleConfig((previous) => ({ + ...previous, + adjustWidthByProperty: event.target.checked, + })) + } + /> + } + label={按数值调整线宽} + /> + + {styleConfig.adjustWidthByProperty + ? `${styleConfig.minStrokeWidth} - ${styleConfig.maxStrokeWidth}px` + : `${styleConfig.fixedStrokeWidth}px`} + + + setStyleConfig((previous) => + Array.isArray(value) + ? { ...previous, minStrokeWidth: value[0], maxStrokeWidth: value[1] } + : { ...previous, fixedStrokeWidth: value }, + ) + } + min={1} + max={12} + step={0.5} + size="small" + sx={sliderSx} + /> + + )} + 透明度 {Math.round(styleConfig.opacity * 100)}% + + setStyleConfig((previous) => ({ ...previous, opacity: value as number })) + } + min={0.1} + max={1} + step={0.05} + size="small" + sx={sliderSx} + /> + + + + + + setStyleConfig((previous) => ({ ...previous, showLabels: event.target.checked })) + } + /> + } + label={数值标注} + /> + + setStyleConfig((previous) => ({ ...previous, showId: event.target.checked })) + } + /> + } + label={设施 ID} + /> + - - setStyleConfig((prev) => ({ ...prev, showId: e.target.checked })) - } - /> - } - label="显示 ID(缩放 >=15 级时显示)" - /> - - - setStyleConfig((prev) => ({ ...prev, showLabels: e.target.checked })) - } - /> - } - label="显示属性(缩放 >=15 级时显示)" - /> - -
- - - - + + + {validationErrors[0] && ( + + {validationErrors[0]} + + )} + + + + + + + + -
+
); }; diff --git a/src/components/olmap/core/Controls/StyleEditorPanel.tsx b/src/components/olmap/core/Controls/StyleEditorPanel.tsx index afc1686..ada7dd8 100644 --- a/src/components/olmap/core/Controls/StyleEditorPanel.tsx +++ b/src/components/olmap/core/Controls/StyleEditorPanel.tsx @@ -1,50 +1,38 @@ import React from "react"; +import { Box, CircularProgress } from "@mui/material"; import StyleEditorForm from "./StyleEditorForm"; -import { createDefaultLayerStyleState, createDefaultLayerStyleStates } from "./styleEditorPresets"; -import { LayerStyleState, StyleConfig, StyleEditorPanelProps } from "./styleEditorTypes"; +import type { StyleEditorPanelProps } from "./styleEditorTypes"; const StyleEditorPanel: React.FC = ({ isReady, - renderLayers, - selectedRenderLayer, - styleConfig, - setStyleConfig, - availableProperties, - onLayerChange, - onPropertyChange, - onClassificationMethodChange, - onSegmentsChange, - onCustomBreakChange, - onCustomBreakBlur, - onColorTypeChange, - onApply, - onReset, + ...formProps }) => { if (!isReady) { - return
Loading...
; + return ( + + + + ); } - return ( - - ); + return ; }; export default StyleEditorPanel; -export type { LayerStyleState, StyleConfig } from "./styleEditorTypes"; -export { createDefaultLayerStyleState, createDefaultLayerStyleStates }; diff --git a/src/components/olmap/core/Controls/StyleLegend.tsx b/src/components/olmap/core/Controls/StyleLegend.tsx index a1bffe8..597f8d2 100644 --- a/src/components/olmap/core/Controls/StyleLegend.tsx +++ b/src/components/olmap/core/Controls/StyleLegend.tsx @@ -7,34 +7,31 @@ interface LegendStyleConfig { layerId: string; property: string; colors: string[]; - type: string; // 图例类型 - dimensions: number[]; // 尺寸大小 - breaks: number[]; // 分段值 - labels?: string[]; // 可选标签(用于离散分类) + type: string; + dimensions: number[]; + breaks: number[]; + labels?: string[]; columns?: number; itemsPerColumn?: number; } -// 图例组件 -// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息 -// 通过传入的配置对象动态生成图例内容,适用于不同的样式配置 -// 使用时需要确保传入的 colors、dimensions 和 breaks 数组长度一致 - const StyleLegend: React.FC = ({ layerName, layerId, property, colors, - type, // 图例类型 + type, dimensions, breaks, labels, columns = 1, itemsPerColumn, }) => { + const itemCount = Math.min(colors.length, dimensions.length, Math.max(0, breaks.length - 1)); return ( {layerName} - {property} @@ -56,39 +53,9 @@ const StyleLegend: React.FC = ({ rowGap: 0.5, }} > - {[...Array(breaks.length)].map((_, index) => { - const color = colors[index]; // 默认颜色为黑色 - const dimension = dimensions[index]; // 默认尺寸为16 - - // // 处理第一个区间(小于 breaks[0]) - // if (index === 0) { - // return ( - // - // - // - // {"<"} {breaks[0]?.toFixed(1)} - // - // - // ); - // } - - // 处理中间区间(breaks[index] - breaks[index + 1]) + {Array.from({ length: itemCount }, (_, index) => { + const color = colors[index]; + const dimension = dimensions[index]; if (index + 1 < breaks.length) { const prevValue = breaks[index]; const currentValue = breaks[index + 1]; diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index e7d5935..dc7869b 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -107,6 +107,7 @@ const Timeline: React.FC = ({ const [calculatedInterval, setCalculatedInterval] = useState(stepMinutes); // 分钟 const [isCalculating, setIsCalculating] = useState(false); + const [sliderPreviewTime, setSliderPreviewTime] = useState(null); // 计算时间轴范围 const minTime = timeRange @@ -146,8 +147,8 @@ const Timeline: React.FC = ({ // 添加缓存引用 const nodeCacheRef = useRef>(new Map()); const linkCacheRef = useRef>(new Map()); - // 添加防抖引用 - const debounceRef = useRef(null); + const frameRequestRevisionRef = useRef(0); + const frameAbortControllerRef = useRef(null); const updateDataStates = useCallback( ( @@ -202,6 +203,7 @@ const Timeline: React.FC = ({ target, schemeName, schemeType, + signal, }: { queryTime: Date; junctionProperties: string; @@ -210,6 +212,7 @@ const Timeline: React.FC = ({ target: "primary" | "compare"; schemeName?: string; schemeType?: string; + signal?: AbortSignal; }) => { const query_time = queryTime.toISOString(); let nodeRecords: any = { results: [] }; @@ -233,10 +236,12 @@ const Timeline: React.FC = ({ nodePromise = sourceType === "scheme" && schemeName ? apiFetch( - `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}` + `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`, + { signal }, ) : apiFetch( - `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}` + `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`, + { signal }, ); requests.push(nodePromise); } @@ -260,10 +265,12 @@ const Timeline: React.FC = ({ linkPromise = sourceType === "scheme" && schemeName ? apiFetch( - `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}` + `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`, + { signal }, ) : apiFetch( - `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}` + `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`, + { signal }, ); requests.push(linkPromise); } @@ -309,9 +316,13 @@ const Timeline: React.FC = ({ ); } - updateDataStates(nodeRecords.results || [], linkRecords.results || [], target); + return { + target, + nodeResults: nodeRecords.results || [], + linkResults: linkRecords.results || [], + }; }, - [buildCacheKey, updateDataStates] + [buildCacheKey] ); const fetchFrameData = useCallback( @@ -322,6 +333,11 @@ const Timeline: React.FC = ({ schemeName: string, schemeType: string ) => { + const revision = frameRequestRevisionRef.current + 1; + frameRequestRevisionRef.current = revision; + frameAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + frameAbortControllerRef.current = abortController; const primarySourceType = disableDateSelection && schemeName ? "scheme" : "realtime"; const tasks = [ @@ -333,6 +349,7 @@ const Timeline: React.FC = ({ target: "primary", schemeName, schemeType, + signal: abortController.signal, }), ]; @@ -344,13 +361,29 @@ const Timeline: React.FC = ({ pipeProperties, sourceType: "realtime", target: "compare", + signal: abortController.signal, }) ); } - await Promise.all(tasks); + try { + const frames = await Promise.all(tasks); + if ( + abortController.signal.aborted || + revision !== frameRequestRevisionRef.current + ) { + return; + } + frames.forEach(({ nodeResults, linkResults, target }) => { + updateDataStates(nodeResults, linkResults, target); + }); + } catch (error) { + if ((error as Error).name !== "AbortError") { + console.error("Timeline frame fetch failed:", error); + } + } }, - [disableDateSelection, fetchDataBySource, isCompareMode] + [disableDateSelection, fetchDataBySource, isCompareMode, updateDataStates] ); // 格式化时间显示 @@ -427,15 +460,18 @@ const Timeline: React.FC = ({ if (timeRange && (value < minTime || value > maxTime)) { return; } - // 防抖设置currentTime,避免频繁触发数据获取 - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - debounceRef.current = setTimeout(() => { - setCurrentTime(value); - }, 500); // 500ms 防抖延迟 + setSliderPreviewTime(value); }, - [timeRange, minTime, maxTime, setCurrentTime], + [timeRange, minTime, maxTime], + ); + + const handleSliderChangeCommitted = useCallback( + (_event: Event | React.SyntheticEvent, newValue: number | number[]) => { + const value = Array.isArray(newValue) ? newValue[0] : newValue; + setSliderPreviewTime(null); + setCurrentTime(value); + }, + [setCurrentTime], ); // 播放控制 @@ -585,9 +621,7 @@ const Timeline: React.FC = ({ if (intervalRef.current) { clearInterval(intervalRef.current); } - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } + frameAbortControllerRef.current?.abort(); }; }, [durationMinutes, setCurrentTime, stepMinutes]); @@ -731,7 +765,7 @@ const Timeline: React.FC = ({
= ({ = ({ const styleEditor = useStyleEditor({ layerStyleStates, setLayerStyleStates, + workspace: project?.workspace || config.MAP_WORKSPACE, }); useToolbarChatActions({ @@ -803,10 +804,12 @@ const Toolbar: React.FC = ({ onClassificationMethodChange={styleEditor.handleClassificationMethodChange} onSegmentsChange={styleEditor.handleSegmentsChange} onCustomBreakChange={styleEditor.handleCustomBreakChange} - onCustomBreakBlur={styleEditor.handleCustomBreakBlur} onColorTypeChange={styleEditor.handleColorTypeChange} onApply={styleEditor.handleApply} onReset={styleEditor.handleReset} + validationErrors={styleEditor.validationErrors} + isDirty={styleEditor.isDirty} + isApplying={styleEditor.isApplying} />
>; + workspace: string; } export interface AvailableProperty { @@ -50,15 +68,17 @@ export interface StyleEditorFormProps { styleConfig: StyleConfig; setStyleConfig: React.Dispatch>; availableProperties: AvailableProperty[]; - onLayerChange: (index: number) => void; + onLayerChange: (layerId: string) => void; onPropertyChange: (property: string) => void; - onClassificationMethodChange: (method: string) => void; + onClassificationMethodChange: (method: ClassificationMethod) => void; onSegmentsChange: (segments: number) => void; onCustomBreakChange: (index: number, value: string) => void; - onCustomBreakBlur: () => void; - onColorTypeChange: (colorType: string) => void; + onColorTypeChange: (colorType: ColorType) => void; onApply: () => void; onReset: () => void; + validationErrors: string[]; + isDirty: boolean; + isApplying: boolean; } export interface StyleEditorPanelProps extends StyleEditorFormProps { diff --git a/src/components/olmap/core/Controls/styleEditorUtils.test.ts b/src/components/olmap/core/Controls/styleEditorUtils.test.ts new file mode 100644 index 0000000..3237e56 --- /dev/null +++ b/src/components/olmap/core/Controls/styleEditorUtils.test.ts @@ -0,0 +1,107 @@ +import { createDefaultLayerStyleState } from "./styleEditorPresets"; +import { + buildDynamicStyleTemplate, + buildStyleVariables, + getDefaultCustomBreaks, + resolveLayerStyle, + requiresStyleApply, + validateStyleConfig, +} from "./styleEditorUtils"; + +describe("styleEditorUtils", () => { + it("uses N intervals, N+1 boundaries and N visual values", () => { + const styleConfig = { + ...createDefaultLayerStyleState("pipes").styleConfig, + classificationMethod: "pretty_breaks" as const, + segments: 4, + }; + const resolved = resolveLayerStyle({ + layerType: "linestring", + styleConfig, + values: [0, 1, 2, 3, 4, 5], + }); + + expect(resolved?.boundaries).toHaveLength(5); + expect(resolved?.colors).toHaveLength(4); + expect(resolved?.dimensions).toHaveLength(4); + expect(resolved?.labels).toHaveLength(4); + }); + + it("accepts signed custom boundaries and rejects unordered boundaries", () => { + const base = createDefaultLayerStyleState("junctions").styleConfig; + const valid = { + ...base, + segments: 3, + customBreaks: [-5, 0, 5, 10], + customColors: base.customColors?.slice(0, 3), + }; + expect(validateStyleConfig(valid)).toEqual({ valid: true, errors: [] }); + expect( + validateStyleConfig({ ...valid, customBreaks: [-5, 0, 0, 10] }).valid, + ).toBe(false); + }); + + it("creates deterministic defaults and collapses constant data labels", () => { + const defaults = getDefaultCustomBreaks({ + segments: 3, + property: "pressure", + layerId: "junctions", + currentJunctionCalData: [{ value: 20 }, { value: 20 }], + }); + expect(defaults).toHaveLength(4); + expect( + defaults.every( + (value, index) => index === 0 || value > defaults[index - 1], + ), + ).toBe(true); + + const styleConfig = { + ...createDefaultLayerStyleState("junctions").styleConfig, + classificationMethod: "pretty_breaks" as const, + segments: 3, + }; + const resolved = resolveLayerStyle({ + layerType: "point", + styleConfig, + values: [20, 20], + }); + expect(resolved?.isConstant).toBe(true); + expect(resolved?.labels).toEqual(["20"]); + }); + + it("generates variable-only visual templates with shader-safe names", () => { + const styleConfig = createDefaultLayerStyleState("pipes").styleConfig; + const resolved = resolveLayerStyle({ + layerType: "linestring", + styleConfig, + values: [], + }); + expect(resolved).not.toBeNull(); + const template = buildDynamicStyleTemplate({ + layerType: "linestring", + property: styleConfig.property, + classCount: styleConfig.segments, + }); + const variables = buildStyleVariables(styleConfig, resolved!); + const serialized = JSON.stringify({ template, variables }); + expect(serialized).toContain("tj_color_0"); + expect(serialized).not.toContain("__"); + }); + + it("requires Apply only for structural changes", () => { + const applied = createDefaultLayerStyleState("pipes").styleConfig; + expect(requiresStyleApply(applied, { ...applied, opacity: 0.4 })).toBe(false); + expect( + requiresStyleApply(applied, { ...applied, minStrokeWidth: 1 }), + ).toBe(false); + expect( + requiresStyleApply(applied, { ...applied, property: "flow" }), + ).toBe(true); + expect( + requiresStyleApply(applied, { + ...applied, + customBreaks: [...(applied.customBreaks || []), 2], + }), + ).toBe(true); + }); +}); diff --git a/src/components/olmap/core/Controls/styleEditorUtils.ts b/src/components/olmap/core/Controls/styleEditorUtils.ts index 0baf15b..6f59b49 100644 --- a/src/components/olmap/core/Controls/styleEditorUtils.ts +++ b/src/components/olmap/core/Controls/styleEditorUtils.ts @@ -1,4 +1,4 @@ -import { FlatStyleLike } from "ol/style/flat"; +import type { FlatStyleLike, StyleVariables } from "ol/style/flat"; import { calculateClassification } from "@utils/breaksClassification"; import { parseColor } from "@utils/parseColor"; @@ -8,16 +8,34 @@ import { RAINBOW_PALETTES, SINGLE_COLOR_PALETTES, } from "./styleEditorPresets"; -import { StyleConfig } from "./styleEditorTypes"; +import type { + ResolvedLayerStyle, + StyleConfig, + StyleValidationResult, +} from "./styleEditorTypes"; + +export const MIN_CLASS_COUNT = 2; +export const MAX_CLASS_COUNT = 10; + +const clampIndex = (value: number, length: number) => + Math.min(Math.max(Math.round(value) || 0, 0), Math.max(length - 1, 0)); + +const arraysEqual = (left: readonly T[] = [], right: readonly T[] = []) => + left.length === right.length && left.every((value, index) => value === right[index]); + +const withOpacity = (color: string, opacity: number) => { + const parsed = parseColor(color); + return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${opacity})`; +}; + +const formatBoundary = (value: number) => + Number.isInteger(value) ? String(value) : Number(value.toFixed(3)).toString(); export const rgbaToHex = (rgba: string) => { try { - const c = parseColor(rgba); - const toHex = (n: number) => { - const hex = Math.round(n).toString(16); - return hex.length === 1 ? `0${hex}` : hex; - }; - return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`; + const color = parseColor(rgba); + const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0"); + return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`; } catch { return "#000000"; } @@ -28,25 +46,71 @@ export const hexToRgba = (hex: string) => { return result ? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt( result[3], - 16 + 16, )}, 1)` : "rgba(0, 0, 0, 1)"; }; export const getDefaultCustomColors = ( segments: number, - existingColors: string[] = [] + existingColors: string[] = [], ) => { const nextColors = [...existingColors]; const baseColors = RAINBOW_PALETTES[0].colors; - while (nextColors.length < segments) { nextColors.push(baseColors[nextColors.length % baseColors.length]); } - return nextColors.slice(0, segments); }; +const createEqualBoundaries = (minimum: number, maximum: number, segments: number) => { + if (minimum === maximum) { + return Array.from({ length: segments + 1 }, () => minimum); + } + return Array.from( + { length: segments + 1 }, + (_, index) => minimum + ((maximum - minimum) * index) / segments, + ); +}; + +const normalizeCalculatedBoundaries = ( + calculated: number[], + values: number[], + segments: number, +) => { + const finiteValues = values.filter(Number.isFinite).sort((a, b) => a - b); + if (finiteValues.length === 0) return []; + const minimum = finiteValues[0]; + const maximum = finiteValues[finiteValues.length - 1]; + if (minimum === maximum) return createEqualBoundaries(minimum, maximum, segments); + + const candidates = Array.from( + new Set([minimum, ...calculated.filter(Number.isFinite), maximum]), + ) + .filter((value) => value >= minimum && value <= maximum) + .sort((a, b) => a - b); + + if (candidates.length === segments + 1) return candidates; + return createEqualBoundaries(minimum, maximum, segments); +}; + +export const resolveBoundaries = ( + values: number[], + styleConfig: StyleConfig, +): number[] => { + const segments = Math.min( + MAX_CLASS_COUNT, + Math.max(MIN_CLASS_COUNT, Math.round(styleConfig.segments)), + ); + if (styleConfig.classificationMethod === "custom_breaks") { + return [...(styleConfig.customBreaks || [])]; + } + const finiteValues = values.filter(Number.isFinite); + if (finiteValues.length === 0) return []; + const calculated = calculateClassification(finiteValues, segments, "pretty_breaks"); + return normalizeCalculatedBoundaries(calculated, finiteValues, segments); +}; + export const getDefaultCustomBreaks = ({ segments, property, @@ -64,261 +128,255 @@ export const getDefaultCustomBreaks = ({ currentJunctionCalData?: any[]; currentPipeCalData?: any[]; }) => { - if (!layerId || !property) { - return Array.from({ length: segments }, () => 0); + let values: number[] = []; + if (layerId === "junctions" && property === "elevation" && elevationRange) { + values = elevationRange; + } else if (layerId === "pipes" && property === "diameter" && diameterRange) { + values = diameterRange; + } else if (layerId === "junctions") { + values = (currentJunctionCalData || []).map((item: any) => Number(item.value)); + } else if (layerId === "pipes") { + values = (currentPipeCalData || []).map((item: any) => Number(item.value)); } - - let dataArr: number[] = []; - - const isElevation = layerId === "junctions" && property === "elevation"; - const isDiameter = layerId === "pipes" && property === "diameter"; - - if (isElevation && elevationRange) { - dataArr = [elevationRange[0], elevationRange[1]]; - } else if (isDiameter && diameterRange) { - dataArr = [diameterRange[0], diameterRange[1]]; - } else if (layerId === "junctions" && currentJunctionCalData) { - dataArr = currentJunctionCalData.map((d: any) => d.value); - } else if (layerId === "pipes" && currentPipeCalData) { - dataArr = currentPipeCalData.map((d: any) => d.value); + const finiteValues = values.filter(Number.isFinite); + if (!property || finiteValues.length === 0) { + return Array.from({ length: segments + 1 }, (_, index) => index); } - - if (dataArr.length === 0) { - return Array.from({ length: segments }, () => 0); + const minimum = Math.min(...finiteValues); + const maximum = Math.max(...finiteValues); + if (minimum === maximum) { + const padding = Math.max(Math.abs(minimum) * 0.01, 1); + return createEqualBoundaries(minimum - padding, maximum + padding, segments); } - - const defaultBreaks = calculateClassification( - dataArr, + return normalizeCalculatedBoundaries( + calculateClassification(finiteValues, segments, "pretty_breaks"), + finiteValues, segments, - "pretty_breaks" - ).slice(0, segments); - - while (defaultBreaks.length < segments) { - defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0); - } - - return defaultBreaks; -}; - -export const normalizeCustomBreaks = (breaks: number[], desired: number) => { - const nextBreaks = [...breaks] - .slice(0, desired) - .filter((value) => value >= 0) - .sort((a, b) => a - b); - - while (nextBreaks.length < desired) { - nextBreaks.push(nextBreaks[nextBreaks.length - 1] ?? 0); - } - - return nextBreaks; -}; - -export const addBreakExtrema = (breaks: number[], dataValues: number[]) => { - const nextBreaks = [...breaks]; - const minValue = Math.max( - dataValues.reduce((min, value) => Math.min(min, value), Infinity), - 0 ); - const maxValue = dataValues.reduce( - (max, value) => Math.max(max, value), - -Infinity - ); - - if (!nextBreaks.includes(minValue)) { - nextBreaks.push(minValue); - } - - if (!nextBreaks.includes(maxValue)) { - nextBreaks.push(maxValue); - } - - nextBreaks.sort((a, b) => a - b); - return nextBreaks; }; +export const normalizeCustomBreaks = (breaks: number[], segments: number) => { + const finite = breaks.filter(Number.isFinite).slice(0, segments + 1); + if (finite.length === segments + 1) return finite; + if (finite.length >= 2) { + return createEqualBoundaries(finite[0], finite[finite.length - 1], segments); + } + return Array.from({ length: segments + 1 }, (_, index) => finite[0] ?? index); +}; + +export const validateStyleConfig = (styleConfig: StyleConfig): StyleValidationResult => { + const errors: string[] = []; + if (!styleConfig.property) errors.push("请选择分级属性"); + if ( + !Number.isInteger(styleConfig.segments) || + styleConfig.segments < MIN_CLASS_COUNT || + styleConfig.segments > MAX_CLASS_COUNT + ) { + errors.push(`分类数量必须是 ${MIN_CLASS_COUNT}-${MAX_CLASS_COUNT} 的整数`); + } + if (styleConfig.classificationMethod === "custom_breaks") { + const boundaries = styleConfig.customBreaks || []; + if (boundaries.length !== styleConfig.segments + 1) { + errors.push(`需要 ${styleConfig.segments + 1} 个区间边界`); + } else if (boundaries.some((value) => !Number.isFinite(value))) { + errors.push("区间边界必须是有限数字"); + } else if (boundaries.some((value, index) => index > 0 && value <= boundaries[index - 1])) { + errors.push("区间边界必须严格递增"); + } + } + if (styleConfig.colorType === "custom") { + const colors = styleConfig.customColors || []; + if (colors.length !== styleConfig.segments) { + errors.push(`需要 ${styleConfig.segments} 个自定义颜色`); + } else if (colors.some((color) => { + try { + parseColor(color); + return false; + } catch { + return true; + } + })) { + errors.push("自定义颜色格式无效"); + } + } + if (!Number.isFinite(styleConfig.opacity) || styleConfig.opacity < 0 || styleConfig.opacity > 1) { + errors.push("透明度必须在 0 到 1 之间"); + } + const paletteIndexes: Array<[number, number]> = [ + [styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length], + [styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length], + [styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length], + ]; + if ( + paletteIndexes.some( + ([index, length]) => !Number.isInteger(index) || index < 0 || index >= length, + ) + ) { + errors.push("色板索引无效"); + } + if ( + [ + styleConfig.minSize, + styleConfig.maxSize, + styleConfig.minStrokeWidth, + styleConfig.maxStrokeWidth, + styleConfig.fixedStrokeWidth, + ].some((value) => !Number.isFinite(value) || value <= 0) + ) { + errors.push("符号尺寸必须大于 0"); + } + if (styleConfig.minSize > styleConfig.maxSize) errors.push("节点最小尺寸不能大于最大尺寸"); + if (styleConfig.minStrokeWidth > styleConfig.maxStrokeWidth) { + errors.push("管线最小宽度不能大于最大宽度"); + } + return { valid: errors.length === 0, errors }; +}; + +export const requiresStyleApply = ( + applied: StyleConfig | undefined, + draft: StyleConfig, +) => + !applied || + applied.property !== draft.property || + applied.classificationMethod !== draft.classificationMethod || + applied.segments !== draft.segments || + (draft.classificationMethod === "custom_breaks" && + !arraysEqual(applied.customBreaks, draft.customBreaks)); + export const resolveStyleColors = ( styleConfig: StyleConfig, - breaksLength: number + classCount = styleConfig.segments, ): string[] => { if (styleConfig.colorType === "single") { - return Array.from( - { length: breaksLength }, - () => SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color - ); + const palette = SINGLE_COLOR_PALETTES[ + clampIndex(styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length) + ]; + return Array.from({ length: classCount }, () => palette.color); } - if (styleConfig.colorType === "gradient") { - const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex]; - const startColor = parseColor(start); - const endColor = parseColor(end); - - return Array.from({ length: breaksLength }, (_, index) => { - const ratio = breaksLength > 1 ? index / (breaksLength - 1) : 1; - const r = Math.round(startColor.r + (endColor.r - startColor.r) * ratio); - const g = Math.round(startColor.g + (endColor.g - startColor.g) * ratio); - const b = Math.round(startColor.b + (endColor.b - startColor.b) * ratio); - return `rgba(${r}, ${g}, ${b}, 1)`; + const palette = GRADIENT_PALETTES[ + clampIndex(styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length) + ]; + const start = parseColor(palette.start); + const end = parseColor(palette.end); + return Array.from({ length: classCount }, (_, index) => { + const ratio = classCount > 1 ? index / (classCount - 1) : 0; + return `rgba(${Math.round(start.r + (end.r - start.r) * ratio)}, ${Math.round( + start.g + (end.g - start.g) * ratio, + )}, ${Math.round(start.b + (end.b - start.b) * ratio)}, 1)`; }); } - if (styleConfig.colorType === "rainbow") { - const baseColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors; + const palette = RAINBOW_PALETTES[ + clampIndex(styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length) + ]; return Array.from( - { length: breaksLength }, - (_, index) => baseColors[index % baseColors.length] + { length: classCount }, + (_, index) => palette.colors[index % palette.colors.length], ); } - - const customColors = styleConfig.customColors || []; - const reverseRainbowColors = RAINBOW_PALETTES[1].colors; - const result = [...customColors]; - - while (result.length < breaksLength) { - result.push( - reverseRainbowColors[ - (result.length - customColors.length) % reverseRainbowColors.length - ] - ); - } - - return result.slice(0, breaksLength); -}; - -export const getSizePreviewColors = (styleConfig: StyleConfig) => { - if (styleConfig.colorType === "single") { - const color = SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color; - return [color, color]; - } - - if (styleConfig.colorType === "gradient") { - const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex]; - return [start, end]; - } - - if (styleConfig.colorType === "rainbow") { - const rainbowColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors; - return [rainbowColors[0], rainbowColors[rainbowColors.length - 1]]; - } - - const customColors = styleConfig.customColors || []; - return [ - customColors[0] || "rgba(0,0,0,1)", - customColors[customColors.length - 1] || "rgba(0,0,0,1)", - ]; + return getDefaultCustomColors(classCount, styleConfig.customColors || []); }; export const resolveDimensions = ({ layerType, styleConfig, - breaksLength, + classCount = styleConfig.segments, }: { layerType: string; styleConfig: StyleConfig; - breaksLength: number; + classCount?: number; }) => { + const interpolate = (minimum: number, maximum: number, index: number) => { + const ratio = classCount > 1 ? index / (classCount - 1) : 0; + return minimum + (maximum - minimum) * ratio; + }; if (layerType === "linestring") { - if (styleConfig.adjustWidthByProperty) { - return Array.from({ length: breaksLength }, (_, index) => { - const ratio = index / (breaksLength - 1); - return ( - styleConfig.minStrokeWidth + - (styleConfig.maxStrokeWidth - styleConfig.minStrokeWidth) * ratio - ); - }); - } - - return Array.from( - { length: breaksLength }, - () => styleConfig.fixedStrokeWidth + return Array.from({ length: classCount }, (_, index) => + styleConfig.adjustWidthByProperty + ? interpolate(styleConfig.minStrokeWidth, styleConfig.maxStrokeWidth, index) + : styleConfig.fixedStrokeWidth, ); } - - return Array.from({ length: breaksLength }, (_, index) => { - const ratio = index / (breaksLength - 1); - return styleConfig.minSize + (styleConfig.maxSize - styleConfig.minSize) * ratio; - }); + return Array.from({ length: classCount }, (_, index) => + interpolate(styleConfig.minSize, styleConfig.maxSize, index), + ); }; -export const buildDynamicStyle = ({ +export const resolveLayerStyle = ({ layerType, styleConfig, - breaks, - colors, - dimensions, + values, }: { layerType: string; styleConfig: StyleConfig; - breaks: number[]; - colors: string[]; - dimensions: number[]; -}): FlatStyleLike => { - const generateColorConditions = (property: string): any[] => { - const conditions: any[] = ["case"]; - for (let index = 1; index < breaks.length; index++) { - if (property === "unit_headloss") { - conditions.push([ - "<=", - ["/", ["get", "unit_headloss"], ["/", ["get", "length"], 1000]], - breaks[index], - ]); - } else { - conditions.push(["<=", ["get", property], breaks[index]]); - } - const colorObj = parseColor(colors[index - 1]); - conditions.push( - `rgba(${colorObj.r}, ${colorObj.g}, ${colorObj.b}, ${styleConfig.opacity})` + values: number[]; +}): ResolvedLayerStyle | null => { + const boundaries = resolveBoundaries(values, styleConfig); + if (boundaries.length !== styleConfig.segments + 1) return null; + const colors = resolveStyleColors(styleConfig, styleConfig.segments); + const dimensions = resolveDimensions({ layerType, styleConfig }); + const isConstant = boundaries.every((value) => value === boundaries[0]); + const labels = isConstant + ? [`${formatBoundary(boundaries[0])}`] + : Array.from( + { length: styleConfig.segments }, + (_, index) => `${formatBoundary(boundaries[index])} - ${formatBoundary(boundaries[index + 1])}`, ); - } - const defaultColor = parseColor(colors[0]); - conditions.push( - `rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${styleConfig.opacity})` + return { boundaries, colors, dimensions, labels, isConstant }; +}; + +const valueExpression = (property: string): any[] => ["get", property]; + +const buildVariableCase = (property: string, classCount: number, variablePrefix: string) => { + const expression: any[] = ["case"]; + for (let index = 0; index < classCount - 1; index += 1) { + expression.push( + ["<=", valueExpression(property), ["var", `tj_break_${index + 1}`]], + ["var", `${variablePrefix}_${index}`], ); - return conditions; - }; - - const generateDimensionConditions = (property: string): any[] => { - const conditions: any[] = ["case"]; - for (let index = 0; index < breaks.length; index++) { - if (property === "unit_headloss") { - conditions.push([ - "<=", - ["/", ["get", "headloss"], ["get", "length"]], - breaks[index], - ]); - } else { - conditions.push(["<=", ["get", property], breaks[index]]); - } - conditions.push(dimensions[index]); - } - conditions.push(dimensions[dimensions.length - 1]); - return conditions; - }; - - const generatePointDimensionConditions = (property: string): any[] => { - const conditions: any[] = ["case"]; - for (let index = 0; index < breaks.length; index++) { - conditions.push(["<=", ["get", property], breaks[index]]); - conditions.push(["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimensions[index]]); - } - conditions.push(dimensions[dimensions.length - 1]); - return conditions; - }; - - const dynamicStyle: FlatStyleLike = {}; - - if (layerType === "linestring") { - dynamicStyle["stroke-color"] = generateColorConditions(styleConfig.property); - dynamicStyle["stroke-width"] = generateDimensionConditions(styleConfig.property); - } else if (layerType === "point") { - dynamicStyle["circle-fill-color"] = generateColorConditions(styleConfig.property); - dynamicStyle["circle-radius"] = generatePointDimensionConditions( - styleConfig.property - ); - dynamicStyle["circle-stroke-color"] = generateColorConditions(styleConfig.property); - dynamicStyle["circle-stroke-width"] = 2; } + expression.push(["var", `${variablePrefix}_${classCount - 1}`]); + return expression; +}; - return dynamicStyle; +export const buildDynamicStyleTemplate = ({ + layerType, + property, + classCount, +}: { + layerType: string; + property: string; + classCount: number; +}): FlatStyleLike => { + const color = buildVariableCase(property, classCount, "tj_color"); + const dimension = buildVariableCase(property, classCount, "tj_size"); + if (layerType === "linestring") { + return { "stroke-color": color, "stroke-width": dimension }; + } + return { + "circle-fill-color": color, + "circle-radius": ["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimension], + "circle-stroke-color": color, + "circle-stroke-width": 2, + }; +}; + +export const buildStyleVariables = ( + styleConfig: StyleConfig, + resolvedStyle: ResolvedLayerStyle, +): StyleVariables => { + const variables: StyleVariables = {}; + resolvedStyle.boundaries.slice(1, -1).forEach((boundary, index) => { + variables[`tj_break_${index + 1}`] = boundary; + }); + resolvedStyle.colors.forEach((color, index) => { + variables[`tj_color_${index}`] = withOpacity(color, styleConfig.opacity); + }); + resolvedStyle.dimensions.forEach((dimension, index) => { + variables[`tj_size_${index}`] = dimension; + }); + return variables; }; export const buildContourDefinitions = ({ @@ -329,20 +387,12 @@ export const buildContourDefinitions = ({ styleConfig: StyleConfig; breaks: number[]; colors: string[]; -}) => { - const contours = []; - for (let index = 0; index < breaks.length - 1; index++) { - const colorObj = parseColor(colors[index]); - contours.push({ +}) => + colors.map((color, index) => { + const parsed = parseColor(color); + return { threshold: [breaks[index], breaks[index + 1]], - color: [ - colorObj.r, - colorObj.g, - colorObj.b, - Math.round(styleConfig.opacity * 255), - ], + color: [parsed.r, parsed.g, parsed.b, Math.round(styleConfig.opacity * 255)], strokeWidth: 0, - }); - } - return contours; -}; + }; + }); diff --git a/src/components/olmap/core/Controls/useStyleEditor.ts b/src/components/olmap/core/Controls/useStyleEditor.ts index 65c4506..c708ce3 100644 --- a/src/components/olmap/core/Controls/useStyleEditor.ts +++ b/src/components/olmap/core/Controls/useStyleEditor.ts @@ -1,184 +1,245 @@ import { useNotification } from "@refinedev/core"; -import { VectorTile } from "ol"; import type { Map as OlMap } from "ol"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; -import VectorTileSource from "ol/source/VectorTile"; +import type { FlatStyleLike } from "ol/style/flat"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { FlatStyleLike } from "ol/style/flat"; import { config } from "@/config/config"; +import { isLpsFlowProperty, toM3h } from "@utils/units"; +import { LayerStyleController } from "../layerStyleController"; import { useData, useMap } from "../MapComponent"; import { createDefaultLayerStyleState, + createDefaultLayerStyleStates, createEmptyStyleConfig, } from "./styleEditorPresets"; import { - addBreakExtrema, buildContourDefinitions, - buildDynamicStyle, + buildDynamicStyleTemplate, + buildStyleVariables, getDefaultCustomBreaks, getDefaultCustomColors, normalizeCustomBreaks, + requiresStyleApply, resolveDimensions, + resolveLayerStyle, resolveStyleColors, + validateStyleConfig, } from "./styleEditorUtils"; -import { +import type { AvailableProperty, + ClassificationMethod, + ColorType, DefaultLayerStyleId, LayerStyleState, + ResolvedLayerStyle, StyleConfig, StyleEditorStateProps, } from "./styleEditorTypes"; -import { LegendStyleConfig } from "./StyleLegend"; -import { calculateClassification } from "@utils/breaksClassification"; -import { isLpsFlowProperty, toM3h } from "@utils/units"; +import type { LegendStyleConfig } from "./StyleLegend"; const UNIT_HEADLOSS_RANGE: [number, number] = [0, 5]; +const STYLE_STORAGE_VERSION = 2; + +const cloneStyleConfig = (styleConfig: StyleConfig): StyleConfig => ({ + ...styleConfig, + customBreaks: [...(styleConfig.customBreaks || [])], + customColors: [...(styleConfig.customColors || [])], +}); + +const configsEqual = (left?: StyleConfig, right?: StyleConfig) => + Boolean(left && right && JSON.stringify(left) === JSON.stringify(right)); + +const hasSameTemplate = (left: StyleConfig, right: StyleConfig) => + left.property === right.property && left.segments === right.segments; const normalizeComputedStyleValue = (property: string, value: unknown) => { const numericValue = Number(value); - if (!Number.isFinite(numericValue)) { - return Number.NaN; - } - + if (!Number.isFinite(numericValue)) return Number.NaN; const displayValue = isLpsFlowProperty(property) ? toM3h(numericValue, "lps") : numericValue; - return property === "flow" ? Math.abs(displayValue) : displayValue; }; +const isDefaultLayerId = (value: unknown): value is DefaultLayerStyleId => + value === "junctions" || value === "pipes"; + +const createLegendConfig = ({ + layerId, + layerName, + property, + layerType, + resolved, +}: { + layerId: DefaultLayerStyleId; + layerName: string; + property: string; + layerType: string; + resolved: ResolvedLayerStyle; +}): LegendStyleConfig => ({ + layerId, + layerName, + property, + colors: resolved.isConstant ? resolved.colors.slice(0, 1) : resolved.colors, + type: layerType, + dimensions: resolved.isConstant + ? resolved.dimensions.slice(0, 1) + : resolved.dimensions, + breaks: resolved.boundaries, + labels: resolved.labels, +}); + +const resolveStyleFromLegend = ( + styleConfig: StyleConfig, + legendConfig: LegendStyleConfig, +): ResolvedLayerStyle | null => { + if (legendConfig.breaks.length !== styleConfig.segments + 1) return null; + return { + boundaries: legendConfig.breaks, + colors: resolveStyleColors(styleConfig), + dimensions: resolveDimensions({ layerType: legendConfig.type, styleConfig }), + labels: legendConfig.labels || [], + isConstant: legendConfig.breaks.every( + (value) => value === legendConfig.breaks[0], + ), + }; +}; + +const createRenderInputs = ( + styleConfig: StyleConfig, + resolved: ResolvedLayerStyle, +) => { + const variables = buildStyleVariables(styleConfig, resolved); + return { + variables, + signature: JSON.stringify({ styleConfig, variables }), + }; +}; + export const useStyleEditor = ({ layerStyleStates, setLayerStyleStates, + workspace, }: StyleEditorStateProps) => { const map = useMap(); const data = useData(); const { open } = useNotification(); - + const activeMaps = useMemo( + () => (data?.maps?.length ? data.maps : map ? [map] : []), + [data, map], + ); + const compareMap = data?.compareMap; const currentJunctionCalData = data?.currentJunctionCalData; const currentPipeCalData = data?.currentPipeCalData; const compareJunctionCalData = data?.compareJunctionCalData; const comparePipeCalData = data?.comparePipeCalData; - const compareMap = data?.compareMap; - const activeMaps = useMemo( - () => (data?.maps?.length ? data.maps : map ? [map] : []), - [data?.maps, map] - ); - const junctionText = data?.junctionText ?? ""; - const pipeText = data?.pipeText ?? ""; + const elevationRange = data?.elevationRange; + const diameterRange = data?.diameterRange; + const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0; + const setJunctionText = data?.setJunctionText; + const setPipeText = data?.setPipeText; const setShowJunctionTextLayer = data?.setShowJunctionTextLayer; const setShowPipeTextLayer = data?.setShowPipeTextLayer; const setShowJunctionId = data?.setShowJunctionId; const setShowPipeId = data?.setShowPipeId; const setContourLayerAvailable = data?.setContourLayerAvailable; + const setContours = data?.setContours; const setWaterflowLayerAvailable = data?.setWaterflowLayerAvailable; const setShowWaterflowLayer = data?.setShowWaterflowLayer; - const setJunctionText = data?.setJunctionText; - const setPipeText = data?.setPipeText; - const setContours = data?.setContours; - const diameterRange = data?.diameterRange; - const elevationRange = data?.elevationRange; - const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0; - const [applyJunctionStyle, setApplyJunctionStyle] = useState(false); - const [applyPipeStyle, setApplyPipeStyle] = useState(false); - const [styleUpdateTrigger, setStyleUpdateTrigger] = useState(0); - const prevStyleUpdateTriggerRef = useRef(0); - const lastForceStyleAutoApplyVersionRef = useRef(0); - - const [renderLayers, setRenderLayers] = useState([]); - const [selectedRenderLayer, setSelectedRenderLayer] = + const renderLayers = useMemo( + () => + (map?.getAllLayers() || []).filter( + (layer): layer is WebGLVectorTileLayer => + layer instanceof WebGLVectorTileLayer && + (layer.get("value") === "junctions" || layer.get("value") === "pipes"), + ), + [map], + ); + const [selectedRenderLayerState, setSelectedRenderLayer] = useState(); - const [styleConfig, setStyleConfig] = useState(createEmptyStyleConfig); + const selectedRenderLayer = + renderLayers.find( + (layer) => layer.get("value") === selectedRenderLayerState?.get("value"), + ) || renderLayers.find((layer) => layer.get("value") === "junctions") || renderLayers[0]; + const [styleConfig, setStyleConfig] = useState(() => + createDefaultLayerStyleState("junctions").styleConfig, + ); + const [isApplying, setIsApplying] = useState(false); + const [persistenceReady, setPersistenceReady] = useState(false); const latestLayerStyleStatesRef = useRef(layerStyleStates); - - const tileLoadListenersRef = useRef< - Map void }> - >(new Map()); + const controllersRef = useRef(new Map()); + const renderInputsRef = useRef( + new Map< + string, + { records: readonly any[]; signature: string } + >(), + ); + const draftsRef = useRef(new Map()); + const renderRevisionRef = useRef(new Map()); + const lastForceStyleAutoApplyVersionRef = useRef(0); useEffect(() => { latestLayerStyleStatesRef.current = layerStyleStates; }, [layerStyleStates]); - const upsertLayerStyleState = useCallback( - (newStyleState: LayerStyleState) => { - const existingState = latestLayerStyleStatesRef.current.find( - (state) => state.layerId === newStyleState.layerId - ); - if ( - existingState && - JSON.stringify(existingState.styleConfig) === - JSON.stringify(newStyleState.styleConfig) && - JSON.stringify(existingState.legendConfig) === - JSON.stringify(newStyleState.legendConfig) && - existingState.layerName === newStyleState.layerName && - existingState.isActive === newStyleState.isActive - ) { - return; - } - - setLayerStyleStates((prev) => { - const existingIndex = prev.findIndex( - (state) => state.layerId === newStyleState.layerId - ); - const nextStates = - existingIndex === -1 - ? [...prev, newStyleState] - : prev.map((state, index) => - index === existingIndex ? newStyleState : state - ); - latestLayerStyleStatesRef.current = nextStates; - return nextStates; - }); - }, - [setLayerStyleStates] - ); - - const removeLayerStyleState = useCallback( - (layerId: string) => { - setLayerStyleStates((prev) => { - const nextStates = prev.filter((state) => state.layerId !== layerId); - latestLayerStyleStatesRef.current = nextStates; - return nextStates; - }); - }, - [setLayerStyleStates] - ); - - const getRenderLayersById = useCallback( - (layerId: string) => - activeMaps.flatMap((targetMap) => - targetMap - .getAllLayers() - .filter((layer) => layer.get("value") === layerId) - .filter( - (layer): layer is WebGLVectorTileLayer => - layer instanceof WebGLVectorTileLayer - ) - ), - [activeMaps] - ); - const getMapKey = useCallback((targetMap: OlMap, layerId: string) => { const mapUid = (targetMap as unknown as { ol_uid?: string }).ol_uid || "map"; return `${mapUid}:${layerId}`; }, []); - const getDataForMap = useCallback( + const getRenderLayersById = useCallback( + (layerId: string) => + activeMaps.flatMap((targetMap) => + targetMap + .getAllLayers() + .filter( + (layer): layer is WebGLVectorTileLayer => + layer instanceof WebGLVectorTileLayer && layer.get("value") === layerId, + ), + ), + [activeMaps], + ); + + const getLayerForMap = useCallback((targetMap: OlMap, layerId: string) => + targetMap + .getAllLayers() + .find( + (layer): layer is WebGLVectorTileLayer => + layer instanceof WebGLVectorTileLayer && layer.get("value") === layerId, + ), []); + + const getController = useCallback( (targetMap: OlMap, layerId: string) => { + const key = getMapKey(targetMap, layerId); + let controller = controllersRef.current.get(key); + if (controller) return controller; + const layer = getLayerForMap(targetMap, layerId); + if (!layer) return null; + controller = new LayerStyleController({ + layer, + map: targetMap, + defaultStyle: config.MAP_DEFAULT_STYLE as FlatStyleLike, + stateNamespace: targetMap === compareMap ? "compare" : "primary", + }); + controllersRef.current.set(key, controller); + return controller; + }, + [compareMap, getLayerForMap, getMapKey], + ); + + const getDataForMap = useCallback( + (targetMap: OlMap, layerId: DefaultLayerStyleId) => { if (layerId === "junctions") { return targetMap === compareMap ? compareJunctionCalData || [] : currentJunctionCalData || []; } - if (layerId === "pipes") { - return targetMap === compareMap - ? comparePipeCalData || [] - : currentPipeCalData || []; - } - return []; + return targetMap === compareMap + ? comparePipeCalData || [] + : currentPipeCalData || []; }, [ compareJunctionCalData, @@ -186,18 +247,36 @@ export const useStyleEditor = ({ comparePipeCalData, currentJunctionCalData, currentPipeCalData, - ] + ], ); - const availableProperties = useMemo(() => { - if (!selectedRenderLayer) { - return []; - } + const availableProperties = useMemo( + () => (selectedRenderLayer?.get("properties") || []) as AvailableProperty[], + [selectedRenderLayer], + ); - return (selectedRenderLayer.get("properties") || []) as AvailableProperty[]; - }, [selectedRenderLayer]); + const upsertLayerStyleState = useCallback( + (nextState: LayerStyleState) => { + setLayerStyleStates((previous) => { + const index = previous.findIndex((state) => state.layerId === nextState.layerId); + if (index >= 0) { + const current = previous[index]; + if (JSON.stringify(current) === JSON.stringify(nextState)) return previous; + } + const next = + index < 0 + ? [...previous, nextState] + : previous.map((state, stateIndex) => + stateIndex === index ? nextState : state, + ); + latestLayerStyleStatesRef.current = next; + return next; + }); + }, + [setLayerStyleStates], + ); - const getBreakDefaults = useCallback( + const getDefaultBreaks = useCallback( (segments: number, property: string, layer = selectedRenderLayer) => getDefaultCustomBreaks({ segments, @@ -214,644 +293,47 @@ export const useStyleEditor = ({ diameterRange, elevationRange, selectedRenderLayer, - ] + ], ); - const saveLayerStyle = useCallback( - ( - layerId?: string, - newLegendConfig?: LegendStyleConfig, - overrideStyleConfig = styleConfig - ) => { - if (!overrideStyleConfig.property || !layerId) { - return; + const getClassificationValues = useCallback( + (layerId: DefaultLayerStyleId, property: string) => { + if (layerId === "junctions" && property === "elevation" && elevationRange) { + return [...elevationRange]; } - - const layerName = - newLegendConfig?.layerName || - selectedRenderLayer?.get("name") || - `图层${layerId}`; - const property = availableProperties.find( - (item) => item.value === overrideStyleConfig.property - ); - - const legendConfig: LegendStyleConfig = newLegendConfig || { - layerId, - layerName, - property: property?.name || overrideStyleConfig.property, - colors: [], - type: selectedRenderLayer?.get("type") || "point", - dimensions: [], - breaks: [], - }; - - const newStyleState: LayerStyleState = { - layerId, - layerName, - styleConfig: { ...overrideStyleConfig }, - legendConfig: { ...legendConfig }, - isActive: true, - }; - - upsertLayerStyleState(newStyleState); + if (layerId === "pipes" && property === "diameter" && diameterRange) { + return [...diameterRange]; + } + if (layerId === "pipes" && property === "unit_headloss") { + return [...UNIT_HEADLOSS_RANGE]; + } + const records = layerId === "junctions" ? currentJunctionCalData : currentPipeCalData; + return (records || []) + .map((item: any) => normalizeComputedStyleValue(property, item.value)) + .filter(Number.isFinite); }, - [availableProperties, selectedRenderLayer, styleConfig, upsertLayerStyleState] + [currentJunctionCalData, currentPipeCalData, diameterRange, elevationRange], ); - const applyContourLayerStyle = useCallback( - (layerStyleConfig: LayerStyleState, breaks?: number[]) => { - if (!breaks || breaks.length === 0 || !setContours) { - return; - } - - const colors = resolveStyleColors(layerStyleConfig.styleConfig, breaks.length); - setContours( - buildContourDefinitions({ - styleConfig: layerStyleConfig.styleConfig, - breaks, - colors, - }) - ); - }, - [setContours] - ); - - const applyLayerStyle = useCallback( - (layerStyleConfig: LayerStyleState, breaks?: number[]) => { - if (!breaks || breaks.length === 0) { - return; - } - - const nextStyleConfig = layerStyleConfig.styleConfig; - const targetLayers = getRenderLayersById(layerStyleConfig.layerId); - const renderLayer = targetLayers[0]; - if (!renderLayer || !nextStyleConfig.property) { - return; - } - - const layerType = renderLayer.get("type") as string; - const colors = resolveStyleColors(nextStyleConfig, breaks.length); - const dimensions = resolveDimensions({ - layerType, - styleConfig: nextStyleConfig, - breaksLength: breaks.length, - }); - const dynamicStyle = buildDynamicStyle({ - layerType, - styleConfig: nextStyleConfig, - breaks, - colors, - dimensions, - }); - - targetLayers.forEach((targetLayer) => { - targetLayer.setStyle(dynamicStyle); - }); - - const layerId = renderLayer.get("value"); - const initLayerStyleState = latestLayerStyleStatesRef.current.find( - (state) => state.layerId === layerId - ); - const legendConfig: LegendStyleConfig = { - layerName: initLayerStyleState?.layerName || `图层${layerId}`, - layerId, - property: initLayerStyleState?.legendConfig.property || "", - colors, - type: layerType, - dimensions, - breaks, - }; - - setTimeout(() => { - saveLayerStyle(layerId, legendConfig, nextStyleConfig); - }, 100); - }, - [getRenderLayersById, saveLayerStyle] - ); - - const applyClassificationStyle = useCallback( - (layerType: "junctions" | "pipes", fallbackStyleConfig?: LayerStyleState["styleConfig"]) => { - const layerStyleState = latestLayerStyleStatesRef.current.find( - (state) => state.layerId === layerType - ); - const effectiveStyleConfig = layerStyleState?.styleConfig || fallbackStyleConfig; - - if (!effectiveStyleConfig) { - return; - } - - const isElevation = - layerType === "junctions" && effectiveStyleConfig.property === "elevation"; - const isDiameter = - layerType === "pipes" && effectiveStyleConfig.property === "diameter"; - const isUnitHeadloss = - layerType === "pipes" && effectiveStyleConfig.property === "unit_headloss"; - - const dataValues = - layerType === "junctions" - ? isElevation && elevationRange - ? [elevationRange[0], elevationRange[1]] - : currentJunctionCalData - ?.map((item: any) => - normalizeComputedStyleValue(effectiveStyleConfig.property, item.value) - ) - .filter(Number.isFinite) || [] - : isDiameter && diameterRange - ? [diameterRange[0], diameterRange[1]] - : isUnitHeadloss - ? [UNIT_HEADLOSS_RANGE[0], UNIT_HEADLOSS_RANGE[1]] - : currentPipeCalData - ?.map((item: any) => - normalizeComputedStyleValue(effectiveStyleConfig.property, item.value) - ) - .filter(Number.isFinite) || []; - - const canApply = - layerType === "junctions" - ? dataValues.length > 0 - : dataValues.length > 0 || isUnitHeadloss; - - if (!canApply || dataValues.length === 0) { - return; - } - - const segments = effectiveStyleConfig.segments ?? 5; - let breaks = - effectiveStyleConfig.classificationMethod === "custom_breaks" - ? normalizeCustomBreaks(effectiveStyleConfig.customBreaks || [], segments) - : calculateClassification( - dataValues, - segments, - effectiveStyleConfig.classificationMethod - ); - - if (breaks.length === 0) { - return; - } - - breaks = addBreakExtrema(breaks, dataValues); - - const styleStateToApply = - layerStyleState || - ({ - layerId: layerType, - layerName: layerType === "junctions" ? "节点" : "管道", - styleConfig: effectiveStyleConfig, - legendConfig: { - layerId: layerType, - layerName: layerType === "junctions" ? "节点" : "管道", - property: effectiveStyleConfig.property, - colors: [], - type: layerType === "junctions" ? "point" : "linestring", - dimensions: [], - breaks: [], - }, - isActive: true, - } as LayerStyleState); - - applyLayerStyle(styleStateToApply, breaks); - if (layerType === "junctions") { - applyContourLayerStyle(styleStateToApply, breaks); - } - }, - [ - applyContourLayerStyle, - applyLayerStyle, - currentJunctionCalData, - currentPipeCalData, - diameterRange, - elevationRange, - ] - ); - - const updateVectorTileSource = useCallback( - (targetMap: OlMap, layerId: string, property: string, records: any[]) => { - const vectorTileSources = targetMap - .getAllLayers() - .filter((layer) => layer.get("value") === layerId) - .map((layer) => layer.getSource() as VectorTileSource) - .filter((source) => source); - - if (!vectorTileSources.length) { - return; - } - - const dataMap = new Map(); - records.forEach((record: any) => { - dataMap.set(record.ID, normalizeComputedStyleValue(property, record.value || 0)); - }); - - vectorTileSources.forEach((vectorTileSource) => { - const sourceTiles = (vectorTileSource as any).sourceTiles_; - Object.values(sourceTiles).forEach((vectorTile: any) => { - const renderFeatures = vectorTile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) { - return; - } - - renderFeatures.forEach((renderFeature: any) => { - const featureId = renderFeature.get("id"); - const value = dataMap.get(featureId); - if (value === undefined) { - return; - } - - renderFeature.properties_[property] = value; - }); - }); - }); - }, - [] - ); - - const attachVectorTileSourceLoadedEvent = useCallback( - (targetMap: OlMap, layerId: string, property: string, records: any[]) => { - const vectorTileSource = targetMap - .getAllLayers() - .filter((layer) => layer.get("value") === layerId) - .map((layer) => layer.getSource() as VectorTileSource) - .filter((source) => source)[0]; - - if (!vectorTileSource) { - return; - } - - const dataMap = new Map(); - records.forEach((record: any) => { - dataMap.set(record.ID, normalizeComputedStyleValue(property, record.value || 0)); - }); - - const listener = (event: any) => { - try { - if (!(event.tile instanceof VectorTile)) { - return; - } - - const renderFeatures = event.tile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) { - return; - } - - renderFeatures.forEach((renderFeature: any) => { - const featureId = renderFeature.get("id"); - const value = dataMap.get(featureId); - if (value === undefined) { - return; - } - - renderFeature.properties_[property] = value; - }); - } catch (error) { - console.error("Error processing tile load event:", error); - } - }; - - const listenerKey = getMapKey(targetMap, layerId); - vectorTileSource.on("tileloadend", listener); - tileLoadListenersRef.current.set(listenerKey, { - source: vectorTileSource, - listener, - }); - }, - [getMapKey] - ); - - const removeVectorTileSourceLoadedEvent = useCallback( - (targetMap: OlMap, layerId: string) => { - const listenerKey = getMapKey(targetMap, layerId); - const listenerState = tileLoadListenersRef.current.get(listenerKey); - if (listenerState) { - listenerState.source.un("tileloadend", listenerState.listener); - tileLoadListenersRef.current.delete(listenerKey); - } - }, - [getMapKey] - ); - - const handleApply = useCallback(() => { - if (!selectedRenderLayer || !styleConfig.property) { - return; - } - - const layerId = selectedRenderLayer.get("value"); - const property = styleConfig.property; - - if (styleConfig.classificationMethod === "custom_breaks") { - const expected = styleConfig.segments; - const custom = styleConfig.customBreaks || []; - - if ( - custom.length !== expected || - custom.some((value) => value === undefined || value === null || isNaN(value)) - ) { - open?.({ - type: "error", - message: `请设置 ${expected} 个有效的自定义阈值(数字)`, - }); - return; - } - - if (custom.some((value) => value < 0)) { - open?.({ type: "error", message: "自定义阈值必须大于等于 0" }); - return; - } - - setStyleConfig((prev) => ({ - ...prev, - customBreaks: [...(prev.customBreaks || [])] - .slice(0, expected) - .sort((a, b) => a - b), - })); - } - - if (layerId === "junctions") { - setJunctionText?.(property); - setShowJunctionTextLayer?.(styleConfig.showLabels); - setShowJunctionId?.(styleConfig.showId); - setApplyJunctionStyle(true); - setContourLayerAvailable?.(property === "pressure"); - saveLayerStyle(layerId); - open?.({ - type: "success", - message: "节点图层样式设置成功,等待数据更新。", - }); - } - - if (layerId === "pipes") { - const isFlowProperty = property === "flow"; - setPipeText?.(property); - setShowPipeTextLayer?.(styleConfig.showLabels); - setShowPipeId?.(styleConfig.showId); - setApplyPipeStyle(true); - setWaterflowLayerAvailable?.(isFlowProperty); - if (!isFlowProperty) { - setShowWaterflowLayer?.(false); - } - saveLayerStyle(layerId); - open?.({ - type: "success", - message: "管道图层样式设置成功,等待数据更新。", - }); - } - - setStyleUpdateTrigger((prev) => prev + 1); - }, [ - open, - saveLayerStyle, - selectedRenderLayer, - setContourLayerAvailable, - setJunctionText, - setPipeText, - setShowJunctionId, - setShowJunctionTextLayer, - setShowPipeId, - setShowPipeTextLayer, - setShowWaterflowLayer, - setWaterflowLayerAvailable, - styleConfig, - ]); - - const handleReset = useCallback(() => { - if (!selectedRenderLayer) { - return; - } - - const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE; - const layerId = selectedRenderLayer.get("value"); - - getRenderLayersById(layerId).forEach((targetLayer) => { - targetLayer.setStyle(defaultFlatStyle); - }); - - removeLayerStyleState(layerId); - - if (layerId === "junctions") { - setApplyJunctionStyle(false); - setShowJunctionTextLayer?.(false); - setShowJunctionId?.(false); - setJunctionText?.(""); - setContours?.([]); - setContourLayerAvailable?.(false); - } else if (layerId === "pipes") { - setApplyPipeStyle(false); - setShowPipeTextLayer?.(false); - setShowPipeId?.(false); - setPipeText?.(""); - setWaterflowLayerAvailable?.(false); - } - }, [ - getRenderLayersById, - selectedRenderLayer, - setContourLayerAvailable, - setContours, - setJunctionText, - removeLayerStyleState, - setPipeText, - setShowJunctionId, - setShowJunctionTextLayer, - setShowPipeId, - setShowPipeTextLayer, - setWaterflowLayerAvailable, - ]); - - const normalizeExternalStyleConfig = useCallback( - (layerId: DefaultLayerStyleId, overrides?: Partial): StyleConfig => { - const currentStyleState = latestLayerStyleStatesRef.current.find( - (state) => state.layerId === layerId - ); - const baseStyleConfig = - currentStyleState?.styleConfig || createDefaultLayerStyleState(layerId).styleConfig; - const nextStyleConfig: StyleConfig = { - ...baseStyleConfig, - ...overrides, - customBreaks: overrides?.customBreaks - ? [...overrides.customBreaks] - : [...(baseStyleConfig.customBreaks || [])], - customColors: overrides?.customColors - ? [...overrides.customColors] - : [...(baseStyleConfig.customColors || [])], - }; - - nextStyleConfig.segments = Math.max(1, Math.round(nextStyleConfig.segments || 1)); - nextStyleConfig.opacity = Math.min(1, Math.max(0, nextStyleConfig.opacity)); - nextStyleConfig.singlePaletteIndex = Math.max( - 0, - Math.round(nextStyleConfig.singlePaletteIndex || 0) - ); - nextStyleConfig.gradientPaletteIndex = Math.max( - 0, - Math.round(nextStyleConfig.gradientPaletteIndex || 0) - ); - nextStyleConfig.rainbowPaletteIndex = Math.max( - 0, - Math.round(nextStyleConfig.rainbowPaletteIndex || 0) - ); - nextStyleConfig.minSize = Math.max(1, nextStyleConfig.minSize); - nextStyleConfig.maxSize = Math.max(nextStyleConfig.minSize, nextStyleConfig.maxSize); - nextStyleConfig.minStrokeWidth = Math.max(1, nextStyleConfig.minStrokeWidth); - nextStyleConfig.maxStrokeWidth = Math.max( - nextStyleConfig.minStrokeWidth, - nextStyleConfig.maxStrokeWidth - ); - nextStyleConfig.fixedStrokeWidth = Math.max(1, nextStyleConfig.fixedStrokeWidth); - nextStyleConfig.customColors = - nextStyleConfig.colorType === "custom" - ? getDefaultCustomColors( - nextStyleConfig.segments, - nextStyleConfig.customColors || [] - ) - : nextStyleConfig.customColors; - nextStyleConfig.customBreaks = - nextStyleConfig.classificationMethod === "custom_breaks" - ? normalizeCustomBreaks( - nextStyleConfig.customBreaks || - getBreakDefaults( - nextStyleConfig.segments, - nextStyleConfig.property, - getRenderLayersById(layerId)[0] - ), - nextStyleConfig.segments - ) - : nextStyleConfig.customBreaks; - - return nextStyleConfig; - }, - [getBreakDefaults, getRenderLayersById] - ); - - const applyExternalStyle = useCallback( - (layerId: DefaultLayerStyleId, overrides?: Partial) => { - const targetLayer = getRenderLayersById(layerId)[0]; - if (!targetLayer) { - open?.({ - type: "error", - message: `未找到${layerId === "junctions" ? "节点" : "管道"}图层,无法应用样式。`, - }); - return; - } - - const nextStyleConfig = normalizeExternalStyleConfig(layerId, overrides); - if (!nextStyleConfig.property) { - open?.({ - type: "error", - message: "样式工具缺少有效的渲染属性,无法应用样式。", - }); - return; - } - - const layerName = targetLayer.get("name") || (layerId === "junctions" ? "节点" : "管道"); - const targetProperties = (targetLayer.get("properties") || []) as AvailableProperty[]; - const propertyLabel = - targetProperties.find((item) => item.value === nextStyleConfig.property)?.name || - nextStyleConfig.property; - - setSelectedRenderLayer(targetLayer); - setStyleConfig(nextStyleConfig); - upsertLayerStyleState({ - layerId, - layerName, - styleConfig: nextStyleConfig, - legendConfig: { - layerId, - layerName, - property: propertyLabel, - colors: [], - type: targetLayer.get("type") || (layerId === "junctions" ? "point" : "linestring"), - dimensions: [], - breaks: [], - }, - isActive: true, - }); - + const syncAuxiliaryLayers = useCallback( + (layerId: DefaultLayerStyleId, nextConfig: StyleConfig | null) => { + const active = Boolean(nextConfig); if (layerId === "junctions") { - setJunctionText?.(nextStyleConfig.property); - setShowJunctionTextLayer?.(nextStyleConfig.showLabels); - setShowJunctionId?.(nextStyleConfig.showId); - setContourLayerAvailable?.(nextStyleConfig.property === "pressure"); - setApplyJunctionStyle(true); - } else { - const isFlowProperty = nextStyleConfig.property === "flow"; - setPipeText?.(nextStyleConfig.property); - setShowPipeTextLayer?.(nextStyleConfig.showLabels); - setShowPipeId?.(nextStyleConfig.showId); - setWaterflowLayerAvailable?.(isFlowProperty); - if (!isFlowProperty) { - setShowWaterflowLayer?.(false); - } - setApplyPipeStyle(true); - } - - applyClassificationStyle(layerId, nextStyleConfig); - open?.({ - type: "success", - message: `${layerId === "junctions" ? "节点" : "管道"}图层样式已应用。`, - }); - }, - [ - applyClassificationStyle, - getRenderLayersById, - normalizeExternalStyleConfig, - open, - setContourLayerAvailable, - setJunctionText, - setPipeText, - setShowJunctionId, - setShowJunctionTextLayer, - setShowPipeId, - setShowPipeTextLayer, - setShowWaterflowLayer, - setWaterflowLayerAvailable, - upsertLayerStyleState, - ] - ); - - const resetExternalStyle = useCallback( - (layerId: DefaultLayerStyleId) => { - const targetLayer = getRenderLayersById(layerId)[0]; - if (!targetLayer) { - open?.({ - type: "error", - message: `未找到${layerId === "junctions" ? "节点" : "管道"}图层,无法重置样式。`, - }); + setJunctionText?.(nextConfig?.property || ""); + setShowJunctionTextLayer?.(active && Boolean(nextConfig?.showLabels)); + setShowJunctionId?.(active && Boolean(nextConfig?.showId)); + setContourLayerAvailable?.(active && nextConfig?.property === "pressure"); + if (!active) setContours?.([]); return; } - - const defaultStyleConfig = createDefaultLayerStyleState(layerId).styleConfig; - const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE; - - setSelectedRenderLayer(targetLayer); - setStyleConfig(defaultStyleConfig); - - getRenderLayersById(layerId).forEach((renderLayer) => { - renderLayer.setStyle(defaultFlatStyle); - }); - - removeLayerStyleState(layerId); - - if (layerId === "junctions") { - setApplyJunctionStyle(false); - setShowJunctionTextLayer?.(false); - setShowJunctionId?.(false); - setJunctionText?.(""); - setContours?.([]); - setContourLayerAvailable?.(false); - } else { - setApplyPipeStyle(false); - setShowPipeTextLayer?.(false); - setShowPipeId?.(false); - setPipeText?.(""); - setWaterflowLayerAvailable?.(false); - } - - open?.({ - type: "success", - message: `${layerId === "junctions" ? "节点" : "管道"}图层样式已重置。`, - }); + const isFlow = active && nextConfig?.property === "flow"; + setPipeText?.(nextConfig?.property || ""); + setShowPipeTextLayer?.(active && Boolean(nextConfig?.showLabels)); + setShowPipeId?.(active && Boolean(nextConfig?.showId)); + setWaterflowLayerAvailable?.(isFlow); + if (!isFlow) setShowWaterflowLayer?.(false); }, [ - getRenderLayersById, - open, - removeLayerStyleState, setContourLayerAvailable, setContours, setJunctionText, @@ -860,336 +342,605 @@ export const useStyleEditor = ({ setShowJunctionTextLayer, setShowPipeId, setShowPipeTextLayer, + setShowWaterflowLayer, setWaterflowLayerAvailable, - ] + ], ); - const handleLayerChange = useCallback( - (index: number) => { - const newLayer = index >= 0 ? renderLayers[index] : undefined; - setSelectedRenderLayer(newLayer); - - if (!newLayer) { - return; - } - - const layerId = newLayer.get("value"); - const cachedStyleState = layerStyleStates.find((state) => state.layerId === layerId); - - if (cachedStyleState) { - setStyleConfig(cachedStyleState.styleConfig); - return; - } - - setStyleConfig((prev) => ({ - ...prev, - property: "", - customBreaks: - prev.classificationMethod === "custom_breaks" - ? getBreakDefaults(prev.segments, "", newLayer) - : prev.customBreaks, - customColors: getDefaultCustomColors(prev.segments, prev.customColors), - })); + const syncContoursForStyle = useCallback( + ( + layerId: DefaultLayerStyleId, + nextConfig: StyleConfig, + resolved: ResolvedLayerStyle, + ) => { + if (layerId !== "junctions") return; + setContours?.( + nextConfig.property === "pressure" + ? buildContourDefinitions({ + styleConfig: nextConfig, + breaks: resolved.boundaries, + colors: resolved.colors, + }) + : [], + ); }, - [getBreakDefaults, layerStyleStates, renderLayers] + [setContours], + ); + + const renderAppliedStyle = useCallback( + async (layerId: DefaultLayerStyleId, nextConfig: StyleConfig) => { + const targetLayer = getRenderLayersById(layerId)[0]; + if (!targetLayer) return false; + const values = getClassificationValues(layerId, nextConfig.property); + const resolved = resolveLayerStyle({ + layerType: targetLayer.get("type") || (layerId === "junctions" ? "point" : "linestring"), + styleConfig: nextConfig, + values, + }); + if (!resolved) return false; + + const revision = (renderRevisionRef.current.get(layerId) || 0) + 1; + renderRevisionRef.current.set(layerId, revision); + setIsApplying(true); + const layerType = targetLayer.get("type") || (layerId === "junctions" ? "point" : "linestring"); + const template = buildDynamicStyleTemplate({ + layerType, + property: nextConfig.property, + classCount: nextConfig.segments, + }); + const { variables, signature: renderSignature } = createRenderInputs( + nextConfig, + resolved, + ); + const usesNativeProperty = + nextConfig.property === "elevation" || nextConfig.property === "diameter"; + + const commits = activeMaps.map(async (targetMap) => { + const key = getMapKey(targetMap, layerId); + const records = getDataForMap(targetMap, layerId); + const previousInput = renderInputsRef.current.get(key); + if ( + controllersRef.current.has(key) && + previousInput?.records === records && + previousInput.signature === renderSignature + ) { + return; + } + const controller = getController(targetMap, layerId); + if (!controller) return; + const options = { + property: nextConfig.property, + classCount: nextConfig.segments, + template, + variables, + }; + if (usesNativeProperty) { + controller.applyNative(options); + renderInputsRef.current.set(key, { records, signature: renderSignature }); + return; + } + const stateById = new Map(); + records.forEach((record: any) => { + const id = record.ID ?? record.id; + if (id === undefined || id === null) return; + const value = normalizeComputedStyleValue(nextConfig.property, record.value); + if (Number.isFinite(value)) stateById.set(String(id), value); + }); + const committed = await controller.applyRuntime(options, stateById); + if (committed !== false) { + renderInputsRef.current.set(key, { records, signature: renderSignature }); + } + }); + await Promise.all(commits); + if (revision !== renderRevisionRef.current.get(layerId)) return false; + + const layerName = targetLayer.get("name") || (layerId === "junctions" ? "节点" : "管道"); + const properties = (targetLayer.get("properties") || []) as AvailableProperty[]; + const propertyLabel = + properties.find((item) => item.value === nextConfig.property)?.name || nextConfig.property; + const legendConfig = createLegendConfig({ + layerId, + layerName, + property: propertyLabel, + layerType, + resolved, + }); + upsertLayerStyleState({ + layerId, + layerName, + styleConfig: cloneStyleConfig(nextConfig), + legendConfig, + isActive: true, + }); + syncContoursForStyle(layerId, nextConfig, resolved); + setIsApplying(false); + return true; + }, + [ + activeMaps, + getClassificationValues, + getController, + getDataForMap, + getMapKey, + getRenderLayersById, + syncContoursForStyle, + upsertLayerStyleState, + ], + ); + + const activateStyle = useCallback( + async ( + layerId: DefaultLayerStyleId, + nextConfig: StyleConfig, + notify = true, + ) => { + const validation = validateStyleConfig(nextConfig); + if (!validation.valid) { + if (notify) open?.({ type: "error", message: validation.errors[0] }); + return false; + } + const targetLayer = getRenderLayersById(layerId)[0]; + if (!targetLayer) { + if (notify) open?.({ type: "error", message: "未找到目标图层,无法应用样式。" }); + return false; + } + const previous = latestLayerStyleStatesRef.current.find((state) => state.layerId === layerId); + const layerName = targetLayer.get("name") || (layerId === "junctions" ? "节点" : "管道"); + const layerType = targetLayer.get("type") || (layerId === "junctions" ? "point" : "linestring"); + const canUpdateVariablesOnly = + previous?.isActive && + hasSameTemplate(previous.styleConfig, nextConfig) && + activeMaps.every((targetMap) => { + const controller = controllersRef.current.get(getMapKey(targetMap, layerId)); + return controller?.hasTemplate(); + }); + + if (canUpdateVariablesOnly) { + const resolved = resolveLayerStyle({ + layerType, + styleConfig: nextConfig, + values: getClassificationValues(layerId, nextConfig.property), + }); + if (resolved) { + const { variables } = createRenderInputs(nextConfig, resolved); + activeMaps.forEach((targetMap) => + controllersRef.current + .get(getMapKey(targetMap, layerId)) + ?.updateVariables(variables), + ); + const properties = (targetLayer.get("properties") || []) as AvailableProperty[]; + const propertyLabel = + properties.find((item) => item.value === nextConfig.property)?.name || + nextConfig.property; + upsertLayerStyleState({ + layerId, + layerName, + styleConfig: cloneStyleConfig(nextConfig), + legendConfig: createLegendConfig({ + layerId, + layerName, + property: propertyLabel, + layerType, + resolved, + }), + isActive: true, + }); + syncContoursForStyle(layerId, nextConfig, resolved); + draftsRef.current.set(layerId, cloneStyleConfig(nextConfig)); + syncAuxiliaryLayers(layerId, nextConfig); + if (notify) { + open?.({ + type: "success", + message: `${layerId === "junctions" ? "节点" : "管道"}图层样式已应用。`, + }); + } + return true; + } + } + upsertLayerStyleState({ + layerId, + layerName, + styleConfig: cloneStyleConfig(nextConfig), + legendConfig: previous?.legendConfig || { + layerId, + layerName, + property: nextConfig.property, + colors: [], + type: layerType, + dimensions: [], + breaks: [], + }, + isActive: true, + }); + draftsRef.current.set(layerId, cloneStyleConfig(nextConfig)); + syncAuxiliaryLayers(layerId, nextConfig); + const rendered = await renderAppliedStyle(layerId, nextConfig); + if (notify) { + open?.({ + type: rendered ? "success" : "progress", + message: rendered + ? `${layerId === "junctions" ? "节点" : "管道"}图层样式已应用。` + : "样式已保存,将在数据就绪后渲染。", + }); + } + return true; + }, + [ + activeMaps, + getClassificationValues, + getMapKey, + getRenderLayersById, + open, + renderAppliedStyle, + syncContoursForStyle, + syncAuxiliaryLayers, + upsertLayerStyleState, + ], + ); + + const resetLayer = useCallback( + (layerId: DefaultLayerStyleId, notify = true) => { + activeMaps.forEach((targetMap) => { + const key = getMapKey(targetMap, layerId); + controllersRef.current.get(key)?.reset(); + controllersRef.current.delete(key); + renderInputsRef.current.delete(key); + }); + const defaults = createDefaultLayerStyleState(layerId); + setLayerStyleStates((previous) => { + const next = previous.map((state) => (state.layerId === layerId ? defaults : state)); + latestLayerStyleStatesRef.current = next; + return next; + }); + draftsRef.current.delete(layerId); + syncAuxiliaryLayers(layerId, null); + if (selectedRenderLayer?.get("value") === layerId) { + setStyleConfig(cloneStyleConfig(defaults.styleConfig)); + } + if (notify) { + open?.({ + type: "success", + message: `${layerId === "junctions" ? "节点" : "管道"}图层样式已重置。`, + }); + } + }, + [activeMaps, getMapKey, open, selectedRenderLayer, setLayerStyleStates, syncAuxiliaryLayers], + ); + + const normalizeExternalStyleConfig = useCallback( + (layerId: DefaultLayerStyleId, overrides?: Partial) => { + const applied = latestLayerStyleStatesRef.current.find((state) => state.layerId === layerId); + const base = applied?.styleConfig || createDefaultLayerStyleState(layerId).styleConfig; + const next: StyleConfig = { + ...base, + ...overrides, + customBreaks: overrides?.customBreaks + ? [...overrides.customBreaks] + : [...(base.customBreaks || [])], + customColors: overrides?.customColors + ? [...overrides.customColors] + : [...(base.customColors || [])], + }; + const classCountChanged = + overrides?.segments !== undefined && overrides.segments !== base.segments; + if ( + next.colorType === "custom" && + overrides?.customColors === undefined && + (classCountChanged || base.colorType !== "custom") + ) { + next.customColors = getDefaultCustomColors(next.segments, next.customColors); + } + if ( + next.classificationMethod === "custom_breaks" && + overrides?.customBreaks === undefined && + (classCountChanged || base.classificationMethod !== "custom_breaks") + ) { + const fallback = getDefaultBreaks( + next.segments, + next.property, + getRenderLayersById(layerId)[0], + ); + next.customBreaks = normalizeCustomBreaks(fallback, next.segments); + } + return next; + }, + [getDefaultBreaks, getRenderLayersById], + ); + + const applyExternalStyle = useCallback( + (layerId: DefaultLayerStyleId, overrides?: Partial) => { + const targetLayer = getRenderLayersById(layerId)[0]; + if (!targetLayer) { + open?.({ type: "error", message: "未找到目标图层,无法应用样式。" }); + return; + } + const next = normalizeExternalStyleConfig(layerId, overrides); + setSelectedRenderLayer(targetLayer); + setStyleConfig(cloneStyleConfig(next)); + void activateStyle(layerId, next); + }, + [activateStyle, getRenderLayersById, normalizeExternalStyleConfig, open], + ); + + const handleApply = useCallback(() => { + const layerId = selectedRenderLayer?.get("value"); + if (!isDefaultLayerId(layerId)) return; + void activateStyle(layerId, styleConfig); + }, [activateStyle, selectedRenderLayer, styleConfig]); + + const handleReset = useCallback(() => { + const layerId = selectedRenderLayer?.get("value"); + if (isDefaultLayerId(layerId)) resetLayer(layerId); + }, [resetLayer, selectedRenderLayer]); + + const handleLayerChange = useCallback( + (layerId: string) => { + const currentLayerId = selectedRenderLayer?.get("value"); + if (isDefaultLayerId(currentLayerId)) { + draftsRef.current.set(currentLayerId, cloneStyleConfig(styleConfig)); + const applied = latestLayerStyleStatesRef.current.find( + (state) => state.layerId === currentLayerId && state.isActive, + ); + if (applied) { + const resolved = resolveStyleFromLegend( + applied.styleConfig, + applied.legendConfig, + ); + if (resolved) { + const { variables } = createRenderInputs(applied.styleConfig, resolved); + activeMaps.forEach((targetMap) => + getController(targetMap, currentLayerId)?.updateVariables(variables), + ); + } + } + } + const nextLayer = renderLayers.find((layer) => layer.get("value") === layerId); + setSelectedRenderLayer(nextLayer); + if (!nextLayer) return; + const draft = draftsRef.current.get(layerId); + const applied = latestLayerStyleStatesRef.current.find((state) => state.layerId === layerId); + const fallback = isDefaultLayerId(layerId) + ? createDefaultLayerStyleState(layerId).styleConfig + : createEmptyStyleConfig(); + setStyleConfig(cloneStyleConfig(draft || applied?.styleConfig || fallback)); + }, + [activeMaps, getController, renderLayers, selectedRenderLayer, styleConfig], ); const handlePropertyChange = useCallback( (property: string) => { - setStyleConfig((prev) => ({ - ...prev, + setStyleConfig((previous) => ({ + ...previous, property, customBreaks: - prev.classificationMethod === "custom_breaks" - ? getBreakDefaults(prev.segments, property) - : prev.customBreaks, + previous.classificationMethod === "custom_breaks" + ? getDefaultBreaks(previous.segments, property) + : previous.customBreaks, })); }, - [getBreakDefaults] + [getDefaultBreaks], ); const handleClassificationMethodChange = useCallback( - (classificationMethod: string) => { - setStyleConfig((prev) => ({ - ...prev, + (classificationMethod: ClassificationMethod) => { + setStyleConfig((previous) => ({ + ...previous, classificationMethod, customBreaks: classificationMethod === "custom_breaks" - ? getBreakDefaults(prev.segments, prev.property) - : prev.customBreaks, + ? getDefaultBreaks(previous.segments, previous.property) + : previous.customBreaks, })); }, - [getBreakDefaults] + [getDefaultBreaks], ); const handleSegmentsChange = useCallback( (segments: number) => { - setStyleConfig((prev) => { - const newCustomColors = [...(prev.customColors || [])]; - return { - ...prev, - segments, - customBreaks: - prev.classificationMethod === "custom_breaks" - ? getBreakDefaults(segments, prev.property) - : prev.customBreaks, - customColors: getDefaultCustomColors(segments, newCustomColors), - }; - }); + setStyleConfig((previous) => ({ + ...previous, + segments, + customBreaks: + previous.classificationMethod === "custom_breaks" + ? getDefaultBreaks(segments, previous.property) + : previous.customBreaks, + customColors: getDefaultCustomColors(segments, previous.customColors), + })); }, - [getBreakDefaults] + [getDefaultBreaks], ); - const handleCustomBreakChange = useCallback( - (index: number, value: string) => { - const nextValue = parseFloat(value); - setStyleConfig((prev) => { - const nextBreaks = [...(prev.customBreaks || [])]; - while (nextBreaks.length < prev.segments) { - nextBreaks.push(0); - } - nextBreaks[index] = isNaN(nextValue) ? 0 : Math.max(0, nextValue); - return { ...prev, customBreaks: nextBreaks }; - }); - }, - [] - ); + const handleCustomBreakChange = useCallback((index: number, value: string) => { + setStyleConfig((previous) => { + const boundaries = [...(previous.customBreaks || [])]; + while (boundaries.length < previous.segments + 1) boundaries.push(0); + boundaries[index] = value.trim() === "" ? Number.NaN : Number(value); + return { ...previous, customBreaks: boundaries }; + }); + }, []); - const handleCustomBreakBlur = useCallback(() => { - setStyleConfig((prev) => ({ - ...prev, - customBreaks: [...(prev.customBreaks || [])] - .slice(0, prev.segments + 1) - .sort((a, b) => a - b), + const handleColorTypeChange = useCallback((colorType: ColorType) => { + setStyleConfig((previous) => ({ + ...previous, + colorType, + customColors: + colorType === "custom" + ? getDefaultCustomColors(previous.segments, previous.customColors) + : previous.customColors, })); }, []); - const handleColorTypeChange = useCallback((colorType: string) => { - setStyleConfig((prev) => { - let customColors = prev.customColors; - if (colorType === "custom" && (!customColors || customColors.length === 0)) { - customColors = getDefaultCustomColors(prev.segments, []); - } + const selectedLayerId = selectedRenderLayer?.get("value"); + const appliedStyleConfig = isDefaultLayerId(selectedLayerId) + ? layerStyleStates.find((state) => state.layerId === selectedLayerId && state.isActive) + ?.styleConfig + : undefined; + const validation = useMemo(() => validateStyleConfig(styleConfig), [styleConfig]); + const isDirty = requiresStyleApply(appliedStyleConfig, styleConfig); - return { - ...prev, - colorType, - adjustWidthByProperty: colorType === "single" ? true : prev.adjustWidthByProperty, - customColors, - }; + useEffect(() => { + if (!appliedStyleConfig || !isDefaultLayerId(selectedLayerId)) return; + if (requiresStyleApply(appliedStyleConfig, styleConfig)) return; + if (!validation.valid) return; + const state = layerStyleStates.find((item) => item.layerId === selectedLayerId); + if (!state || state.legendConfig.breaks.length !== styleConfig.segments + 1) return; + if (configsEqual(styleConfig, appliedStyleConfig)) return; + const resolved = resolveStyleFromLegend(styleConfig, state.legendConfig); + if (!resolved) return; + const { variables, signature: renderSignature } = createRenderInputs( + styleConfig, + resolved, + ); + activeMaps.forEach((targetMap) => { + const key = getMapKey(targetMap, selectedLayerId); + getController(targetMap, selectedLayerId)?.updateVariables(variables); + const previousInput = renderInputsRef.current.get(key); + if (previousInput) { + renderInputsRef.current.set(key, { + ...previousInput, + signature: renderSignature, + }); + } }); - }, []); + const nextStyleConfig = cloneStyleConfig(styleConfig); + draftsRef.current.set(selectedLayerId, nextStyleConfig); + upsertLayerStyleState({ + ...state, + styleConfig: nextStyleConfig, + legendConfig: { + ...state.legendConfig, + colors: resolved.isConstant ? resolved.colors.slice(0, 1) : resolved.colors, + dimensions: resolved.isConstant + ? resolved.dimensions.slice(0, 1) + : resolved.dimensions, + }, + }); + syncAuxiliaryLayers(selectedLayerId, nextStyleConfig); + syncContoursForStyle(selectedLayerId, styleConfig, resolved); + }, [ + activeMaps, + appliedStyleConfig, + getController, + getMapKey, + layerStyleStates, + selectedLayerId, + styleConfig, + syncAuxiliaryLayers, + syncContoursForStyle, + upsertLayerStyleState, + validation.valid, + ]); + + useEffect(() => { + if (!persistenceReady) return; + latestLayerStyleStatesRef.current + .filter((state) => state.isActive && isDefaultLayerId(state.layerId)) + .forEach((state) => void renderAppliedStyle(state.layerId as DefaultLayerStyleId, state.styleConfig)); + }, [ + activeMaps, + compareJunctionCalData, + comparePipeCalData, + currentJunctionCalData, + currentPipeCalData, + diameterRange, + elevationRange, + persistenceReady, + renderAppliedStyle, + ]); + + useEffect(() => { + const activeMapKeys = new Set( + activeMaps.map((targetMap) => getMapKey(targetMap, "").replace(/:$/, "")), + ); + controllersRef.current.forEach((controller, key) => { + const mapKey = key.slice(0, key.lastIndexOf(":")); + if (activeMapKeys.has(mapKey)) return; + controller.dispose(); + controllersRef.current.delete(key); + renderInputsRef.current.delete(key); + }); + }, [activeMaps, getMapKey]); useEffect(() => { if ( forceStyleAutoApplyVersion <= 0 || forceStyleAutoApplyVersion === lastForceStyleAutoApplyVersionRef.current - ) { - return; - } - + ) return; lastForceStyleAutoApplyVersionRef.current = forceStyleAutoApplyVersion; - - const defaultJunctionStyleState = { - ...createDefaultLayerStyleState("junctions"), - isActive: true, - }; - const defaultPipeStyleState = { - ...createDefaultLayerStyleState("pipes"), - isActive: true, - }; - - setLayerStyleStates((prev) => { - const nextStates = [...prev]; - [defaultJunctionStyleState, defaultPipeStyleState].forEach((defaultState) => { - const index = nextStates.findIndex((state) => state.layerId === defaultState.layerId); - if (index === -1) { - nextStates.push(defaultState); - } else { - nextStates[index] = defaultState; - } - }); - latestLayerStyleStatesRef.current = nextStates; - return nextStates; + (["junctions", "pipes"] as DefaultLayerStyleId[]).forEach((layerId) => { + const defaults = createDefaultLayerStyleState(layerId).styleConfig; + void activateStyle(layerId, defaults, false); + if (selectedRenderLayer?.get("value") === layerId) { + setStyleConfig(cloneStyleConfig(defaults)); + } }); + }, [activateStyle, forceStyleAutoApplyVersion, selectedRenderLayer]); - setJunctionText?.(defaultJunctionStyleState.styleConfig.property); - setPipeText?.(defaultPipeStyleState.styleConfig.property); - setShowJunctionTextLayer?.(defaultJunctionStyleState.styleConfig.showLabels); - setShowPipeTextLayer?.(defaultPipeStyleState.styleConfig.showLabels); - setShowJunctionId?.(defaultJunctionStyleState.styleConfig.showId); - setShowPipeId?.(defaultPipeStyleState.styleConfig.showId); - setContourLayerAvailable?.( - defaultJunctionStyleState.styleConfig.property === "pressure" + const storageKey = `${workspace}:map-layer-style:v${STYLE_STORAGE_VERSION}`; + useEffect(() => { + // Restoring an external browser preference intentionally initializes React state. + // eslint-disable-next-line react-hooks/set-state-in-effect + setPersistenceReady(false); + let restored = createDefaultLayerStyleStates(); + try { + const raw = window.localStorage.getItem(storageKey); + if (raw) { + const document = JSON.parse(raw) as { + version?: number; + layers?: Partial>; + }; + if (document.version === STYLE_STORAGE_VERSION && document.layers) { + restored = createDefaultLayerStyleStates().map((state) => { + if (!isDefaultLayerId(state.layerId)) return state; + const stored = document.layers?.[state.layerId]; + if (!stored || !validateStyleConfig(stored).valid) return state; + return { + ...state, + styleConfig: cloneStyleConfig(stored), + legendConfig: { ...state.legendConfig, property: stored.property }, + isActive: true, + }; + }); + } + } + } catch (error) { + console.warn("Restore layer styles failed", error); + } + latestLayerStyleStatesRef.current = restored; + setLayerStyleStates(restored); + const restoredSelection = restored.find((state) => state.layerId === "junctions"); + if (restoredSelection) setStyleConfig(cloneStyleConfig(restoredSelection.styleConfig)); + restored + .filter((state) => state.isActive && isDefaultLayerId(state.layerId)) + .forEach((state) => syncAuxiliaryLayers(state.layerId as DefaultLayerStyleId, state.styleConfig)); + setPersistenceReady(true); + }, [setLayerStyleStates, storageKey, syncAuxiliaryLayers]); + + useEffect(() => { + if (!persistenceReady) return; + const layers = Object.fromEntries( + layerStyleStates + .filter((state) => state.isActive && isDefaultLayerId(state.layerId)) + .map((state) => [state.layerId, state.styleConfig]), ); - const isDefaultPipeFlow = - defaultPipeStyleState.styleConfig.property === "flow"; - setWaterflowLayerAvailable?.(isDefaultPipeFlow); - if (!isDefaultPipeFlow) { - setShowWaterflowLayer?.(false); - } - setApplyJunctionStyle(true); - setApplyPipeStyle(true); - - const selectedLayerId = selectedRenderLayer?.get("value"); - if (selectedLayerId === "junctions") { - setStyleConfig(defaultJunctionStyleState.styleConfig); - } else if (selectedLayerId === "pipes") { - setStyleConfig(defaultPipeStyleState.styleConfig); - } - }, [ - forceStyleAutoApplyVersion, - selectedRenderLayer, - setContourLayerAvailable, - setJunctionText, - setLayerStyleStates, - setPipeText, - setShowJunctionId, - setShowJunctionTextLayer, - setShowPipeId, - setShowPipeTextLayer, - setShowWaterflowLayer, - setWaterflowLayerAvailable, - ]); - - useEffect(() => { - const isUserTrigger = styleUpdateTrigger !== prevStyleUpdateTriggerRef.current; - prevStyleUpdateTriggerRef.current = styleUpdateTrigger; - - const updateJunctionStyle = () => { - const junctionStyleState = latestLayerStyleStatesRef.current.find( - (state) => state.layerId === "junctions" + try { + window.localStorage.setItem( + storageKey, + JSON.stringify({ version: STYLE_STORAGE_VERSION, layers }), ); - const isElevation = - junctionStyleState?.styleConfig.property === "elevation"; - - applyClassificationStyle("junctions", junctionStyleState?.styleConfig); - - if (isElevation) { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "junctions"); - }); - return; - } - - activeMaps.forEach((targetMap) => { - const targetData = getDataForMap(targetMap, "junctions"); - if (!targetData || targetData.length === 0) { - return; - } - updateVectorTileSource(targetMap, "junctions", junctionText, targetData); - removeVectorTileSourceLoadedEvent(targetMap, "junctions"); - attachVectorTileSourceLoadedEvent( - targetMap, - "junctions", - junctionText, - targetData - ); - }); - }; - - const updatePipeStyle = () => { - const pipeStyleState = latestLayerStyleStatesRef.current.find( - (state) => state.layerId === "pipes" - ); - const isDiameter = pipeStyleState?.styleConfig.property === "diameter"; - - applyClassificationStyle("pipes", pipeStyleState?.styleConfig); - - if (isDiameter) { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "pipes"); - }); - return; - } - - activeMaps.forEach((targetMap) => { - const targetData = getDataForMap(targetMap, "pipes"); - if (!targetData || targetData.length === 0) { - return; - } - updateVectorTileSource(targetMap, "pipes", pipeText, targetData); - removeVectorTileSourceLoadedEvent(targetMap, "pipes"); - attachVectorTileSourceLoadedEvent(targetMap, "pipes", pipeText, targetData); - }); - }; - - if (isUserTrigger) { - if (selectedRenderLayer?.get("value") === "junctions") { - updateJunctionStyle(); - } else if (selectedRenderLayer?.get("value") === "pipes") { - updatePipeStyle(); - } - return; + } catch (error) { + console.warn("Save layer styles failed", error); } + }, [layerStyleStates, persistenceReady, storageKey]); - const isElevation = junctionText === "elevation"; - const isDiameter = pipeText === "diameter"; - - if ( - applyJunctionStyle && - ((currentJunctionCalData && currentJunctionCalData.length > 0) || isElevation) - ) { - updateJunctionStyle(); - } - - if ( - applyPipeStyle && - ((currentPipeCalData && currentPipeCalData.length > 0) || isDiameter) - ) { - updatePipeStyle(); - } - - if (!applyJunctionStyle) { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "junctions"); - }); - } - - if (!applyPipeStyle) { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "pipes"); - }); - } - // This effect is intentionally driven by explicit style triggers and data snapshots. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - styleUpdateTrigger, - applyJunctionStyle, - applyPipeStyle, - currentJunctionCalData, - currentPipeCalData, - compareJunctionCalData, - comparePipeCalData, - activeMaps, - ]); - - useEffect(() => { - return () => { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "junctions"); - removeVectorTileSourceLoadedEvent(targetMap, "pipes"); - }); - }; - }, [activeMaps, removeVectorTileSourceLoadedEvent]); - - useEffect(() => { - if (!map) { - return; - } - - const updateVisibleLayers = () => { - const layers = map.getAllLayers(); - const webGLVectorTileLayers = layers.filter( - (layer) => - layer.get("value") === "junctions" || layer.get("value") === "pipes" - ) as WebGLVectorTileLayer[]; - - setRenderLayers(webGLVectorTileLayers); - }; - - updateVisibleLayers(); - }, [map]); + useEffect( + () => () => { + controllersRef.current.forEach((controller) => controller.dispose()); + controllersRef.current.clear(); + renderInputsRef.current.clear(); + }, + [], + ); return { isReady: Boolean(data), @@ -1198,16 +949,18 @@ export const useStyleEditor = ({ styleConfig, setStyleConfig, availableProperties, + validationErrors: validation.errors, + isDirty, + isApplying, handleLayerChange, handlePropertyChange, handleClassificationMethodChange, handleSegmentsChange, handleCustomBreakChange, - handleCustomBreakBlur, handleColorTypeChange, handleApply, handleReset, applyExternalStyle, - resetExternalStyle, + resetExternalStyle: resetLayer, }; }; diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 51a6786..0440c54 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -10,7 +10,7 @@ import React, { useCallback, useRef, } from "react"; -import { Map as OlMap, VectorTile } from "ol"; +import { Map as OlMap } from "ol"; import View from "ol/View.js"; import "ol/ol.css"; import MapTools from "./MapTools"; @@ -31,13 +31,47 @@ import { disposeMapResources, markMapResourcePersistent, } from "./mapLifecycle"; -import { createOperationalMapResources } from "./operationalLayers"; +import { + createOperationalMapResources, + createOperationalMapSources, +} from "./operationalLayers"; import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime"; import { useTimelineTimeConfig } from "./Controls/useTimelineTimeConfig"; +import { + TileFeatureIndex, + clipLineStringPartsToExtent, + coordinatesToLonLat, + lineStringFromFlatCoordinates, + type TileFeatureInstance, +} from "./tileFeatureIndex"; interface MapComponentProps { children?: React.ReactNode; } + +const COMPARE_OPEN_START_MARK = "tjwater:compare-open-start"; +const COMPARE_OPEN_READY_MARK = "tjwater:compare-open-ready"; +const COMPARE_OPEN_MEASURE = "tjwater:compare-open"; + +const markCompareOpenStart = () => { + performance.clearMarks(COMPARE_OPEN_START_MARK); + performance.clearMarks(COMPARE_OPEN_READY_MARK); + performance.clearMeasures(COMPARE_OPEN_MEASURE); + performance.mark(COMPARE_OPEN_START_MARK); +}; + +const markCompareOpenReady = () => { + if (performance.getEntriesByName(COMPARE_OPEN_START_MARK).length === 0) { + return; + } + performance.mark(COMPARE_OPEN_READY_MARK); + performance.measure( + COMPARE_OPEN_MEASURE, + COMPARE_OPEN_START_MARK, + COMPARE_OPEN_READY_MARK, + ); +}; + interface DataContextType { currentTime?: number; // 当前时间 setCurrentTime?: React.Dispatch>; @@ -122,6 +156,57 @@ function debounce any>( return debounced; } +const indexCalculationRecords = (records: any[]) => + new Map(records.map((record) => [String(record.ID), record])); + +const mergeJunctionValues = ( + features: any[], + records: any[], + property: string, +) => { + const recordsById = indexCalculationRecords(records); + return features.map((feature) => { + const record = recordsById.get(String(feature.id)); + if (!record) return feature; + const value = isLpsFlowProperty(property) + ? toM3h(record.value, "lps") + : record.value; + return { ...feature, [property]: value }; + }); +}; + +const mergePipeValues = ( + features: any[], + records: any[], + property: string, +) => { + const recordsById = indexCalculationRecords(records); + const isFlow = property === "flow"; + return features.map((feature) => { + const record = recordsById.get(String(feature.id)); + if (!record) return feature; + const value = isFlow ? toM3h(record.value, "lps") : record.value; + const reverseFlow = isFlow && record.value < 0; + return { + ...feature, + [property]: isFlow ? Math.abs(value) : value, + flowFlag: reverseFlow ? -1 : 1, + path: reverseFlow ? [...feature.path].reverse() : feature.path, + }; + }); +}; + +const getNumericRange = (values: number[]): [number, number] | undefined => { + let min = Infinity; + let max = -Infinity; + values.forEach((value) => { + if (!Number.isFinite(value)) return; + min = Math.min(min, value); + max = Math.max(max, value); + }); + return min === Infinity ? undefined : [min, max]; +}; + export const useMap = () => { return useContext(MapContext); }; @@ -151,7 +236,6 @@ const MapComponent: React.FC = ({ children }) => { const compareDeckLayerRef = useRef(null); const isDisposingRef = useRef(false); const isCompareDisposingRef = useRef(false); - const pendingTimeoutsRef = useRef([]); const [map, setMap] = useState(); const [deckLayer, setDeckLayer] = useState(); @@ -174,14 +258,13 @@ const MapComponent: React.FC = ({ children }) => { ); const [comparePipeCalData, setComparePipeCalData] = useState([]); const [isCompareMode, setCompareMode] = useState(false); - // junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染 - // currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值 + // junctionData 为当前层级和视口内按 ID 去重的节点数据;pipeData 为管道标签数据。 + // pipeFragments 保留当前层级和视口内每个瓦片管道片段,水流动画按 instanceKey 渲染,不能按业务 ID 去重。 const [junctionData, setJunctionDataState] = useState([]); const [pipeData, setPipeDataState] = useState([]); - const junctionDataIds = useRef(new Set()); - const pipeDataIds = useRef(new Set()); - const tileJunctionDataBuffer = useRef([]); - const tilePipeDataBuffer = useRef([]); + const [pipeFragments, setPipeFragments] = useState([]); + const junctionIndexRef = useRef(null); + const pipeIndexRef = useRef(null); const [showJunctionTextLayer, setShowJunctionTextLayer] = useState(false); // 控制节点文本图层显示 const [showPipeTextLayer, setShowPipeTextLayer] = useState(false); // 控制管道文本图层显示 @@ -191,7 +274,6 @@ const MapComponent: React.FC = ({ children }) => { const [junctionText, setJunctionText] = useState("pressure"); const [pipeText, setPipeText] = useState("velocity"); const [contours, setContours] = useState([]); - const flowAnimation = useRef(false); // 添加动画控制标志 const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示 const [isWaterflowLayerAvailable, setWaterflowLayerAvailable] = useState(false); // 控制等高线图层显示 @@ -199,67 +281,40 @@ const MapComponent: React.FC = ({ children }) => { const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别 // 实时合并计算结果到基础地理数据中 - const mergedJunctionData = useMemo(() => { - const nodeMap = new Map(currentJunctionCalData.map((r: any) => [r.ID, r])); - return junctionData.map((j) => { - const record = nodeMap.get(j.id); - let val = record ? record.value : undefined; - if (val !== undefined && isLpsFlowProperty(junctionText)) { - val = toM3h(val, "lps"); - } - return record ? { ...j, [junctionText]: val } : j; - }); - }, [junctionData, currentJunctionCalData, junctionText]); - - const mergedPipeData = useMemo(() => { - const linkMap = new Map(currentPipeCalData.map((r: any) => [r.ID, r])); - return pipeData.map((p) => { - const record = linkMap.get(p.id); - if (!record) return p; - const isFlow = pipeText === "flow"; - let val = record.value; - if (val !== undefined && isFlow) { - val = toM3h(val, "lps"); - } - return { - ...p, - [pipeText]: isFlow ? Math.abs(val) : val, - flowFlag: isFlow && record.value < 0 ? -1 : 1, - path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path, - }; - }); - }, [pipeData, currentPipeCalData, pipeText]); - - const mergedCompareJunctionData = useMemo(() => { - const nodeMap = new Map(compareJunctionCalData.map((r: any) => [r.ID, r])); - return junctionData.map((j) => { - const record = nodeMap.get(j.id); - let val = record ? record.value : undefined; - if (val !== undefined && isLpsFlowProperty(junctionText)) { - val = toM3h(val, "lps"); - } - return record ? { ...j, [junctionText]: val } : j; - }); - }, [junctionData, compareJunctionCalData, junctionText]); - - const mergedComparePipeData = useMemo(() => { - const linkMap = new Map(comparePipeCalData.map((r: any) => [r.ID, r])); - return pipeData.map((p) => { - const record = linkMap.get(p.id); - if (!record) return p; - const isFlow = pipeText === "flow"; - let val = record.value; - if (val !== undefined && isFlow) { - val = toM3h(val, "lps"); - } - return { - ...p, - [pipeText]: isFlow ? Math.abs(val) : val, - flowFlag: isFlow && record.value < 0 ? -1 : 1, - path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path, - }; - }); - }, [pipeData, comparePipeCalData, pipeText]); + const mergedJunctionData = useMemo( + () => + mergeJunctionValues(junctionData, currentJunctionCalData, junctionText), + [junctionData, currentJunctionCalData, junctionText], + ); + const mergedPipeData = useMemo( + () => mergePipeValues(pipeData, currentPipeCalData, pipeText), + [pipeData, currentPipeCalData, pipeText], + ); + const mergedPipeFragments = useMemo( + () => mergePipeValues(pipeFragments, currentPipeCalData, pipeText), + [pipeFragments, currentPipeCalData, pipeText], + ); + const mergedCompareJunctionData = useMemo( + () => + isCompareMode + ? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText) + : [], + [isCompareMode, junctionData, compareJunctionCalData, junctionText], + ); + const mergedComparePipeData = useMemo( + () => + isCompareMode + ? mergePipeValues(pipeData, comparePipeCalData, pipeText) + : [], + [isCompareMode, pipeData, comparePipeCalData, pipeText], + ); + const mergedComparePipeFragments = useMemo( + () => + isCompareMode + ? mergePipeValues(pipeFragments, comparePipeCalData, pipeText) + : [], + [isCompareMode, pipeFragments, comparePipeCalData, pipeText], + ); const [diameterRange, setDiameterRange] = useState< [number, number] | undefined @@ -271,7 +326,10 @@ const MapComponent: React.FC = ({ children }) => { useState(0); const toggleCompareMode = useCallback(() => { - setCompareMode((prev) => !prev); + setCompareMode((prev) => { + if (!prev) markCompareOpenStart(); + return !prev; + }); }, []); const maps = useMemo( @@ -288,91 +346,126 @@ const MapComponent: React.FC = ({ children }) => { [compareDeckLayer, deckLayer, isCompareMode], ); - const setJunctionData = (newData: any[]) => { - const uniqueNewData = newData.filter((item) => { - if (!item || !item.id) return false; - if (!junctionDataIds.current.has(item.id)) { - junctionDataIds.current.add(item.id); - return true; + const buildPipeFragments = useCallback((instance: TileFeatureInstance) => { + const tileCoordinates = lineStringFromFlatCoordinates( + instance.flatCoordinates, + instance.stride, + ); + const clippedParts = clipLineStringPartsToExtent( + tileCoordinates, + instance.tileExtent, + ); + return clippedParts.flatMap((clippedCoordinates, partIndex) => { + const path = coordinatesToLonLat(clippedCoordinates); + const lineStringFeature = lineString(path); + const fragmentLength = length(lineStringFeature); + if (fragmentLength <= 0) return []; + + const timestamps = [0]; + let cumulativeLength = 0; + for (let i = 1; i < path.length; i += 1) { + cumulativeLength += length(lineString([path[i - 1], path[i]])); + timestamps.push((cumulativeLength / fragmentLength) * 10); } - return false; + + const midPoint = along(lineStringFeature, fragmentLength / 2).geometry + .coordinates; + const prevPoint = along(lineStringFeature, fragmentLength * 0.49).geometry + .coordinates; + const nextPoint = along(lineStringFeature, fragmentLength * 0.51).geometry + .coordinates; + let lineAngle = bearing(prevPoint, nextPoint); + lineAngle = -lineAngle + 90; + if (lineAngle < -90 || lineAngle > 90) { + lineAngle += 180; + } + + return [ + { + instanceKey: `${instance.instanceKey}/${partIndex}`, + id: instance.featureId, + diameter: instance.properties.diameter || 0, + length: instance.properties.length || fragmentLength * 1000, + path, + position: midPoint, + angle: lineAngle, + timestamps, + fragmentLength, + }, + ]; }); - if (uniqueNewData.length > 0) { - setJunctionDataState((prev) => prev.concat(uniqueNewData)); - setElevationRange((prev) => { - const elevations = uniqueNewData - .map((d) => d.elevation) - .filter((v) => typeof v === "number"); - if (elevations.length === 0) return prev; - - let newMin = elevations[0]; - let newMax = elevations[0]; - for (let i = 1; i < elevations.length; i++) { - if (elevations[i] < newMin) newMin = elevations[i]; - if (elevations[i] > newMax) newMax = elevations[i]; - } - - if (!prev) { - return [newMin, newMax]; - } - return [Math.min(prev[0], newMin), Math.max(prev[1], newMax)]; - }); - } - }; - - const setPipeData = (newData: any[]) => { - const uniqueNewData = newData.filter((item) => { - if (!item || !item.id) return false; - if (!pipeDataIds.current.has(item.id)) { - pipeDataIds.current.add(item.id); - return true; - } - return false; - }); - if (uniqueNewData.length > 0) { - setPipeDataState((prev) => prev.concat(uniqueNewData)); - setDiameterRange((prev) => { - const diameters = uniqueNewData - .map((d) => d.diameter) - .filter((v) => typeof v === "number"); - if (diameters.length === 0) return prev; - - let newMin = diameters[0]; - let newMax = diameters[0]; - for (let i = 1; i < diameters.length; i++) { - if (diameters[i] < newMin) newMin = diameters[i]; - if (diameters[i] > newMax) newMax = diameters[i]; - } - - if (!prev) { - return [newMin, newMax]; - } - return [Math.min(prev[0], newMin), Math.max(prev[1], newMax)]; - }); - } - }; - - const debouncedUpdateDataRef = useRef void> | null>( - null, - ); - - useEffect(() => { - debouncedUpdateDataRef.current = debounce(() => { - if (tileJunctionDataBuffer.current.length > 0) { - setJunctionData(tileJunctionDataBuffer.current); - tileJunctionDataBuffer.current = []; - } - if (tilePipeDataBuffer.current.length > 0) { - setPipeData(tilePipeDataBuffer.current); - tilePipeDataBuffer.current = []; - } - }, 100); - - return () => { - debouncedUpdateDataRef.current?.cancel(); - debouncedUpdateDataRef.current = null; - }; }, []); + + const publishActiveTileSnapshot = useCallback( + (targetMap: OlMap) => { + const zoom = targetMap.getView().getZoom() ?? 0; + const junctionSnapshot = junctionIndexRef.current?.getSnapshot( + targetMap, + zoom, + ); + const pipeSnapshot = pipeIndexRef.current?.getSnapshot(targetMap, zoom); + + const nextJunctionData = Array.from( + junctionSnapshot?.instancesById.values() ?? [], + ) + .map((instances) => instances[0]) + .filter(Boolean) + .map((instance) => { + const [x, y] = lineStringFromFlatCoordinates( + instance.flatCoordinates, + instance.stride, + )[0]; + return { + id: instance.featureId, + instanceKey: instance.instanceKey, + position: toLonLat([x, y]), + elevation: instance.properties.elevation || 0, + demand: instance.properties.demand || 0, + }; + }) + .sort((a, b) => String(a.id).localeCompare(String(b.id))); + + const nextPipeFragments = (pipeSnapshot?.instances ?? []) + .filter((instance) => instance.geometryType.includes("Line")) + .flatMap(buildPipeFragments) + .sort((a, b) => a.instanceKey.localeCompare(b.instanceKey)); + + const labelById = new Map(); + nextPipeFragments.forEach((fragment) => { + const previous = labelById.get(fragment.id); + if ( + !previous || + fragment.fragmentLength > previous.fragmentLength || + (fragment.fragmentLength === previous.fragmentLength && + fragment.instanceKey.localeCompare(previous.instanceKey) < 0) + ) { + labelById.set(fragment.id, fragment); + } + }); + const nextPipeLabels = Array.from(labelById.values()).sort((a, b) => + String(a.id).localeCompare(String(b.id)), + ); + + setJunctionDataState(nextJunctionData); + setPipeFragments(nextPipeFragments); + setPipeDataState(nextPipeLabels); + setElevationRange( + getNumericRange(nextJunctionData.map((item) => item.elevation)), + ); + setDiameterRange( + getNumericRange(nextPipeLabels.map((item) => item.diameter)), + ); + }, + [buildPipeFragments], + ); + const operationalSources = useMemo( + () => + createOperationalMapSources({ + mapUrl: MAP_URL, + workspace: MAP_WORKSPACE, + }), + [MAP_URL, MAP_WORKSPACE], + ); const operationalResources = useMemo( () => createOperationalMapResources({ @@ -380,8 +473,9 @@ const MapComponent: React.FC = ({ children }) => { workspace: MAP_WORKSPACE, extent: MAP_EXTENT, persistent: true, + sources: operationalSources, }), - [MAP_URL, MAP_WORKSPACE, MAP_EXTENT], + [MAP_URL, MAP_WORKSPACE, MAP_EXTENT, operationalSources], ); const { junctions: junctionSource, pipes: pipeSource } = operationalResources.sources; @@ -395,60 +489,15 @@ const MapComponent: React.FC = ({ children }) => { return; } isDisposingRef.current = false; - const activeJunctionDataIds = junctionDataIds.current; - const activePipeDataIds = pipeDataIds.current; - const addTimeout = (callback: () => void, delay: number) => { - const timerId = window.setTimeout(() => { - pendingTimeoutsRef.current = pendingTimeoutsRef.current.filter( - (id) => id !== timerId, - ); - if (isDisposingRef.current) return; - callback(); - }, delay); - pendingTimeoutsRef.current.push(timerId); - return timerId; - }; + junctionIndexRef.current = new TileFeatureIndex("junctions", junctionSource); + pipeIndexRef.current = new TileFeatureIndex("pipes", pipeSource); - const clearPendingTimeouts = () => { - pendingTimeoutsRef.current.forEach((id) => clearTimeout(id)); - pendingTimeoutsRef.current = []; - }; - - // 缓存 junction、pipe 数据,提供给 deck.gl 提供坐标供标签显示 const handleJunctionTileLoadEnd = (event: any) => { if (isDisposingRef.current) return; try { - if (event.tile instanceof VectorTile) { - const renderFeatures = event.tile.getFeatures(); - const data = new Map(); - - renderFeatures.forEach((renderFeature: any) => { - const props = renderFeature.getProperties(); - const featureId = props.id; - if (featureId && !junctionDataIds.current.has(featureId)) { - const geometry = renderFeature.getGeometry(); - if (geometry) { - const coordinates = geometry.getFlatCoordinates(); - const coordWGS84 = toLonLat(coordinates); - data.set(featureId, { - id: featureId, - position: coordWGS84, - elevation: props.elevation || 0, - demand: props.demand || 0, - }); - } - } - }); - - const uniqueData = Array.from(data.values()); - if (uniqueData.length > 0) { - uniqueData.forEach((item) => - tileJunctionDataBuffer.current.push(item), - ); - debouncedUpdateDataRef.current?.(); - } - } + junctionIndexRef.current?.registerTile(event.tile); + scheduleActiveTileSnapshot(); } catch (error) { console.error("Junction tile load error:", error); } @@ -456,86 +505,12 @@ const MapComponent: React.FC = ({ children }) => { const handlePipeTileLoadEnd = (event: any) => { if (isDisposingRef.current) return; try { - if (event.tile instanceof VectorTile) { - const renderFeatures = event.tile.getFeatures(); - const data = new Map(); - - renderFeatures.forEach((renderFeature: any) => { - try { - const props = renderFeature.getProperties(); - const featureId = props.id; - if (featureId && !pipeDataIds.current.has(featureId)) { - const geometry = renderFeature.getGeometry(); - if (geometry) { - const flatCoordinates = geometry.getFlatCoordinates(); - const stride = geometry.getStride(); // 获取步长,通常为 2 - // 重建为 LineString GeoJSON 格式的 coordinates: [[x1, y1], [x2, y2], ...] - const lineCoords = []; - for (let i = 0; i < flatCoordinates.length; i += stride) { - lineCoords.push([ - flatCoordinates[i], - flatCoordinates[i + 1], - ]); - } - const lineCoordsWGS84 = lineCoords.map((coord) => { - const [lon, lat] = toLonLat(coord); - return [lon, lat]; - }); - // 添加验证:确保至少有 2 个坐标点 - if (lineCoordsWGS84.length < 2) return; // 跳过此特征 - // 计算中点 - const lineStringFeature = lineString(lineCoordsWGS84); - const lineLength = length(lineStringFeature); - const midPoint = along(lineStringFeature, lineLength / 2) - .geometry.coordinates; - // 计算角度 - const prevPoint = along(lineStringFeature, lineLength * 0.49) - .geometry.coordinates; - const nextPoint = along(lineStringFeature, lineLength * 0.51) - .geometry.coordinates; - let lineAngle = bearing(prevPoint, nextPoint); - lineAngle = -lineAngle + 90; - if (lineAngle < -90 || lineAngle > 90) { - lineAngle += 180; - } - - // 计算时间戳(可选) - const numSegments = lineCoordsWGS84.length - 1; - const timestamps = [0]; - if (numSegments > 0) { - for (let i = 1; i <= numSegments; i++) { - timestamps.push((i / numSegments) * 10); - } - } - - data.set(featureId, { - id: featureId, - diameter: props.diameter || 0, - length: props.length || 0, - path: lineCoordsWGS84, // 使用重建后的坐标 - position: midPoint, - angle: lineAngle, - timestamps, - }); - } - } - } catch (geomError) { - console.error("Geometry calculation error:", geomError); - } - }); - - const uniqueData = Array.from(data.values()); - if (uniqueData.length > 0) { - uniqueData.forEach((item) => tilePipeDataBuffer.current.push(item)); - debouncedUpdateDataRef.current?.(); - } - } + pipeIndexRef.current?.registerTile(event.tile); + scheduleActiveTileSnapshot(); } catch (error) { console.error("Pipe tile load error:", error); } }; - junctionSource.on("tileloadend", handleJunctionTileLoadEnd); - pipeSource.on("tileloadend", handlePipeTileLoadEnd); // 监听 junctionsLayer 的 visible 变化 const handleJunctionVisibilityChange = () => { const isVisible = junctionsLayer.getVisible(); @@ -559,6 +534,12 @@ const MapComponent: React.FC = ({ children }) => { layers: operationalResources.orderedLayers.slice(), controls: [], }); + const scheduleActiveTileSnapshot = debounce( + () => publishActiveTileSnapshot(map), + 50, + ); + junctionSource.on("tileloadend", handleJunctionTileLoadEnd); + pipeSource.on("tileloadend", handlePipeTileLoadEnd); map.getInteractions().forEach(markMapResourcePersistent); setMap(map); @@ -596,14 +577,18 @@ const MapComponent: React.FC = ({ children }) => { duration: 1000, }); } - // 持久化视图(中心点 + 缩放),防抖写入 localStorage - const persistView = debounce(() => { + // 视图稳定后同步 Deck 数据并持久化,避免移动过程中重复扫描瓦片。 + const handleViewChange = debounce(() => { if (isDisposingRef.current) return; + const view = map.getView(); + const zoom = view.getZoom() || 0; + setCurrentZoom(zoom); + junctionIndexRef.current?.scanLoadedTiles(); + pipeIndexRef.current?.scanLoadedTiles(); + scheduleActiveTileSnapshot(); try { - const view = map.getView(); const center = view.getCenter(); - const zoom = view.getZoom(); - if (center && typeof zoom === "number") { + if (center) { localStorage.setItem( MAP_VIEW_STORAGE_KEY, JSON.stringify({ center, zoom }), @@ -613,21 +598,16 @@ const MapComponent: React.FC = ({ children }) => { console.warn("Save map view failed", err); } }, 250); - - // 监听缩放变化并持久化,同时更新 currentZoom - const handleViewChange = () => { - addTimeout(() => { - const zoom = map.getView().getZoom() || 0; - setCurrentZoom(zoom); - persistView(); - }, 250); - }; map.getView().on("change", handleViewChange); // 初始化当前缩放级别并强制触发瓦片加载 - addTimeout(() => { + const initializeTimer = window.setTimeout(() => { + if (isDisposingRef.current) return; const initialZoom = map.getView().getZoom() || 11; setCurrentZoom(initialZoom); + junctionIndexRef.current?.scanLoadedTiles(); + pipeIndexRef.current?.scanLoadedTiles(); + scheduleActiveTileSnapshot(); // 强制触发地图渲染,让瓦片加载事件触发 map.render(); }, 100); @@ -656,9 +636,9 @@ const MapComponent: React.FC = ({ children }) => { // 清理函数 return () => { isDisposingRef.current = true; - clearPendingTimeouts(); - debouncedUpdateDataRef.current?.cancel(); - persistView.cancel(); + window.clearTimeout(initializeTimer); + scheduleActiveTileSnapshot.cancel(); + handleViewChange.cancel(); junctionSource.un("tileloadend", handleJunctionTileLoadEnd); pipeSource.un("tileloadend", handlePipeTileLoadEnd); map.getView().un("change", handleViewChange); @@ -677,10 +657,11 @@ const MapComponent: React.FC = ({ children }) => { // React Strict Mode re-runs effects with the same memoized layer instances. // Detach and clear them here, but leave final layer/source disposal to GC. disposeMapResources(map, { disposeLayers: false }); - activeJunctionDataIds.clear(); - activePipeDataIds.clear(); - tileJunctionDataBuffer.current = []; - tilePipeDataBuffer.current = []; + junctionIndexRef.current = null; + pipeIndexRef.current = null; + setJunctionDataState([]); + setPipeDataState([]); + setPipeFragments([]); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [MAP_WORKSPACE, MAP_EXTENT]); @@ -699,6 +680,7 @@ const MapComponent: React.FC = ({ children }) => { mapUrl: MAP_URL, workspace: MAP_WORKSPACE, extent: MAP_EXTENT, + sources: operationalSources, }); const nextCompareMap = new OlMap({ target: compareMapRef.current, @@ -706,6 +688,8 @@ const MapComponent: React.FC = ({ children }) => { layers: compareResources.orderedLayers.slice(), controls: [], }); + const handleCompareRenderComplete = () => markCompareOpenReady(); + nextCompareMap.once("rendercomplete", handleCompareRenderComplete); nextCompareMap.getAllLayers().forEach((layer) => { const layerId = layer.get("value"); if (!layerId) return; @@ -748,6 +732,7 @@ const MapComponent: React.FC = ({ children }) => { return () => { isCompareDisposingRef.current = true; window.clearTimeout(resizeTimerId); + nextCompareMap.un("rendercomplete", handleCompareRenderComplete); if ( compareDeckLayerRef.current && !compareDeckLayerRef.current.isDisposedLayer() @@ -762,7 +747,7 @@ const MapComponent: React.FC = ({ children }) => { compareDeckLayerRef.current = null; setCompareDeckLayer(undefined); setCompareMap(undefined); - disposeMapResources(nextCompareMap); + disposeMapResources(nextCompareMap, { disposeSources: false }); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isCompareMode, map]); @@ -1015,11 +1000,11 @@ const MapComponent: React.FC = ({ children }) => { // 控制流动动画开关 useEffect(() => { - flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0; + const hasFlowData = pipeText === "flow" && currentPipeCalData.length > 0; const shouldShowWaterflow = isWaterflowLayerAvailable && showWaterflowLayer && - flowAnimation.current && + hasFlowData && currentZoom >= 12 && currentZoom <= 24; @@ -1027,13 +1012,13 @@ const MapComponent: React.FC = ({ children }) => { const syncWaterflowLayer = ( targetDeckLayer: DeckLayer | null, - targetPipeData: any[], + targetPipeFragments: any[], disposing: boolean, ) => { if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) { return; } - if (!shouldShowWaterflow || targetPipeData.length === 0) { + if (!shouldShowWaterflow || targetPipeFragments.length === 0) { targetDeckLayer.removeDeckLayer("waterflowLayer"); return; } @@ -1045,7 +1030,8 @@ const MapComponent: React.FC = ({ children }) => { const waterflowLayer = new TripsLayer({ id: "waterflowLayer", name: "水流", - data: targetPipeData, + data: targetPipeFragments, + getObjectId: (d: any) => d.instanceKey, getPath: (d) => d.path, getTimestamps: (d) => d.timestamps, getColor: [0, 220, 255], @@ -1067,13 +1053,13 @@ const MapComponent: React.FC = ({ children }) => { const animate = () => { syncWaterflowLayer( deckLayerRef.current, - mergedPipeData, + mergedPipeFragments, isDisposingRef.current, ); if (isCompareMode) { syncWaterflowLayer( compareDeckLayerRef.current, - mergedComparePipeData, + mergedComparePipeFragments, isCompareDisposingRef.current, ); } @@ -1092,8 +1078,8 @@ const MapComponent: React.FC = ({ children }) => { }, [ currentPipeCalData, currentZoom, - mergedPipeData, - mergedComparePipeData, + mergedPipeFragments, + mergedComparePipeFragments, isCompareMode, pipeText, isWaterflowLayerAvailable, diff --git a/src/components/olmap/core/layerStyleController.test.ts b/src/components/olmap/core/layerStyleController.test.ts new file mode 100644 index 0000000..116f614 --- /dev/null +++ b/src/components/olmap/core/layerStyleController.test.ts @@ -0,0 +1,87 @@ +jest.mock("ol/layer/WebGLVectorTile", () => ({ + __esModule: true, + default: class WebGLVectorTileLayer {}, +})); + +import { LayerStyleController } from "./layerStyleController"; + +describe("LayerStyleController", () => { + it("rebuilds only when the structural signature changes", () => { + const layer = { + getSource: () => null, + updateStyleVariables: jest.fn(), + setStyle: jest.fn(), + } as any; + const controller = new LayerStyleController({ + layer, + defaultStyle: { "stroke-color": "gray" }, + }); + const base = { + property: "pressure", + classCount: 5, + template: { "stroke-color": ["var", "tj_color_0"] } as any, + variables: { tj_color_0: "red" }, + }; + + controller.applyNative(base); + controller.applyNative({ ...base, variables: { tj_color_0: "blue" } }); + expect(layer.setStyle).toHaveBeenCalledTimes(1); + expect(layer.updateStyleVariables).toHaveBeenCalledTimes(2); + + controller.applyNative({ ...base, classCount: 6 }); + expect(layer.setStyle).toHaveBeenCalledTimes(2); + }); + + it("isolates primary and comparison values on a shared vector source", () => { + const properties: Record = { id: "J-1" }; + const feature = { + properties, + properties_: properties, + get: (key: string) => properties[key], + }; + const listeners = new Set<(event: any) => void>(); + const source = { + sourceTiles_: { loaded: { getFeatures: () => [feature] } }, + on: (_type: string, listener: (event: any) => void) => listeners.add(listener), + un: (_type: string, listener: (event: any) => void) => listeners.delete(listener), + }; + const createLayer = () => ({ + getSource: () => source, + on: jest.fn(), + un: jest.fn(), + getOpacity: () => 1, + setOpacity: jest.fn(), + setStyle: jest.fn(), + updateStyleVariables: jest.fn(), + }); + const primary = new LayerStyleController({ + layer: createLayer() as any, + defaultStyle: { "circle-fill-color": "gray" }, + stateNamespace: "primary", + }); + const compare = new LayerStyleController({ + layer: createLayer() as any, + defaultStyle: { "circle-fill-color": "gray" }, + stateNamespace: "compare", + }); + const options = { + property: "pressure", + classCount: 5, + template: { "circle-fill-color": ["get", "pressure"] } as any, + variables: {}, + }; + + primary.applyRuntime(options, new Map([["J-1", 0.25]])); + compare.applyRuntime(options, new Map([["J-1", 0.75]])); + + expect(properties.primary_pressure).toBe(0.25); + expect(properties.compare_pressure).toBe(0.75); + expect(listeners.size).toBe(2); + + primary.dispose(); + expect(listeners.size).toBe(1); + expect(properties.compare_pressure).toBe(0.75); + compare.dispose(); + expect(listeners.size).toBe(0); + }); +}); diff --git a/src/components/olmap/core/layerStyleController.ts b/src/components/olmap/core/layerStyleController.ts new file mode 100644 index 0000000..ed714f2 --- /dev/null +++ b/src/components/olmap/core/layerStyleController.ts @@ -0,0 +1,126 @@ +import type OlMap from "ol/Map"; +import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import type VectorTileSource from "ol/source/VectorTile"; +import type { FlatStyleLike, StyleVariables } from "ol/style/flat"; + +import { + buildVersionedFlatStyle, + sanitizeStyleIdentifier, + VectorTileStyleSession, +} from "./vectorTileStyleSession"; + +type FeatureStateValue = string | number | boolean | null; + +type ApplyStyleOptions = { + property: string; + classCount: number; + template: FlatStyleLike; + variables: StyleVariables; +}; + +type LayerStyleControllerOptions = { + layer: WebGLVectorTileLayer; + defaultStyle: FlatStyleLike; + map?: OlMap; + stateNamespace?: "primary" | "compare"; +}; + +export class LayerStyleController { + private readonly layer: WebGLVectorTileLayer; + private readonly source: VectorTileSource | null; + private readonly defaultStyle: FlatStyleLike; + private readonly map?: OlMap; + private readonly stateNamespace?: "primary" | "compare"; + private session: VectorTileStyleSession | null = null; + private templateSignature = ""; + + constructor(options: LayerStyleControllerOptions) { + this.layer = options.layer; + this.source = options.layer.getSource() as VectorTileSource | null; + this.defaultStyle = options.defaultStyle; + this.map = options.map; + this.stateNamespace = options.stateNamespace; + } + + applyNative(options: ApplyStyleOptions) { + this.disposeSession(); + const signature = `native:${options.property}:${options.classCount}`; + this.layer.updateStyleVariables(options.variables); + if (signature !== this.templateSignature) { + this.layer.setStyle(options.template); + this.templateSignature = signature; + } + } + + applyRuntime( + options: ApplyStyleOptions, + stateById: ReadonlyMap, + ) { + if (!this.source) return; + const signature = `runtime:${options.property}:${options.classCount}`; + const statePropertyKey = sanitizeStyleIdentifier( + this.stateNamespace + ? `${this.stateNamespace}_${options.property}` + : options.property, + ); + const buildStyle = ( + statePropertyKey: string, + versionKey: string, + version: number, + ) => + buildVersionedFlatStyle( + options.template, + this.defaultStyle, + options.property, + statePropertyKey, + versionKey, + version, + ); + + if (!this.session || this.session.propertyKey !== statePropertyKey) { + this.disposeSession(); + this.session = new VectorTileStyleSession({ + layer: this.layer, + source: this.source, + propertyKey: statePropertyKey, + defaultStyle: this.defaultStyle, + buildStyle, + variables: options.variables, + map: this.map, + buffered: Boolean(this.map), + }); + } else { + this.session.setBuildStyle(buildStyle); + } + this.session.updateStyleVariables(options.variables); + this.templateSignature = signature; + return this.session.commit(stateById); + } + + updateVariables(variables: StyleVariables) { + if (this.session) { + this.session.updateStyleVariables(variables); + } else { + this.layer.updateStyleVariables(variables); + } + } + + hasTemplate() { + return this.templateSignature.length > 0; + } + + reset() { + this.disposeSession(); + this.templateSignature = ""; + this.layer.setStyle(this.defaultStyle); + } + + dispose() { + this.disposeSession(); + } + + private disposeSession() { + this.session?.dispose(); + this.session = null; + } +} diff --git a/src/components/olmap/core/mapLifecycle.test.ts b/src/components/olmap/core/mapLifecycle.test.ts index 38c4056..4a31fc1 100644 --- a/src/components/olmap/core/mapLifecycle.test.ts +++ b/src/components/olmap/core/mapLifecycle.test.ts @@ -91,4 +91,29 @@ describe("map lifecycle", () => { expect(layer.dispose).not.toHaveBeenCalled(); expect(map.dispose).toHaveBeenCalledTimes(1); }); + + it("detaches a comparison map without clearing shared sources", () => { + const source = { clear: jest.fn(), dispose: jest.fn() }; + const layer = { ...createResource(), getSource: () => source }; + const layers = [layer]; + const interactions: ReturnType[] = []; + const controls: ReturnType[] = []; + const overlays: ReturnType[] = []; + const map = { + getLayers: () => createCollection(layers), + removeLayer: (resource: unknown) => layers.splice(layers.indexOf(resource as never), 1), + getInteractions: () => createCollection(interactions), + getControls: () => createCollection(controls), + getOverlays: () => createCollection(overlays), + setTarget: jest.fn(), + dispose: jest.fn(), + } as any; + + disposeMapResources(map, { disposeSources: false }); + + expect(source.clear).not.toHaveBeenCalled(); + expect(source.dispose).not.toHaveBeenCalled(); + expect(layer.dispose).toHaveBeenCalledTimes(1); + expect(map.dispose).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/olmap/core/mapLifecycle.ts b/src/components/olmap/core/mapLifecycle.ts index 8e6404c..dba46b4 100644 --- a/src/components/olmap/core/mapLifecycle.ts +++ b/src/components/olmap/core/mapLifecycle.ts @@ -28,25 +28,34 @@ const removeTransientResources = ( }); }; -const releaseLayer = (layer: any, dispose: boolean) => { +type ReleaseLayerOptions = { + disposeLayer: boolean; + disposeSource: boolean; +}; + +const releaseLayer = (layer: any, options: ReleaseLayerOptions) => { const childLayers = layer.getLayers?.().getArray?.(); if (Array.isArray(childLayers)) { - [...childLayers].forEach((childLayer) => releaseLayer(childLayer, dispose)); + [...childLayers].forEach((childLayer) => releaseLayer(childLayer, options)); layer.getLayers().clear(); } const source = layer.getSource?.(); - try { - source?.clear?.(); - } catch { - // Some third-party sources do not support explicit cache clearing. - } - if (dispose) { + if (options.disposeSource) { try { - source?.dispose?.(); + source?.clear?.(); } catch { - // Source may already be disposed by its owning layer. + // Some third-party sources do not support explicit cache clearing. } + if (options.disposeLayer) { + try { + source?.dispose?.(); + } catch { + // Source may already be disposed by its owning layer. + } + } + } + if (options.disposeLayer) { try { layer.dispose?.(); } catch { @@ -59,7 +68,7 @@ export const cleanupTransientMapResources = (map: OlMap) => { [...map.getLayers().getArray()].forEach((layer) => { if (isMapResourcePersistent(layer)) return; map.removeLayer(layer); - releaseLayer(layer, true); + releaseLayer(layer, { disposeLayer: true, disposeSource: true }); }); removeTransientResources(map.getInteractions().getArray(), (interaction) => @@ -75,12 +84,16 @@ export const cleanupTransientMapResources = (map: OlMap) => { export const disposeMapResources = ( map: OlMap, - options: { disposeLayers?: boolean } = {}, + options: { disposeLayers?: boolean; disposeSources?: boolean } = {}, ) => { const disposeLayers = options.disposeLayers ?? true; + const disposeSources = options.disposeSources ?? true; [...map.getLayers().getArray()].forEach((layer) => { map.removeLayer(layer); - releaseLayer(layer, disposeLayers); + releaseLayer(layer, { + disposeLayer: disposeLayers, + disposeSource: disposeSources, + }); }); map.getInteractions().clear(); map.getControls().clear(); diff --git a/src/components/olmap/core/operationalLayers.test.ts b/src/components/olmap/core/operationalLayers.test.ts new file mode 100644 index 0000000..5037c27 --- /dev/null +++ b/src/components/olmap/core/operationalLayers.test.ts @@ -0,0 +1,84 @@ +jest.mock("@turf/turf", () => ({ + along: jest.fn(), + lineString: jest.fn(), + length: jest.fn(), + toMercator: jest.fn(), +})); +jest.mock("ol/format/MVT", () => ({ __esModule: true, default: class MVT {} })); +jest.mock("ol/format/GeoJSON", () => ({ __esModule: true, default: class GeoJSON {} })); +jest.mock("ol/geom", () => ({ Point: class Point {} })); +jest.mock("ol/proj", () => ({ toLonLat: jest.fn() })); +jest.mock("ol/style", () => ({ Icon: class Icon {}, Style: class Style {} })); +jest.mock("ol/source/Vector", () => ({ + __esModule: true, + default: class VectorSource { + constructor(readonly options: unknown) {} + }, +})); +jest.mock("ol/source/VectorTile", () => ({ + __esModule: true, + default: class VectorTileSource { + constructor(readonly options: unknown) {} + }, +})); + +jest.mock("ol/layer/Vector", () => ({ + __esModule: true, + default: class MockVectorLayer { + private readonly source: unknown; + private readonly properties = new Map(); + constructor(options: any) { + this.source = options.source; + Object.entries(options.properties || {}).forEach(([key, value]) => + this.properties.set(key, value), + ); + } + getSource() { return this.source; } + get(key: string) { return this.properties.get(key); } + set(key: string, value: unknown) { this.properties.set(key, value); } + setStyle() {} + }, +})); +jest.mock("ol/layer/WebGLVectorTile", () => ({ + __esModule: true, + default: class MockWebGlVectorTileLayer { + private readonly source: unknown; + private readonly properties = new Map(); + constructor(options: any) { + this.source = options.source; + Object.entries(options.properties || {}).forEach(([key, value]) => + this.properties.set(key, value), + ); + } + getSource() { return this.source; } + get(key: string) { return this.properties.get(key); } + set(key: string, value: unknown) { this.properties.set(key, value); } + setStyle() {} + }, +})); + +import { + createOperationalMapResources, + createOperationalMapSources, +} from "./operationalLayers"; + +describe("operational map resources", () => { + it("shares sources while keeping per-map layer instances independent", () => { + const options = { + mapUrl: "https://maps.example.test/geoserver", + workspace: "test_workspace", + extent: [0, 0, 100, 100] as [number, number, number, number], + }; + const sources = createOperationalMapSources(options); + const primary = createOperationalMapResources({ ...options, sources }); + const compare = createOperationalMapResources({ ...options, sources }); + + Object.keys(sources).forEach((sourceId) => { + const key = sourceId as keyof typeof sources; + expect(primary.sources[key]).toBe(compare.sources[key]); + expect(primary.layers[key]).not.toBe(compare.layers[key]); + expect(primary.layers[key].getSource()).toBe(sources[key]); + expect(compare.layers[key].getSource()).toBe(sources[key]); + }); + }); +}); diff --git a/src/components/olmap/core/operationalLayers.ts b/src/components/olmap/core/operationalLayers.ts index 7f5ecba..0a2a128 100644 --- a/src/components/olmap/core/operationalLayers.ts +++ b/src/components/olmap/core/operationalLayers.ts @@ -17,11 +17,25 @@ import { markMapResourcePersistent } from "./mapLifecycle"; type MapExtent = [number, number, number, number]; -type CreateOperationalLayersOptions = { +type CreateOperationalMapSourcesOptions = { mapUrl: string; workspace: string; +}; + +type CreateOperationalLayersOptions = CreateOperationalMapSourcesOptions & { extent: MapExtent; persistent?: boolean; + sources?: OperationalMapSources; +}; + +export type OperationalMapSources = { + junctions: VectorTileSource; + pipes: VectorTileSource; + valves: VectorTileSource; + reservoirs: VectorSource; + pumps: VectorSource; + tanks: VectorSource; + scada: VectorSource; }; const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike; @@ -85,18 +99,16 @@ const pipeProperties = [ { name: "流速", value: "velocity" }, ]; -export const createOperationalMapResources = ({ +export const createOperationalMapSources = ({ mapUrl, workspace, - extent, - persistent = false, -}: CreateOperationalLayersOptions) => { +}: CreateOperationalMapSourcesOptions): OperationalMapSources => { const vectorTileUrl = (name: string) => `${mapUrl}/gwc/service/tms/1.0.0/${workspace}:${name}@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`; const vectorUrl = (name: string) => `${mapUrl}/${workspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspace}:${name}&outputFormat=application/json`; - const sources = { + return { junctions: new VectorTileSource({ url: vectorTileUrl("geo_junctions"), format: new MVT(), @@ -129,6 +141,15 @@ export const createOperationalMapResources = ({ format: new GeoJSON(), }), }; +}; + +export const createOperationalMapResources = ({ + mapUrl, + workspace, + extent, + persistent = false, + sources = createOperationalMapSources({ mapUrl, workspace }), +}: CreateOperationalLayersOptions) => { const layers = { junctions: new WebGLVectorTileLayer({ diff --git a/src/components/olmap/core/tileFeatureIndex.test.ts b/src/components/olmap/core/tileFeatureIndex.test.ts new file mode 100644 index 0000000..617533d --- /dev/null +++ b/src/components/olmap/core/tileFeatureIndex.test.ts @@ -0,0 +1,101 @@ +jest.mock("ol/extent", () => ({ + intersects: (a: number[], b: number[]) => + a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1], +})); + +jest.mock("ol/proj", () => ({ + toLonLat: (coordinate: number[]) => coordinate, +})); + +import { + TileFeatureIndex, + clipLineStringPartsToExtent, + lineStringFromFlatCoordinates, +} from "./tileFeatureIndex"; + +describe("tileFeatureIndex geometry helpers", () => { + it("clips a line to the tile core extent without dropping both sides", () => { + const clipped = clipLineStringPartsToExtent( + [ + [-10, 5], + [5, 5], + [15, 5], + ], + [0, 0, 10, 10], + ); + + expect(clipped).toEqual([ + [ + [0, 5], + [5, 5], + [10, 5], + ], + ]); + }); + + it("preserves all coordinates from a flat line with stride", () => { + expect(lineStringFromFlatCoordinates([0, 1, 2, 3, 4, 5], 2)).toEqual([ + [0, 1], + [2, 3], + [4, 5], + ]); + }); + + it("keeps disconnected clipped line parts separate", () => { + expect( + clipLineStringPartsToExtent( + [ + [-5, 2], + [5, 2], + [15, 2], + [15, 8], + [5, 8], + [-5, 8], + ], + [0, 0, 10, 10], + ), + ).toEqual([ + [ + [0, 2], + [5, 2], + [10, 2], + ], + [ + [10, 8], + [5, 8], + [0, 8], + ], + ]); + }); + + it("keeps multiple tile instances for the same feature id", () => { + const makeFeature = (flatCoordinates: number[]) => ({ + getProperties: () => ({ id: "P-1", diameter: 100 }), + getGeometry: () => ({ + getType: () => "LineString", + getFlatCoordinates: () => flatCoordinates, + getStride: () => 2, + }), + }); + const makeTile = (x: number, flatCoordinates: number[]) => ({ + getTileCoord: () => [14, x, 5], + getFeatures: () => [makeFeature(flatCoordinates)], + }); + const source = { + sourceTiles_: { + a: makeTile(1, [0, 0, 10, 0]), + b: makeTile(2, [10, 0, 20, 0]), + }, + getTileGrid: () => ({ + getTileCoordExtent: ([, x]: [number, number, number]) => + x === 1 ? [0, -10, 10, 10] : [10, -10, 20, 10], + }), + } as any; + + const index = new TileFeatureIndex("pipes", source); + const snapshot = index.getSnapshot(undefined, 14); + + expect(snapshot.instances).toHaveLength(2); + expect(snapshot.instancesById.get("P-1")).toHaveLength(2); + }); +}); diff --git a/src/components/olmap/core/tileFeatureIndex.ts b/src/components/olmap/core/tileFeatureIndex.ts new file mode 100644 index 0000000..c941076 --- /dev/null +++ b/src/components/olmap/core/tileFeatureIndex.ts @@ -0,0 +1,322 @@ +import { intersects } from "ol/extent"; +import type { Extent } from "ol/extent"; +import type OlMap from "ol/Map"; +import { toLonLat } from "ol/proj"; +import type VectorTileSource from "ol/source/VectorTile"; +import type { Coordinate } from "ol/coordinate"; +import { + getLoadedVectorTiles, + getVectorTileFeatureId, + getVectorTileFeatureProperties, +} from "./vectorTileUtils"; + +type TileKey = string; + +export type TileFeatureInstance = { + instanceKey: string; + z: number; + featureId: string; + properties: Record; + geometryType: string; + tileExtent: Extent; + flatCoordinates: number[]; + stride: number; +}; + +type TileFeatureSnapshot = { + instancesById: Map; + instances: TileFeatureInstance[]; +}; + +const getTileCoord = (tile: any): [number, number, number] | null => { + const coord = + typeof tile.getTileCoord === "function" + ? tile.getTileCoord() + : tile.tileCoord ?? tile.tileCoord_; + if (!Array.isArray(coord) || coord.length < 3) return null; + return [Number(coord[0]), Number(coord[1]), Number(coord[2])]; +}; + +const getTileKey = (sourceKey: string, z: number, x: number, y: number) => + `${sourceKey}/${z}/${x}/${y}`; + +const getInstanceKey = ( + sourceKey: string, + z: number, + x: number, + y: number, + featureOrdinal: number, +) => `${sourceKey}/${z}/${x}/${y}/${featureOrdinal}`; + +const normalizeExtent = (extent: Extent): Extent => [ + Math.min(extent[0], extent[2]), + Math.min(extent[1], extent[3]), + Math.max(extent[0], extent[2]), + Math.max(extent[1], extent[3]), +]; + +const getTileGrid = (source: VectorTileSource, map?: OlMap) => { + const sourceAny = source as any; + const projection = map?.getView().getProjection(); + if (projection && typeof sourceAny.getTileGridForProjection === "function") { + return sourceAny.getTileGridForProjection(projection); + } + return typeof sourceAny.getTileGrid === "function" + ? sourceAny.getTileGrid() + : sourceAny.tileGrid ?? sourceAny.tileGrid_; +}; + +const getTileExtent = ( + source: VectorTileSource, + tile: any, + z: number, + x: number, + y: number, +): Extent | null => { + const tileGrid = getTileGrid(source); + if (tileGrid && typeof tileGrid.getTileCoordExtent === "function") { + return normalizeExtent(tileGrid.getTileCoordExtent([z, x, y]) as Extent); + } + const extent = + typeof tile.getExtent === "function" + ? tile.getExtent() + : tile.extent ?? tile.extent_; + return Array.isArray(extent) ? normalizeExtent(extent as Extent) : null; +}; + +const getFeatureGeometry = (renderFeature: any) => { + if (typeof renderFeature.getGeometry === "function") { + return renderFeature.getGeometry(); + } + return renderFeature; +}; + +const getGeometryType = (geometry: any): string => { + if (typeof geometry.getType === "function") return String(geometry.getType()); + if (typeof geometry.getType === "string") return geometry.getType; + return ""; +}; + +const getFlatCoordinates = (geometry: any): number[] => { + if (typeof geometry.getFlatCoordinates === "function") { + return Array.from(geometry.getFlatCoordinates() ?? []); + } + if (Array.isArray(geometry.flatCoordinates)) { + return geometry.flatCoordinates.slice(); + } + if (Array.isArray(geometry.flatCoordinates_)) { + return geometry.flatCoordinates_.slice(); + } + return []; +}; + +const getStride = (geometry: any) => + typeof geometry.getStride === "function" + ? Number(geometry.getStride()) + : Number(geometry.stride ?? geometry.stride_ ?? 2); + +export const lineStringFromFlatCoordinates = ( + flatCoordinates: number[], + stride: number, +): Coordinate[] => { + const coordinates: Coordinate[] = []; + for (let i = 0; i + 1 < flatCoordinates.length; i += stride) { + coordinates.push([flatCoordinates[i], flatCoordinates[i + 1]]); + } + return coordinates; +}; + +const clipSegmentToExtent = ( + start: Coordinate, + end: Coordinate, + extent: Extent, +): [Coordinate, Coordinate] | null => { + const [minX, minY, maxX, maxY] = extent; + const dx = end[0] - start[0]; + const dy = end[1] - start[1]; + let t0 = 0; + let t1 = 1; + + const update = (p: number, q: number) => { + if (p === 0) return q >= 0; + const r = q / p; + if (p < 0) { + if (r > t1) return false; + if (r > t0) t0 = r; + } else { + if (r < t0) return false; + if (r < t1) t1 = r; + } + return true; + }; + + if ( + !update(-dx, start[0] - minX) || + !update(dx, maxX - start[0]) || + !update(-dy, start[1] - minY) || + !update(dy, maxY - start[1]) + ) { + return null; + } + + return [ + [start[0] + t0 * dx, start[1] + t0 * dy], + [start[0] + t1 * dx, start[1] + t1 * dy], + ]; +}; + +const sameCoordinate = (a: Coordinate, b: Coordinate) => + a[0] === b[0] && a[1] === b[1]; + +export const clipLineStringPartsToExtent = ( + coordinates: Coordinate[], + extent: Extent, +): Coordinate[][] => { + const parts: Coordinate[][] = []; + let currentPart: Coordinate[] = []; + for (let i = 1; i < coordinates.length; i += 1) { + const segment = clipSegmentToExtent( + coordinates[i - 1], + coordinates[i], + extent, + ); + if (!segment) { + if (currentPart.length >= 2) parts.push(currentPart); + currentPart = []; + continue; + } + const [start, end] = segment; + if ( + currentPart.length > 0 && + !sameCoordinate(currentPart[currentPart.length - 1], start) + ) { + if (currentPart.length >= 2) parts.push(currentPart); + currentPart = []; + } + if ( + currentPart.length === 0 || + !sameCoordinate(currentPart[currentPart.length - 1], start) + ) { + currentPart.push(start); + } + if (!sameCoordinate(start, end)) { + currentPart.push(end); + } + } + if (currentPart.length >= 2) parts.push(currentPart); + return parts; +}; + +export const coordinatesToLonLat = ( + coordinates: Coordinate[], +): [number, number][] => + coordinates.map((coordinate) => { + const [lon, lat] = toLonLat(coordinate); + return [lon, lat]; + }); + +export class TileFeatureIndex { + private readonly instancesByTile = new Map< + TileKey, + TileFeatureInstance[] + >(); + + constructor( + private readonly sourceKey: string, + private readonly source: VectorTileSource, + ) { + this.scanLoadedTiles(); + } + + scanLoadedTiles() { + const activeTileKeys = new Set(); + getLoadedVectorTiles(this.source).forEach((tile) => { + const tileKey = this.registerTile(tile); + if (tileKey) activeTileKeys.add(tileKey); + }); + this.pruneMissingTiles(activeTileKeys); + } + + registerTile(tile: any): TileKey | null { + if (typeof tile?.getFeatures !== "function") return null; + const coord = getTileCoord(tile); + if (!coord) return null; + const [z, x, y] = coord; + const extent = getTileExtent(this.source, tile, z, x, y); + if (!extent) return null; + + const tileKey = getTileKey(this.sourceKey, z, x, y); + const tileInstances: TileFeatureInstance[] = []; + const renderFeatures = tile.getFeatures() ?? []; + renderFeatures.forEach((renderFeature: any, featureOrdinal: number) => { + const properties = getVectorTileFeatureProperties(renderFeature); + const featureId = getVectorTileFeatureId(renderFeature); + if (!featureId) return; + const geometry = getFeatureGeometry(renderFeature); + if (!geometry) return; + const flatCoordinates = getFlatCoordinates(geometry); + const stride = getStride(geometry); + if (flatCoordinates.length < 2 || stride < 2) return; + + const instanceKey = getInstanceKey( + this.sourceKey, + z, + x, + y, + featureOrdinal, + ); + tileInstances.push({ + instanceKey, + z, + featureId, + properties, + geometryType: getGeometryType(geometry), + tileExtent: extent, + flatCoordinates, + stride, + }); + }); + + if (tileInstances.length === 0) { + this.instancesByTile.delete(tileKey); + } else { + this.instancesByTile.set(tileKey, tileInstances); + } + return tileKey; + } + + private pruneMissingTiles(activeTileKeys: Set) { + Array.from(this.instancesByTile.keys()).forEach((tileKey) => { + if (!activeTileKeys.has(tileKey)) { + this.instancesByTile.delete(tileKey); + } + }); + } + + getSnapshot(map: OlMap | undefined, zoom: number): TileFeatureSnapshot { + const tileGrid = getTileGrid(this.source, map); + const resolution = map?.getView().getResolution(); + const targetZ = + tileGrid && resolution !== undefined + ? tileGrid.getZForResolution(resolution, (this.source as any).zDirection) + : Math.max(0, Math.round(zoom)); + const viewExtent = map?.getView().calculateExtent(map.getSize()); + const instances = Array.from(this.instancesByTile.values()) + .flat() + .filter((instance) => instance.z === targetZ) + .filter( + (instance) => + !viewExtent || intersects(instance.tileExtent, viewExtent), + ) + .sort((a, b) => a.instanceKey.localeCompare(b.instanceKey)); + + const instancesById = new Map(); + instances.forEach((instance) => { + const featureInstances = instancesById.get(instance.featureId); + if (featureInstances) featureInstances.push(instance); + else instancesById.set(instance.featureId, [instance]); + }); + + return { instancesById, instances }; + } +} diff --git a/src/components/olmap/core/vectorTileStyleSession.test.ts b/src/components/olmap/core/vectorTileStyleSession.test.ts new file mode 100644 index 0000000..29a6c6d --- /dev/null +++ b/src/components/olmap/core/vectorTileStyleSession.test.ts @@ -0,0 +1,245 @@ +jest.mock("ol/layer/WebGLVectorTile", () => ({ + __esModule: true, + default: class WebGLVectorTileLayer { + opacity: number; + visible: boolean; + renderer = { renderComplete: true }; + source: any; + style: any; + properties: Record; + constructor(options: any = {}) { + this.opacity = options.opacity ?? 1; + this.visible = options.visible ?? true; + this.source = options.source; + this.style = options.style; + this.properties = options.properties ?? {}; + } + getOpacity() { return this.opacity; } + setOpacity(value: number) { this.opacity = value; } + getVisible() { return this.visible; } + setVisible(value: boolean) { this.visible = value; } + getExtent() { return undefined; } + getMinZoom() { return 0; } + getMaxZoom() { return 24; } + getMinResolution() { return 0; } + getMaxResolution() { return Infinity; } + getPreload() { return 0; } + setPreload() {} + getZIndex() { return undefined; } + setZIndex() {} + on() {} + un() {} + setStyle(style: any) { + this.style = style; + this.renderer = { renderComplete: true }; + } + updateStyleVariables() {} + getRenderer() { return this.renderer; } + dispose() {} + }, +})); + +import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import { VectorTileStyleSession } from "./vectorTileStyleSession"; + +const makeFeature = (id: string) => { + const properties: Record = { id }; + return { + properties, + properties_: properties, + get: (key: string) => properties[key], + }; +}; + +const makeTile = (...features: ReturnType[]) => ({ + getFeatures: () => features, +}); + +describe("VectorTileStyleSession", () => { + it("fans one feature state out to every loaded and future tile instance", () => { + const featureA = makeFeature("P-1"); + const featureB = makeFeature("P-1"); + const listeners = new Set<(event: any) => void>(); + const source = { + sourceTiles_: { + a: makeTile(featureA), + b: makeTile(featureB), + }, + on: (_type: string, listener: (event: any) => void) => listeners.add(listener), + un: (_type: string, listener: (event: any) => void) => listeners.delete(listener), + } as any; + const layer = { + on: jest.fn(), + un: jest.fn(), + getOpacity: () => 1, + setOpacity: jest.fn(), + setStyle: jest.fn(), + } as any; + const session = new VectorTileStyleSession({ + layer, + source, + propertyKey: "healthRisk", + defaultStyle: { "stroke-color": "blue" }, + buildStyle: () => ({ "stroke-color": "red" }), + }); + + session.commit(new Map([["P-1", 0.25]])); + + expect(featureA.properties.healthRisk).toBe(0.25); + expect(featureB.properties.healthRisk).toBe(0.25); + expect(layer.setStyle).toHaveBeenCalledTimes(1); + + const futureFeature = makeFeature("P-1"); + listeners.forEach((listener) => listener({ tile: makeTile(futureFeature) })); + expect(futureFeature.properties.healthRisk).toBe(0.25); + + session.commit(new Map()); + expect(featureA.properties).not.toHaveProperty("healthRisk"); + expect(featureB.properties).not.toHaveProperty("healthRisk"); + + session.dispose(); + expect(listeners.size).toBe(0); + }); + + it("keeps the active layer visible until the standby renderer is ready", async () => { + const feature = makeFeature("P-1"); + const listeners = new Set<(event: any) => void>(); + const source = { + sourceTiles_: { a: makeTile(feature) }, + on: (_type: string, listener: (event: any) => void) => listeners.add(listener), + un: (_type: string, listener: (event: any) => void) => listeners.delete(listener), + } as any; + const baseLayer = new WebGLVectorTileLayer({ + source, + style: { "stroke-color": "blue" }, + }) as any; + const layers = [baseLayer]; + const postrenderListeners = new Set<() => void>(); + const map = { + getLayers: () => ({ + getArray: () => layers, + getLength: () => layers.length, + insertAt: (index: number, layer: any) => layers.splice(index, 0, layer), + }), + removeLayer: (layer: any) => { + const index = layers.indexOf(layer); + if (index >= 0) layers.splice(index, 1); + }, + on: (_type: string, listener: () => void) => postrenderListeners.add(listener), + un: (_type: string, listener: () => void) => postrenderListeners.delete(listener), + render: () => postrenderListeners.forEach((listener) => listener()), + } as any; + const styleKeys: string[] = []; + const session = new VectorTileStyleSession({ + layer: baseLayer, + source, + map, + buffered: true, + propertyKey: "healthRisk", + defaultStyle: { "stroke-color": "blue" }, + buildStyle: (propertyKey, versionKey) => { + styleKeys.push(propertyKey, versionKey); + return { "stroke-color": "red" }; + }, + }); + + const commit = session.commit(new Map([["P-1", 0.25]])); + expect(baseLayer.getOpacity()).toBe(1); + await commit; + + expect(layers).toHaveLength(2); + expect(baseLayer.getOpacity()).toBe(0); + expect(layers[1].getOpacity()).toBe(1); + expect(styleKeys).toEqual([ + "healthRisk_buffer_1", + "healthRisk_render_version_buffer_1", + ]); + expect(styleKeys.every((key) => !key.includes("__"))).toBe(true); + session.dispose(); + expect(layers).toHaveLength(1); + }); + + it("does not replace a renderer while an obsolete buffer is still building", async () => { + const feature = makeFeature("P-1"); + const source = { + sourceTiles_: { a: makeTile(feature) }, + on: jest.fn(), + un: jest.fn(), + } as any; + const baseLayer = new WebGLVectorTileLayer({ + source, + style: { "stroke-color": "blue" }, + }) as any; + const layers = [baseLayer]; + const postrenderListeners = new Set<() => void>(); + const map = { + getLayers: () => ({ + getArray: () => layers, + getLength: () => layers.length, + insertAt: (index: number, layer: any) => layers.splice(index, 0, layer), + }), + removeLayer: jest.fn(), + on: (_type: string, listener: () => void) => postrenderListeners.add(listener), + un: (_type: string, listener: () => void) => postrenderListeners.delete(listener), + render: jest.fn(), + } as any; + const session = new VectorTileStyleSession({ + layer: baseLayer, + source, + map, + buffered: true, + propertyKey: "healthRisk", + defaultStyle: { "stroke-color": "blue" }, + buildStyle: () => ({ "stroke-color": "red" }), + }); + + const firstCommit = session.commit(new Map([["P-1", 0.25]])); + await Promise.resolve(); + await Promise.resolve(); + const standbyLayer = layers[1] as any; + standbyLayer.renderer = { renderComplete: false }; + const setStyle = jest.spyOn(standbyLayer, "setStyle"); + + const latestCommit = session.commit(new Map([["P-1", 0.75]])); + expect(setStyle).not.toHaveBeenCalled(); + + postrenderListeners.forEach((listener) => listener()); + await expect(firstCommit).resolves.toBe(false); + await Promise.resolve(); + await Promise.resolve(); + expect(setStyle).toHaveBeenCalledTimes(1); + + postrenderListeners.forEach((listener) => listener()); + postrenderListeners.forEach((listener) => listener()); + await expect(latestCommit).resolves.toBe(true); + expect(feature.properties.healthRisk_buffer_1).toBe(0.75); + }); + + it("normalizes generated shader identifiers and mirrors variable updates", () => { + const source = { sourceTiles_: {}, on: jest.fn(), un: jest.fn() } as any; + const layer = { + on: jest.fn(), + un: jest.fn(), + getOpacity: () => 1, + setOpacity: jest.fn(), + setStyle: jest.fn(), + updateStyleVariables: jest.fn(), + } as any; + const keys: string[] = []; + const session = new VectorTileStyleSession({ + layer, + source, + propertyKey: "healthRisk__unsafe", + defaultStyle: { "stroke-color": "gray" }, + buildStyle: (propertyKey, versionKey) => { + keys.push(propertyKey, versionKey); + return { "stroke-color": "red" }; + }, + }); + session.updateStyleVariables({ tj_color_0: "red" }); + session.commit(new Map()); + + expect(layer.updateStyleVariables).toHaveBeenCalledWith({ tj_color_0: "red" }); + expect(keys.every((key) => !key.includes("__"))).toBe(true); + }); +}); diff --git a/src/components/olmap/core/vectorTileStyleSession.ts b/src/components/olmap/core/vectorTileStyleSession.ts new file mode 100644 index 0000000..47756f8 --- /dev/null +++ b/src/components/olmap/core/vectorTileStyleSession.ts @@ -0,0 +1,414 @@ +import type OlMap from "ol/Map"; +import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import type VectorTileSource from "ol/source/VectorTile"; +import type { FlatStyleLike, StyleVariables } from "ol/style/flat"; +import { + getLoadedVectorTiles, + getVectorTileFeatureId, + getVectorTileFeatureProperties, +} from "./vectorTileUtils"; + +type FeatureStateValue = string | number | boolean | null; +type StyleBuilder = ( + propertyKey: string, + versionKey: string, + version: number, +) => FlatStyleLike; + +type VectorTileStyleSessionOptions = { + layer: WebGLVectorTileLayer; + source: VectorTileSource; + propertyKey: string; + defaultStyle: FlatStyleLike; + buildStyle: StyleBuilder; + map?: OlMap; + buffered?: boolean; + variables?: StyleVariables; +}; + +const INTERNAL_BUFFER_LAYER = "internalRenderBuffer"; +const BUFFER_READY_TIMEOUT_MS = 5000; + +export class VectorTileStyleSession { + private readonly layer: WebGLVectorTileLayer; + private readonly source: VectorTileSource; + readonly propertyKey: string; + private readonly versionKey: string; + private readonly defaultStyle: FlatStyleLike; + private readonly map?: OlMap; + private readonly buffered: boolean; + private buildStyle: StyleBuilder; + private styleVariables: StyleVariables; + private unbufferedState = new Map(); + private version = 0; + private disposed = false; + private commitRevision = 0; + private bufferedCommitQueue: Promise = Promise.resolve(); + private readonly slotStates = [ + new Map(), + new Map(), + ]; + private readonly slotVersions = [0, 0]; + private activeSlot = 0; + private activeLayer: WebGLVectorTileLayer; + private bufferLayer: WebGLVectorTileLayer | null = null; + private readonly displayOpacity: number; + private readonly listener: (event: any) => void; + private readonly visibilityListener: () => void; + + constructor(options: VectorTileStyleSessionOptions) { + this.layer = options.layer; + this.source = options.source; + this.propertyKey = sanitizeStyleIdentifier(options.propertyKey); + this.versionKey = `${this.propertyKey}_render_version`; + this.defaultStyle = options.defaultStyle; + this.buildStyle = options.buildStyle; + this.styleVariables = { ...(options.variables || {}) }; + this.map = options.map; + this.buffered = Boolean(options.buffered && options.map); + this.activeLayer = this.layer; + this.displayOpacity = this.layer.getOpacity(); + this.listener = (event: any) => { + try { + if (typeof event.tile?.getFeatures !== "function") return; + this.applyTile(event.tile); + } catch (error) { + console.error("Vector tile style session load error:", error); + } + }; + this.visibilityListener = () => { + this.bufferLayer?.setVisible(this.layer.getVisible()); + }; + this.source.on("tileloadend", this.listener); + this.layer.on("change:visible", this.visibilityListener); + } + + commit( + stateById: ReadonlyMap, + ): Promise | void { + if (this.disposed) return; + if (this.buffered) { + return this.commitBuffered(stateById, false); + } + + this.version += 1; + this.unbufferedState = this.normalizeState(stateById); + this.applyLoadedTiles(); + this.layer.setStyle( + this.buildStyle(this.propertyKey, this.versionKey, this.version), + ); + } + + setBuildStyle(buildStyle: StyleBuilder) { + this.buildStyle = buildStyle; + } + + updateStyleVariables(variables: StyleVariables) { + if (this.disposed) return; + this.styleVariables = { ...this.styleVariables, ...variables }; + this.layer.updateStyleVariables(variables); + this.bufferLayer?.updateStyleVariables(variables); + } + + reset(): Promise | void { + if (this.disposed) return; + if (this.buffered) { + return this.commitBuffered(new Map(), true); + } + this.version += 1; + this.unbufferedState = new Map(); + this.applyLoadedTiles(); + this.layer.setStyle(this.defaultStyle); + } + + dispose() { + if (this.disposed) return; + this.commitRevision += 1; + this.source.un("tileloadend", this.listener); + this.layer.un("change:visible", this.visibilityListener); + if (this.bufferLayer && this.map) { + this.map.removeLayer(this.bufferLayer); + this.bufferLayer.dispose(); + this.bufferLayer = null; + } + this.layer.setOpacity(this.displayOpacity); + this.disposed = true; + } + + private normalizeState(stateById: ReadonlyMap) { + return new Map( + Array.from(stateById.entries()).map(([id, value]) => [String(id), value]), + ); + } + + private getSlotPropertyKey(slot: number) { + return `${this.propertyKey}_buffer_${slot}`; + } + + private getSlotVersionKey(slot: number) { + return `${this.versionKey}_buffer_${slot}`; + } + + private commitBuffered( + stateById: ReadonlyMap, + useDefaultStyle: boolean, + ) { + if (!this.map || this.disposed) return Promise.resolve(false); + const revision = this.commitRevision + 1; + this.commitRevision = revision; + const normalizedState = this.normalizeState(stateById); + const commitPromise = this.bufferedCommitQueue.then(() => { + if (this.disposed || revision !== this.commitRevision) return false; + return this.prepareBufferedCommit( + normalizedState, + useDefaultStyle, + revision, + ); + }); + this.bufferedCommitQueue = commitPromise.then( + () => undefined, + () => undefined, + ); + + // Wake a pending readiness check so an obsolete build can exit before the + // next style replaces its renderer. + this.map.render(); + return commitPromise; + } + + private async prepareBufferedCommit( + stateById: Map, + useDefaultStyle: boolean, + revision: number, + ) { + if (!this.map || this.disposed || revision !== this.commitRevision) { + return false; + } + const targetSlot = this.activeSlot === 0 ? 1 : 0; + this.version += 1; + this.slotVersions[targetSlot] = this.version; + this.slotStates[targetSlot] = stateById; + this.applyLoadedTiles(); + + const standbyLayer = this.getStandbyLayer(); + standbyLayer.setVisible(this.layer.getVisible()); + standbyLayer.setOpacity(0); + standbyLayer.setStyle( + useDefaultStyle + ? this.defaultStyle + : this.buildStyle( + this.getSlotPropertyKey(targetSlot), + this.getSlotVersionKey(targetSlot), + this.version, + ), + ); + this.map.render(); + + const ready = await this.waitUntilReady(standbyLayer, revision); + if (!ready || this.disposed || revision !== this.commitRevision) { + return false; + } + + const previousLayer = this.activeLayer; + standbyLayer.setOpacity(this.displayOpacity); + previousLayer.setOpacity(0); + this.activeLayer = standbyLayer; + this.activeSlot = targetSlot; + this.map.render(); + return true; + } + + private getStandbyLayer(): WebGLVectorTileLayer { + const map = this.map; + if (!map) return this.layer; + if (!this.bufferLayer) { + const bufferLayer = new WebGLVectorTileLayer({ + source: this.source, + style: this.defaultStyle, + extent: this.layer.getExtent(), + minZoom: this.layer.getMinZoom(), + maxZoom: this.layer.getMaxZoom(), + minResolution: this.layer.getMinResolution(), + maxResolution: this.layer.getMaxResolution(), + opacity: 0, + visible: this.layer.getVisible(), + variables: this.styleVariables, + properties: { + [INTERNAL_BUFFER_LAYER]: true, + queryable: false, + }, + }); + bufferLayer.setPreload(this.layer.getPreload()); + const zIndex = this.layer.getZIndex(); + if (zIndex !== undefined) bufferLayer.setZIndex(zIndex); + const layers = map.getLayers(); + const baseIndex = layers.getArray().indexOf(this.layer); + layers.insertAt(baseIndex >= 0 ? baseIndex + 1 : layers.getLength(), bufferLayer); + this.bufferLayer = bufferLayer; + } + return this.activeLayer === this.layer ? this.bufferLayer! : this.layer; + } + + private waitUntilReady(layer: WebGLVectorTileLayer, revision: number) { + if (!this.map) return Promise.resolve(true); + return new Promise((resolve) => { + let consecutiveReadyFrames = 0; + let settled = false; + const finish = (ready: boolean) => { + if (settled) return; + settled = true; + window.clearTimeout(timeoutId); + this.map?.un("postrender", checkReady); + resolve(ready); + }; + const checkReady = () => { + if (this.disposed || revision !== this.commitRevision) { + finish(false); + return; + } + const renderer = layer.getRenderer() as any; + consecutiveReadyFrames = renderer?.renderComplete + ? consecutiveReadyFrames + 1 + : 0; + if (consecutiveReadyFrames >= 2) { + finish(true); + return; + } + this.map?.render(); + }; + const timeoutId = window.setTimeout( + () => finish(false), + BUFFER_READY_TIMEOUT_MS, + ); + this.map!.on("postrender", checkReady); + this.map!.render(); + }); + } + + private applyLoadedTiles() { + getLoadedVectorTiles(this.source).forEach((tile) => this.applyTile(tile)); + } + + private applyTile(tile: any) { + if (this.buffered) { + this.slotVersions.forEach((version, slot) => { + if (version > 0) { + this.applyTileState( + tile, + this.slotStates[slot], + this.getSlotPropertyKey(slot), + this.getSlotVersionKey(slot), + version, + ); + } + }); + return; + } + this.applyTileState( + tile, + this.unbufferedState, + this.propertyKey, + this.versionKey, + this.version, + ); + } + + private applyTileState( + tile: any, + stateById: ReadonlyMap, + propertyKey: string, + versionKey: string, + version: number, + ) { + const renderFeatures = tile.getFeatures?.(); + if (!renderFeatures || renderFeatures.length === 0) return; + renderFeatures.forEach((renderFeature: any) => { + const featureId = getVectorTileFeatureId(renderFeature); + if (!featureId) return; + const properties = getVectorTileFeatureProperties(renderFeature); + const value = stateById.get(featureId); + if (value === undefined) { + delete properties[propertyKey]; + properties[versionKey] = version; + return; + } + properties[propertyKey] = value; + properties[versionKey] = version; + }); + } +} + +export const sanitizeStyleIdentifier = (value: string) => { + const normalized = value + .replace(/[^A-Za-z0-9_]/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + const prefixed = /^[A-Za-z]/.test(normalized) ? normalized : `tj_${normalized}`; + return prefixed || "tj_state"; +}; + +export const versionedPropertyCase = ( + propertyKey: string, + versionKey: string, + version: number, + cases: any[], + fallback: any, +) => [ + "case", + ["all", ["==", ["get", versionKey], version], ["has", propertyKey]], + ["case", ...cases, fallback], + fallback, +]; + +const remapFlatStyleProperty = ( + style: FlatStyleLike, + fromProperty: string, + toProperty: string, +): FlatStyleLike => { + const remap = (value: any): any => { + if (Array.isArray(value)) { + const next = value.map(remap); + if ( + (next[0] === "get" || next[0] === "has") && + next[1] === fromProperty + ) { + next[1] = toProperty; + } + return next; + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [key, remap(nested)]), + ); + } + return value; + }; + return remap(style) as FlatStyleLike; +}; + +export const buildVersionedFlatStyle = ( + style: FlatStyleLike, + fallbackStyle: FlatStyleLike, + sourcePropertyKey: string, + propertyKey: string, + versionKey: string, + version: number, +): FlatStyleLike => { + const remappedStyle = remapFlatStyleProperty( + style, + sourcePropertyKey, + propertyKey, + ); + const guarded: Record = {}; + Object.entries(remappedStyle as Record).forEach( + ([key, value]) => { + guarded[key] = [ + "case", + ["all", ["==", ["get", versionKey], version], ["has", propertyKey]], + value, + (fallbackStyle as Record)[key] ?? value, + ]; + }, + ); + return guarded as FlatStyleLike; +}; diff --git a/src/components/olmap/core/vectorTileUtils.ts b/src/components/olmap/core/vectorTileUtils.ts new file mode 100644 index 0000000..640752a --- /dev/null +++ b/src/components/olmap/core/vectorTileUtils.ts @@ -0,0 +1,24 @@ +import type VectorTileSource from "ol/source/VectorTile"; + +export const getLoadedVectorTiles = (source: VectorTileSource): any[] => { + const sourceTiles = (source as any).sourceTiles_; + if (!sourceTiles) return []; + return sourceTiles instanceof Map + ? Array.from(sourceTiles.values()) + : Object.values(sourceTiles); +}; + +export const getVectorTileFeatureProperties = ( + feature: any, +): Record => + feature.properties_ ?? feature.getProperties?.() ?? {}; + +export const getVectorTileFeatureId = (feature: any): string | null => { + const properties = getVectorTileFeatureProperties(feature); + const id = + feature.get?.("id") ?? + feature.get?.("ID") ?? + properties.id ?? + properties.ID; + return id === undefined || id === null || id === "" ? null : String(id); +};