fix(map): stabilize tiled style rendering

This commit is contained in:
2026-07-17 15:12:10 +08:00
parent 59447a100c
commit 589cf45aa7
28 changed files with 3884 additions and 2461 deletions
@@ -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 } });
});
});
+83 -6
View File
@@ -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 = { export type ApplyLayerStyleActionPayload = {
layerId: DefaultLayerStyleId; layerId: DefaultLayerStyleId;
@@ -48,6 +53,23 @@ const asStringArray = (value: unknown): string[] | undefined =>
.filter((item): item is string => item !== undefined) .filter((item): item is string => item !== undefined)
: 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 => { export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => {
const normalized = asString(value)?.toLowerCase(); const normalized = asString(value)?.toLowerCase();
if (normalized === "junctions" || normalized === "pipes") { if (normalized === "junctions" || normalized === "pipes") {
@@ -77,13 +99,25 @@ export const parseApplyLayerStylePayload = (
? (params.styleConfig as Record<string, unknown>) ? (params.styleConfig as Record<string, unknown>)
: null; : 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<StyleConfig> | undefined = rawStyleConfig const styleConfig: Partial<StyleConfig> | undefined = rawStyleConfig
? { ? {
property: asString(rawStyleConfig.property), property: asString(rawStyleConfig.property),
classificationMethod: asString( classificationMethod: asClassificationMethod(classificationValue),
rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod, segments,
),
segments: asNumber(rawStyleConfig.segments),
minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize), minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize),
maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize), maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize),
minStrokeWidth: asNumber( minStrokeWidth: asNumber(
@@ -95,7 +129,7 @@ export const parseApplyLayerStylePayload = (
fixedStrokeWidth: asNumber( fixedStrokeWidth: asNumber(
rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth, rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth,
), ),
colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType), colorType: asColorType(colorTypeValue),
singlePaletteIndex: asNumber( singlePaletteIndex: asNumber(
rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex, rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex,
), ),
@@ -121,6 +155,49 @@ export const parseApplyLayerStylePayload = (
} }
: undefined; : 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 = const hasStyleOverrides =
styleConfig && styleConfig &&
Object.values(styleConfig).some((value) => Object.values(styleConfig).some((value) =>
@@ -1,9 +1,13 @@
import { Map as OlMap, VectorTile } from "ol"; import { Map as OlMap } from "ol";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorTileSource from "ol/source/VectorTile"; import VectorTileSource from "ol/source/VectorTile";
import { FlatStyleLike } from "ol/style/flat"; import { FlatStyleLike } from "ol/style/flat";
import { config } from "@/config/config"; import { config } from "@/config/config";
import {
VectorTileStyleSession,
versionedPropertyCase,
} from "@components/olmap/core/vectorTileStyleSession";
import { getAreaColor } from "./utils"; import { getAreaColor } from "./utils";
const JUNCTION_LAYER_VALUE = "junctions"; 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[] = []; const fillCases: any[] = [];
areaIds.forEach((areaId, index) => { areaIds.forEach((areaId, index) => {
fillCases.push( fillCases.push(
@@ -131,14 +101,34 @@ export const applyJunctionAreaRender = (
); );
junctionLayer.set(RENDER_OWNER_KEY, ownerId); junctionLayer.set(RENDER_OWNER_KEY, ownerId);
junctionLayer.setStyle({ const session = new VectorTileStyleSession({
...config.MAP_DEFAULT_STYLE, layer: junctionLayer,
"circle-fill-color": ["case", ...fillCases, defaultFillColor], source,
"circle-stroke-color": ["case", ...fillCases, defaultStrokeColor], propertyKey,
} as FlatStyleLike); 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 () => { return () => {
source.un("tileloadend", listener); session.dispose();
if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) { if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) {
junctionLayer.unset(RENDER_OWNER_KEY, true); junctionLayer.unset(RENDER_OWNER_KEY, true);
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
@@ -3,6 +3,8 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import type VectorTileSource from "ol/source/VectorTile";
import type { FlatStyleLike } from "ol/style/flat";
import { import {
Box, Box,
@@ -31,6 +33,10 @@ import { config, NETWORK_NAME } from "@/config/config";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { useMap } from "@components/olmap/core/MapComponent"; import { useMap } from "@components/olmap/core/MapComponent";
import { useTimelineTimeConfig } from "@components/olmap/core/Controls/useTimelineTimeConfig"; import { useTimelineTimeConfig } from "@components/olmap/core/Controls/useTimelineTimeConfig";
import {
VectorTileStyleSession,
versionedPropertyCase,
} from "@components/olmap/core/vectorTileStyleSession";
import { useHealthRisk } from "./HealthRiskContext"; import { useHealthRisk } from "./HealthRiskContext";
import { import {
PredictionResult, PredictionResult,
@@ -54,6 +60,50 @@ const getRoundedDate = (date: Date, stepMinutes: number): Date => {
return roundedDate; 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 { interface TimelineProps {
schemeDate?: Date; schemeDate?: Date;
timeRange?: { start: Date; end: Date }; timeRange?: { start: Date; end: Date };
@@ -93,10 +143,10 @@ const Timeline: React.FC<TimelineProps> = ({
const [isPlaying, setIsPlaying] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒 const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
const [isPredicting, setIsPredicting] = useState<boolean>(false); const [isPredicting, setIsPredicting] = useState<boolean>(false);
const [sliderPreviewYear, setSliderPreviewYear] = useState<number | null>(null);
const { stepMinutes } = useTimelineTimeConfig(); const { stepMinutes } = useTimelineTimeConfig();
// 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定 const healthStyleSessionRef = useRef<VectorTileStyleSession | null>(null);
const healthDataRef = useRef<Map<string, number>>(new Map());
// 计算时间轴范围 (4-73) // 计算时间轴范围 (4-73)
const minTime = 4; const minTime = 4;
@@ -105,9 +155,6 @@ const Timeline: React.FC<TimelineProps> = ({
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
// 添加防抖引用
const debounceRef = useRef<NodeJS.Timeout | null>(null);
// 时间刻度数组 (4-73,每3个单位一个刻度) // 时间刻度数组 (4-73,每3个单位一个刻度)
const valueMarks = Array.from({ length: 24 }, (_, i) => ({ const valueMarks = Array.from({ length: 24 }, (_, i) => ({
value: 4 + i * 3, value: 4 + i * 3,
@@ -129,15 +176,18 @@ const Timeline: React.FC<TimelineProps> = ({
if (value < minTime || value > maxTime) { if (value < minTime || value > maxTime) {
return; return;
} }
// 防抖设置currentYear,避免频繁触发数据获取 setSliderPreviewYear(value);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setCurrentYear(value);
}, 500); // 500ms 防抖延迟
}, },
[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<TimelineProps> = ({
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
} }
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
}; };
}, [stepMinutes]); }, [stepMinutes]);
@@ -261,143 +308,50 @@ const Timeline: React.FC<TimelineProps> = ({
) ?? null; ) ?? null;
}, [map]); }, [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<string, number>) => {
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(() => { useEffect(() => {
if (!pipeLayer) return; if (!pipeLayer) return;
const source = pipeLayer.getSource() as any; const source = pipeLayer.getSource() as VectorTileSource | null;
if (!source) return; if (!source) return;
const listener = (event: any) => { const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike;
const vectorTile = event.tile; healthStyleSessionRef.current?.dispose();
const renderFeatures = vectorTile.getFeatures(); healthStyleSessionRef.current = new VectorTileStyleSession({
if (!renderFeatures || renderFeatures.length === 0) return; layer: pipeLayer,
source,
const healthData = healthDataRef.current; propertyKey: "healthRisk",
renderFeatures.forEach((renderFeature: any) => { defaultStyle: defaultFlatStyle,
const featureId = renderFeature.get("id"); buildStyle: buildHealthRiskStyle,
const value = healthData.get(featureId); map,
if (value !== undefined) { buffered: true,
renderFeature.properties_["healthRisk"] = value; });
}
});
};
source.on("tileloadend", listener);
return () => { return () => {
source.un("tileloadend", listener); healthStyleSessionRef.current?.dispose();
healthStyleSessionRef.current = null;
pipeLayer.setStyle(defaultFlatStyle);
}; };
}, [pipeLayer]); }, [map, pipeLayer]);
// 应用样式到管道图层 useEffect(() => {
const applyPipeHealthStyle = useCallback(() => { const session = healthStyleSessionRef.current;
if (!pipeLayer || predictionResults.length === 0) { if (!session) return;
if (predictionResults.length === 0) {
session.reset();
return; return;
} }
// 为每条管道计算当前年份的生存概率
const pipeHealthData = new Map<string, number>(); const pipeHealthData = new Map<string, number>();
predictionResults.forEach((result) => { predictionResults.forEach((result) => {
const probability = getSurvivalProbabilityAtYear( const probability = getSurvivalProbabilityAtYear(
result.survival_function, result.survival_function,
currentYear - 4, // 使用索引 (0-based) currentYear - 4,
); );
pipeHealthData.set(result.link_id, probability); 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(() => { useEffect(() => {
// 监听地图缩放事件,缩放时停止播放 // 监听地图缩放事件,缩放时停止播放
if (map) { if (map) {
@@ -640,12 +594,13 @@ const Timeline: React.FC<TimelineProps> = ({
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}> <Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
<Slider <Slider
value={currentYear} value={sliderPreviewYear ?? currentYear}
min={minTime} min={minTime}
max={maxTime} // 4-73的范围 max={maxTime} // 4-73的范围
step={1} // 每1个单位一个步进 step={1} // 每1个单位一个步进
marks={valueMarks} // 显示刻度 marks={valueMarks} // 显示刻度
onChange={handleSliderChange} onChange={handleSliderChange}
onChangeCommitted={handleSliderChangeCommitted}
valueLabelDisplay="auto" valueLabelDisplay="auto"
sx={{ sx={{
zIndex: 10, zIndex: 10,
@@ -0,0 +1,63 @@
jest.mock("../MapComponent", () => ({
useData: jest.fn(),
useMap: jest.fn(),
}));
jest.mock("../mapLifecycle", () => ({
markMapResourcePersistent: <T,>(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),
);
});
});
});
@@ -30,91 +30,92 @@ const BASE_LAYER_METADATA = [
{ id: "tianditu-image", name: "天地图影像", img: mapboxSatellite.src }, { id: "tianditu-image", name: "天地图影像", img: mapboxSatellite.src },
] as const; ] as const;
const createTileLayer = (url: string, attributions: string) => const createTileSource = (url: string, attributions: string) =>
new TileLayer({ new XYZ({
source: new XYZ({ url,
url, tileSize: 512,
tileSize: 512, maxZoom: 20,
maxZoom: 20, projection: "EPSG:3857",
projection: "EPSG:3857", attributions,
attributions,
}),
}); });
const createBaseLayerEntries = () => { export const createBaseLayerSources = () => ({
const streetsLayer = createTileLayer( streets: createTileSource(
`https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
); ),
const lightMapLayer = createTileLayer( light: createTileSource(
`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
); ),
const satelliteLayer = createTileLayer( satellite: createTileSource(
`https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
); ),
const satelliteStreetsLayer = createTileLayer( satelliteStreets: createTileSource(
`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
); ),
tiandituVector: new XYZ({
const tiandituVectorLayer = new TileLayer({
source: 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}`, 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", projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}), }),
}); tiandituVectorAnnotation: new XYZ({
const tiandituVectorAnnotationLayer = new TileLayer({
source: 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}`, 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", projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}), }),
}); tiandituImage: new XYZ({
const tiandituImageLayer = new TileLayer({
source: 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}`, 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", projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}), }),
}); tiandituImageAnnotation: new XYZ({
const tiandituImageAnnotationLayer = new TileLayer({
source: 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}`, 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", projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}), }),
}); });
export type BaseLayerSources = ReturnType<typeof createBaseLayerSources>;
export const createBaseLayerEntries = (sources: BaseLayerSources) => {
const tileLayer = (source: XYZ) => new TileLayer({ source });
return [ return [
{ {
...BASE_LAYER_METADATA[0], ...BASE_LAYER_METADATA[0],
layer: lightMapLayer, layer: tileLayer(sources.light),
}, },
{ {
...BASE_LAYER_METADATA[1], ...BASE_LAYER_METADATA[1],
layer: satelliteLayer, layer: tileLayer(sources.satellite),
}, },
{ {
...BASE_LAYER_METADATA[2], ...BASE_LAYER_METADATA[2],
layer: satelliteStreetsLayer, layer: tileLayer(sources.satelliteStreets),
}, },
{ {
...BASE_LAYER_METADATA[3], ...BASE_LAYER_METADATA[3],
layer: streetsLayer, layer: tileLayer(sources.streets),
}, },
{ {
...BASE_LAYER_METADATA[4], ...BASE_LAYER_METADATA[4],
layer: new Group({ layer: new Group({
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer], layers: [
tileLayer(sources.tiandituVector),
tileLayer(sources.tiandituVectorAnnotation),
],
}), }),
}, },
{ {
...BASE_LAYER_METADATA[5], ...BASE_LAYER_METADATA[5],
layer: new Group({ layer: new Group({
layers: [tiandituImageLayer, tiandituImageAnnotationLayer], layers: [
tileLayer(sources.tiandituImage),
tileLayer(sources.tiandituImageAnnotation),
],
}), }),
}, },
].map((entry) => ({ ].map((entry) => ({
@@ -130,6 +131,7 @@ const BaseLayers: React.FC = () => {
if (data?.maps?.length) return data.maps; if (data?.maps?.length) return data.maps;
return map ? [map] : []; return map ? [map] : [];
}, [data?.maps, map]); }, [data?.maps, map]);
const sharedSources = useMemo(() => createBaseLayerSources(), []);
const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>()); const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>());
const [isShow, setShow] = useState(false); const [isShow, setShow] = useState(false);
const [isExpanded, setExpanded] = useState(false); const [isExpanded, setExpanded] = useState(false);
@@ -139,7 +141,7 @@ const BaseLayers: React.FC = () => {
maps.forEach((targetMap) => { maps.forEach((targetMap) => {
let layerEntries = layerSetsRef.current.get(targetMap); let layerEntries = layerSetsRef.current.get(targetMap);
if (!layerEntries) { if (!layerEntries) {
layerEntries = createBaseLayerEntries(); layerEntries = createBaseLayerEntries(sharedSources);
layerSetsRef.current.set(targetMap, layerEntries); layerSetsRef.current.set(targetMap, layerEntries);
} }
@@ -151,7 +153,7 @@ const BaseLayers: React.FC = () => {
layerInfo.layer.setVisible(layerInfo.id === activeId); layerInfo.layer.setVisible(layerInfo.id === activeId);
}); });
}); });
}, [activeId, maps]); }, [activeId, maps, sharedSources]);
const changeMapLayers = (id: string) => { const changeMapLayers = (id: string) => {
maps.forEach((targetMap) => { maps.forEach((targetMap) => {
File diff suppressed because it is too large Load Diff
@@ -1,50 +1,38 @@
import React from "react"; import React from "react";
import { Box, CircularProgress } from "@mui/material";
import StyleEditorForm from "./StyleEditorForm"; import StyleEditorForm from "./StyleEditorForm";
import { createDefaultLayerStyleState, createDefaultLayerStyleStates } from "./styleEditorPresets"; import type { StyleEditorPanelProps } from "./styleEditorTypes";
import { LayerStyleState, StyleConfig, StyleEditorPanelProps } from "./styleEditorTypes";
const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
isReady, isReady,
renderLayers, ...formProps
selectedRenderLayer,
styleConfig,
setStyleConfig,
availableProperties,
onLayerChange,
onPropertyChange,
onClassificationMethodChange,
onSegmentsChange,
onCustomBreakChange,
onCustomBreakBlur,
onColorTypeChange,
onApply,
onReset,
}) => { }) => {
if (!isReady) { if (!isReady) {
return <div>Loading...</div>; return (
<Box
sx={{
position: "absolute",
top: 72,
left: 12,
zIndex: 1300,
width: 160,
height: 72,
display: "grid",
placeItems: "center",
bgcolor: "background.paper",
borderRadius: "12px",
boxShadow:
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
opacity: 0.95,
}}
>
<CircularProgress size={22} />
</Box>
);
} }
return ( return <StyleEditorForm {...formProps} />;
<StyleEditorForm
renderLayers={renderLayers}
selectedRenderLayer={selectedRenderLayer}
styleConfig={styleConfig}
setStyleConfig={setStyleConfig}
availableProperties={availableProperties}
onLayerChange={onLayerChange}
onPropertyChange={onPropertyChange}
onClassificationMethodChange={onClassificationMethodChange}
onSegmentsChange={onSegmentsChange}
onCustomBreakChange={onCustomBreakChange}
onCustomBreakBlur={onCustomBreakBlur}
onColorTypeChange={onColorTypeChange}
onApply={onApply}
onReset={onReset}
/>
);
}; };
export default StyleEditorPanel; export default StyleEditorPanel;
export type { LayerStyleState, StyleConfig } from "./styleEditorTypes";
export { createDefaultLayerStyleState, createDefaultLayerStyleStates };
@@ -7,34 +7,31 @@ interface LegendStyleConfig {
layerId: string; layerId: string;
property: string; property: string;
colors: string[]; colors: string[];
type: string; // 图例类型 type: string;
dimensions: number[]; // 尺寸大小 dimensions: number[];
breaks: number[]; // 分段值 breaks: number[];
labels?: string[]; // 可选标签(用于离散分类) labels?: string[];
columns?: number; columns?: number;
itemsPerColumn?: number; itemsPerColumn?: number;
} }
// 图例组件
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
// 通过传入的配置对象动态生成图例内容,适用于不同的样式配置
// 使用时需要确保传入的 colors、dimensions 和 breaks 数组长度一致
const StyleLegend: React.FC<LegendStyleConfig> = ({ const StyleLegend: React.FC<LegendStyleConfig> = ({
layerName, layerName,
layerId, layerId,
property, property,
colors, colors,
type, // 图例类型 type,
dimensions, dimensions,
breaks, breaks,
labels, labels,
columns = 1, columns = 1,
itemsPerColumn, itemsPerColumn,
}) => { }) => {
const itemCount = Math.min(colors.length, dimensions.length, Math.max(0, breaks.length - 1));
return ( return (
<Box <Box
key={layerId} key={layerId}
className="bg-white p-3 rounded-xl max-w-xs opacity-95 transition-opacity duration-300 hover:opacity-100" className="bg-white p-3 max-w-xs opacity-95 transition-opacity duration-300 hover:opacity-100"
sx={{ borderRadius: 1 }}
> >
<Typography variant="subtitle2" gutterBottom> <Typography variant="subtitle2" gutterBottom>
{layerName} - {property} {layerName} - {property}
@@ -56,39 +53,9 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
rowGap: 0.5, rowGap: 0.5,
}} }}
> >
{[...Array(breaks.length)].map((_, index) => { {Array.from({ length: itemCount }, (_, index) => {
const color = colors[index]; // 默认颜色为黑色 const color = colors[index];
const dimension = dimensions[index]; // 默认尺寸为16 const dimension = dimensions[index];
// // 处理第一个区间(小于 breaks[0])
// if (index === 0) {
// return (
// <Box key={index} className="flex items-center gap-2 mb-1">
// <Box
// sx={
// type === "point"
// ? {
// width: dimension,
// height: dimension,
// borderRadius: "50%",
// backgroundColor: color,
// }
// : {
// width: 16,
// height: dimension,
// backgroundColor: color,
// border: `1px solid ${color}`,
// }
// }
// />
// <Typography variant="caption" className="text-xs">
// {"<"} {breaks[0]?.toFixed(1)}
// </Typography>
// </Box>
// );
// }
// 处理中间区间(breaks[index] - breaks[index + 1]
if (index + 1 < breaks.length) { if (index + 1 < breaks.length) {
const prevValue = breaks[index]; const prevValue = breaks[index];
const currentValue = breaks[index + 1]; const currentValue = breaks[index + 1];
+58 -23
View File
@@ -107,6 +107,7 @@ const Timeline: React.FC<TimelineProps> = ({
const [calculatedInterval, setCalculatedInterval] = const [calculatedInterval, setCalculatedInterval] =
useState<number>(stepMinutes); // 分钟 useState<number>(stepMinutes); // 分钟
const [isCalculating, setIsCalculating] = useState<boolean>(false); const [isCalculating, setIsCalculating] = useState<boolean>(false);
const [sliderPreviewTime, setSliderPreviewTime] = useState<number | null>(null);
// 计算时间轴范围 // 计算时间轴范围
const minTime = timeRange const minTime = timeRange
@@ -146,8 +147,8 @@ const Timeline: React.FC<TimelineProps> = ({
// 添加缓存引用 // 添加缓存引用
const nodeCacheRef = useRef<Map<string, any[]>>(new Map()); const nodeCacheRef = useRef<Map<string, any[]>>(new Map());
const linkCacheRef = useRef<Map<string, any[]>>(new Map()); const linkCacheRef = useRef<Map<string, any[]>>(new Map());
// 添加防抖引用 const frameRequestRevisionRef = useRef(0);
const debounceRef = useRef<NodeJS.Timeout | null>(null); const frameAbortControllerRef = useRef<AbortController | null>(null);
const updateDataStates = useCallback( const updateDataStates = useCallback(
( (
@@ -202,6 +203,7 @@ const Timeline: React.FC<TimelineProps> = ({
target, target,
schemeName, schemeName,
schemeType, schemeType,
signal,
}: { }: {
queryTime: Date; queryTime: Date;
junctionProperties: string; junctionProperties: string;
@@ -210,6 +212,7 @@ const Timeline: React.FC<TimelineProps> = ({
target: "primary" | "compare"; target: "primary" | "compare";
schemeName?: string; schemeName?: string;
schemeType?: string; schemeType?: string;
signal?: AbortSignal;
}) => { }) => {
const query_time = queryTime.toISOString(); const query_time = queryTime.toISOString();
let nodeRecords: any = { results: [] }; let nodeRecords: any = { results: [] };
@@ -233,10 +236,12 @@ const Timeline: React.FC<TimelineProps> = ({
nodePromise = nodePromise =
sourceType === "scheme" && schemeName sourceType === "scheme" && schemeName
? apiFetch( ? 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( : 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); requests.push(nodePromise);
} }
@@ -260,10 +265,12 @@ const Timeline: React.FC<TimelineProps> = ({
linkPromise = linkPromise =
sourceType === "scheme" && schemeName sourceType === "scheme" && schemeName
? apiFetch( ? 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( : 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); requests.push(linkPromise);
} }
@@ -309,9 +316,13 @@ const Timeline: React.FC<TimelineProps> = ({
); );
} }
updateDataStates(nodeRecords.results || [], linkRecords.results || [], target); return {
target,
nodeResults: nodeRecords.results || [],
linkResults: linkRecords.results || [],
};
}, },
[buildCacheKey, updateDataStates] [buildCacheKey]
); );
const fetchFrameData = useCallback( const fetchFrameData = useCallback(
@@ -322,6 +333,11 @@ const Timeline: React.FC<TimelineProps> = ({
schemeName: string, schemeName: string,
schemeType: string schemeType: string
) => { ) => {
const revision = frameRequestRevisionRef.current + 1;
frameRequestRevisionRef.current = revision;
frameAbortControllerRef.current?.abort();
const abortController = new AbortController();
frameAbortControllerRef.current = abortController;
const primarySourceType = const primarySourceType =
disableDateSelection && schemeName ? "scheme" : "realtime"; disableDateSelection && schemeName ? "scheme" : "realtime";
const tasks = [ const tasks = [
@@ -333,6 +349,7 @@ const Timeline: React.FC<TimelineProps> = ({
target: "primary", target: "primary",
schemeName, schemeName,
schemeType, schemeType,
signal: abortController.signal,
}), }),
]; ];
@@ -344,13 +361,29 @@ const Timeline: React.FC<TimelineProps> = ({
pipeProperties, pipeProperties,
sourceType: "realtime", sourceType: "realtime",
target: "compare", 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<TimelineProps> = ({
if (timeRange && (value < minTime || value > maxTime)) { if (timeRange && (value < minTime || value > maxTime)) {
return; return;
} }
// 防抖设置currentTime,避免频繁触发数据获取 setSliderPreviewTime(value);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setCurrentTime(value);
}, 500); // 500ms 防抖延迟
}, },
[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<TimelineProps> = ({
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
} }
if (debounceRef.current) { frameAbortControllerRef.current?.abort();
clearTimeout(debounceRef.current);
}
}; };
}, [durationMinutes, setCurrentTime, stepMinutes]); }, [durationMinutes, setCurrentTime, stepMinutes]);
@@ -731,7 +765,7 @@ const Timeline: React.FC<TimelineProps> = ({
<Draggable nodeRef={draggableRef} handle=".drag-handle"> <Draggable nodeRef={draggableRef} handle=".drag-handle">
<div <div
ref={draggableRef} ref={draggableRef}
className="absolute bottom-4 left-1/2 z-10 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100" className="absolute bottom-4 left-1/2 z-20 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100"
> >
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<Paper <Paper
@@ -937,12 +971,13 @@ const Timeline: React.FC<TimelineProps> = ({
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}> <Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
<Slider <Slider
value={timelineCurrentTime} value={sliderPreviewTime ?? timelineCurrentTime}
min={0} min={0}
max={durationMinutes} max={durationMinutes}
step={stepMinutes} step={stepMinutes}
marks={timeMarks} marks={timeMarks}
onChange={handleSliderChange} onChange={handleSliderChange}
onChangeCommitted={handleSliderChangeCommitted}
valueLabelDisplay="auto" valueLabelDisplay="auto"
valueLabelFormat={formatTime} valueLabelFormat={formatTime}
sx={{ sx={{
@@ -138,6 +138,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
const styleEditor = useStyleEditor({ const styleEditor = useStyleEditor({
layerStyleStates, layerStyleStates,
setLayerStyleStates, setLayerStyleStates,
workspace: project?.workspace || config.MAP_WORKSPACE,
}); });
useToolbarChatActions({ useToolbarChatActions({
@@ -803,10 +804,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
onClassificationMethodChange={styleEditor.handleClassificationMethodChange} onClassificationMethodChange={styleEditor.handleClassificationMethodChange}
onSegmentsChange={styleEditor.handleSegmentsChange} onSegmentsChange={styleEditor.handleSegmentsChange}
onCustomBreakChange={styleEditor.handleCustomBreakChange} onCustomBreakChange={styleEditor.handleCustomBreakChange}
onCustomBreakBlur={styleEditor.handleCustomBreakBlur}
onColorTypeChange={styleEditor.handleColorTypeChange} onColorTypeChange={styleEditor.handleColorTypeChange}
onApply={styleEditor.handleApply} onApply={styleEditor.handleApply}
onReset={styleEditor.handleReset} onReset={styleEditor.handleReset}
validationErrors={styleEditor.validationErrors}
isDirty={styleEditor.isDirty}
isApplying={styleEditor.isApplying}
/> />
</div> </div>
<ToolbarHistoryPanel <ToolbarHistoryPanel
@@ -83,7 +83,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record<
styleConfig: { styleConfig: {
property: "pressure", property: "pressure",
classificationMethod: "custom_breaks", classificationMethod: "custom_breaks",
customBreaks: [16, 18, 20, 22, 24, 26], customBreaks: [16, 18, 20, 22, 24, 26, 28],
customColors: [ customColors: [
"rgba(255, 0, 0, 1)", "rgba(255, 0, 0, 1)",
"rgba(255, 127, 0, 1)", "rgba(255, 127, 0, 1)",
@@ -137,7 +137,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record<
showId: false, showId: false,
opacity: 0.9, opacity: 0.9,
adjustWidthByProperty: true, adjustWidthByProperty: true,
customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2], customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4],
customColors: [], customColors: [],
}, },
legendConfig: { legendConfig: {
@@ -3,16 +3,20 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import { LegendStyleConfig } from "./StyleLegend"; import { LegendStyleConfig } from "./StyleLegend";
export type ClassificationMethod = "pretty_breaks" | "custom_breaks";
export type ColorType = "single" | "gradient" | "rainbow" | "custom";
export interface StyleConfig { export interface StyleConfig {
property: string; property: string;
classificationMethod: string; classificationMethod: ClassificationMethod;
/** Number of rendered intervals. Boundaries always contain segments + 1 values. */
segments: number; segments: number;
minSize: number; minSize: number;
maxSize: number; maxSize: number;
minStrokeWidth: number; minStrokeWidth: number;
maxStrokeWidth: number; maxStrokeWidth: number;
fixedStrokeWidth: number; fixedStrokeWidth: number;
colorType: string; colorType: ColorType;
singlePaletteIndex: number; singlePaletteIndex: number;
gradientPaletteIndex: number; gradientPaletteIndex: number;
rainbowPaletteIndex: number; rainbowPaletteIndex: number;
@@ -24,6 +28,19 @@ export interface StyleConfig {
customColors?: string[]; customColors?: string[];
} }
export interface ResolvedLayerStyle {
boundaries: number[];
colors: string[];
dimensions: number[];
labels: string[];
isConstant: boolean;
}
export interface StyleValidationResult {
valid: boolean;
errors: string[];
}
export interface LayerStyleState { export interface LayerStyleState {
layerId: string; layerId: string;
layerName: string; layerName: string;
@@ -37,6 +54,7 @@ export type DefaultLayerStyleId = "junctions" | "pipes";
export interface StyleEditorStateProps { export interface StyleEditorStateProps {
layerStyleStates: LayerStyleState[]; layerStyleStates: LayerStyleState[];
setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>; setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>;
workspace: string;
} }
export interface AvailableProperty { export interface AvailableProperty {
@@ -50,15 +68,17 @@ export interface StyleEditorFormProps {
styleConfig: StyleConfig; styleConfig: StyleConfig;
setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>; setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>;
availableProperties: AvailableProperty[]; availableProperties: AvailableProperty[];
onLayerChange: (index: number) => void; onLayerChange: (layerId: string) => void;
onPropertyChange: (property: string) => void; onPropertyChange: (property: string) => void;
onClassificationMethodChange: (method: string) => void; onClassificationMethodChange: (method: ClassificationMethod) => void;
onSegmentsChange: (segments: number) => void; onSegmentsChange: (segments: number) => void;
onCustomBreakChange: (index: number, value: string) => void; onCustomBreakChange: (index: number, value: string) => void;
onCustomBreakBlur: () => void; onColorTypeChange: (colorType: ColorType) => void;
onColorTypeChange: (colorType: string) => void;
onApply: () => void; onApply: () => void;
onReset: () => void; onReset: () => void;
validationErrors: string[];
isDirty: boolean;
isApplying: boolean;
} }
export interface StyleEditorPanelProps extends StyleEditorFormProps { export interface StyleEditorPanelProps extends StyleEditorFormProps {
@@ -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);
});
});
@@ -1,4 +1,4 @@
import { FlatStyleLike } from "ol/style/flat"; import type { FlatStyleLike, StyleVariables } from "ol/style/flat";
import { calculateClassification } from "@utils/breaksClassification"; import { calculateClassification } from "@utils/breaksClassification";
import { parseColor } from "@utils/parseColor"; import { parseColor } from "@utils/parseColor";
@@ -8,16 +8,34 @@ import {
RAINBOW_PALETTES, RAINBOW_PALETTES,
SINGLE_COLOR_PALETTES, SINGLE_COLOR_PALETTES,
} from "./styleEditorPresets"; } 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 = <T>(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) => { export const rgbaToHex = (rgba: string) => {
try { try {
const c = parseColor(rgba); const color = parseColor(rgba);
const toHex = (n: number) => { const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0");
const hex = Math.round(n).toString(16); return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
return hex.length === 1 ? `0${hex}` : hex;
};
return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`;
} catch { } catch {
return "#000000"; return "#000000";
} }
@@ -28,25 +46,71 @@ export const hexToRgba = (hex: string) => {
return result return result
? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt( ? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(
result[3], result[3],
16 16,
)}, 1)` )}, 1)`
: "rgba(0, 0, 0, 1)"; : "rgba(0, 0, 0, 1)";
}; };
export const getDefaultCustomColors = ( export const getDefaultCustomColors = (
segments: number, segments: number,
existingColors: string[] = [] existingColors: string[] = [],
) => { ) => {
const nextColors = [...existingColors]; const nextColors = [...existingColors];
const baseColors = RAINBOW_PALETTES[0].colors; const baseColors = RAINBOW_PALETTES[0].colors;
while (nextColors.length < segments) { while (nextColors.length < segments) {
nextColors.push(baseColors[nextColors.length % baseColors.length]); nextColors.push(baseColors[nextColors.length % baseColors.length]);
} }
return nextColors.slice(0, segments); 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 = ({ export const getDefaultCustomBreaks = ({
segments, segments,
property, property,
@@ -64,261 +128,255 @@ export const getDefaultCustomBreaks = ({
currentJunctionCalData?: any[]; currentJunctionCalData?: any[];
currentPipeCalData?: any[]; currentPipeCalData?: any[];
}) => { }) => {
if (!layerId || !property) { let values: number[] = [];
return Array.from({ length: segments }, () => 0); 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));
} }
const finiteValues = values.filter(Number.isFinite);
let dataArr: number[] = []; if (!property || finiteValues.length === 0) {
return Array.from({ length: segments + 1 }, (_, index) => index);
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 minimum = Math.min(...finiteValues);
if (dataArr.length === 0) { const maximum = Math.max(...finiteValues);
return Array.from({ length: segments }, () => 0); if (minimum === maximum) {
const padding = Math.max(Math.abs(minimum) * 0.01, 1);
return createEqualBoundaries(minimum - padding, maximum + padding, segments);
} }
return normalizeCalculatedBoundaries(
const defaultBreaks = calculateClassification( calculateClassification(finiteValues, segments, "pretty_breaks"),
dataArr, finiteValues,
segments, 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 = ( export const resolveStyleColors = (
styleConfig: StyleConfig, styleConfig: StyleConfig,
breaksLength: number classCount = styleConfig.segments,
): string[] => { ): string[] => {
if (styleConfig.colorType === "single") { if (styleConfig.colorType === "single") {
return Array.from( const palette = SINGLE_COLOR_PALETTES[
{ length: breaksLength }, clampIndex(styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length)
() => SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color ];
); return Array.from({ length: classCount }, () => palette.color);
} }
if (styleConfig.colorType === "gradient") { if (styleConfig.colorType === "gradient") {
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex]; const palette = GRADIENT_PALETTES[
const startColor = parseColor(start); clampIndex(styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length)
const endColor = parseColor(end); ];
const start = parseColor(palette.start);
return Array.from({ length: breaksLength }, (_, index) => { const end = parseColor(palette.end);
const ratio = breaksLength > 1 ? index / (breaksLength - 1) : 1; return Array.from({ length: classCount }, (_, index) => {
const r = Math.round(startColor.r + (endColor.r - startColor.r) * ratio); const ratio = classCount > 1 ? index / (classCount - 1) : 0;
const g = Math.round(startColor.g + (endColor.g - startColor.g) * ratio); return `rgba(${Math.round(start.r + (end.r - start.r) * ratio)}, ${Math.round(
const b = Math.round(startColor.b + (endColor.b - startColor.b) * ratio); start.g + (end.g - start.g) * ratio,
return `rgba(${r}, ${g}, ${b}, 1)`; )}, ${Math.round(start.b + (end.b - start.b) * ratio)}, 1)`;
}); });
} }
if (styleConfig.colorType === "rainbow") { if (styleConfig.colorType === "rainbow") {
const baseColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors; const palette = RAINBOW_PALETTES[
clampIndex(styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length)
];
return Array.from( return Array.from(
{ length: breaksLength }, { length: classCount },
(_, index) => baseColors[index % baseColors.length] (_, index) => palette.colors[index % palette.colors.length],
); );
} }
return getDefaultCustomColors(classCount, styleConfig.customColors || []);
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)",
];
}; };
export const resolveDimensions = ({ export const resolveDimensions = ({
layerType, layerType,
styleConfig, styleConfig,
breaksLength, classCount = styleConfig.segments,
}: { }: {
layerType: string; layerType: string;
styleConfig: StyleConfig; 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 (layerType === "linestring") {
if (styleConfig.adjustWidthByProperty) { return Array.from({ length: classCount }, (_, index) =>
return Array.from({ length: breaksLength }, (_, index) => { styleConfig.adjustWidthByProperty
const ratio = index / (breaksLength - 1); ? interpolate(styleConfig.minStrokeWidth, styleConfig.maxStrokeWidth, index)
return ( : styleConfig.fixedStrokeWidth,
styleConfig.minStrokeWidth +
(styleConfig.maxStrokeWidth - styleConfig.minStrokeWidth) * ratio
);
});
}
return Array.from(
{ length: breaksLength },
() => styleConfig.fixedStrokeWidth
); );
} }
return Array.from({ length: classCount }, (_, index) =>
return Array.from({ length: breaksLength }, (_, index) => { interpolate(styleConfig.minSize, styleConfig.maxSize, index),
const ratio = index / (breaksLength - 1); );
return styleConfig.minSize + (styleConfig.maxSize - styleConfig.minSize) * ratio;
});
}; };
export const buildDynamicStyle = ({ export const resolveLayerStyle = ({
layerType, layerType,
styleConfig, styleConfig,
breaks, values,
colors,
dimensions,
}: { }: {
layerType: string; layerType: string;
styleConfig: StyleConfig; styleConfig: StyleConfig;
breaks: number[]; values: number[];
colors: string[]; }): ResolvedLayerStyle | null => {
dimensions: number[]; const boundaries = resolveBoundaries(values, styleConfig);
}): FlatStyleLike => { if (boundaries.length !== styleConfig.segments + 1) return null;
const generateColorConditions = (property: string): any[] => { const colors = resolveStyleColors(styleConfig, styleConfig.segments);
const conditions: any[] = ["case"]; const dimensions = resolveDimensions({ layerType, styleConfig });
for (let index = 1; index < breaks.length; index++) { const isConstant = boundaries.every((value) => value === boundaries[0]);
if (property === "unit_headloss") { const labels = isConstant
conditions.push([ ? [`${formatBoundary(boundaries[0])}`]
"<=", : Array.from(
["/", ["get", "unit_headloss"], ["/", ["get", "length"], 1000]], { length: styleConfig.segments },
breaks[index], (_, index) => `${formatBoundary(boundaries[index])} - ${formatBoundary(boundaries[index + 1])}`,
]);
} else {
conditions.push(["<=", ["get", property], breaks[index]]);
}
const colorObj = parseColor(colors[index - 1]);
conditions.push(
`rgba(${colorObj.r}, ${colorObj.g}, ${colorObj.b}, ${styleConfig.opacity})`
); );
} return { boundaries, colors, dimensions, labels, isConstant };
const defaultColor = parseColor(colors[0]); };
conditions.push(
`rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${styleConfig.opacity})` 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 = ({ export const buildContourDefinitions = ({
@@ -329,20 +387,12 @@ export const buildContourDefinitions = ({
styleConfig: StyleConfig; styleConfig: StyleConfig;
breaks: number[]; breaks: number[];
colors: string[]; colors: string[];
}) => { }) =>
const contours = []; colors.map((color, index) => {
for (let index = 0; index < breaks.length - 1; index++) { const parsed = parseColor(color);
const colorObj = parseColor(colors[index]); return {
contours.push({
threshold: [breaks[index], breaks[index + 1]], threshold: [breaks[index], breaks[index + 1]],
color: [ color: [parsed.r, parsed.g, parsed.b, Math.round(styleConfig.opacity * 255)],
colorObj.r,
colorObj.g,
colorObj.b,
Math.round(styleConfig.opacity * 255),
],
strokeWidth: 0, strokeWidth: 0,
}); };
} });
return contours;
};
File diff suppressed because it is too large Load Diff
+298 -312
View File
@@ -10,7 +10,7 @@ import React, {
useCallback, useCallback,
useRef, useRef,
} from "react"; } from "react";
import { Map as OlMap, VectorTile } from "ol"; import { Map as OlMap } from "ol";
import View from "ol/View.js"; import View from "ol/View.js";
import "ol/ol.css"; import "ol/ol.css";
import MapTools from "./MapTools"; import MapTools from "./MapTools";
@@ -31,13 +31,47 @@ import {
disposeMapResources, disposeMapResources,
markMapResourcePersistent, markMapResourcePersistent,
} from "./mapLifecycle"; } from "./mapLifecycle";
import { createOperationalMapResources } from "./operationalLayers"; import {
createOperationalMapResources,
createOperationalMapSources,
} from "./operationalLayers";
import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime"; import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime";
import { useTimelineTimeConfig } from "./Controls/useTimelineTimeConfig"; import { useTimelineTimeConfig } from "./Controls/useTimelineTimeConfig";
import {
TileFeatureIndex,
clipLineStringPartsToExtent,
coordinatesToLonLat,
lineStringFromFlatCoordinates,
type TileFeatureInstance,
} from "./tileFeatureIndex";
interface MapComponentProps { interface MapComponentProps {
children?: React.ReactNode; 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 { interface DataContextType {
currentTime?: number; // 当前时间 currentTime?: number; // 当前时间
setCurrentTime?: React.Dispatch<React.SetStateAction<number>>; setCurrentTime?: React.Dispatch<React.SetStateAction<number>>;
@@ -122,6 +156,57 @@ function debounce<F extends (...args: any[]) => any>(
return debounced; 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 = () => { export const useMap = () => {
return useContext(MapContext); return useContext(MapContext);
}; };
@@ -151,7 +236,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const compareDeckLayerRef = useRef<DeckLayer | null>(null); const compareDeckLayerRef = useRef<DeckLayer | null>(null);
const isDisposingRef = useRef(false); const isDisposingRef = useRef(false);
const isCompareDisposingRef = useRef(false); const isCompareDisposingRef = useRef(false);
const pendingTimeoutsRef = useRef<number[]>([]);
const [map, setMap] = useState<OlMap>(); const [map, setMap] = useState<OlMap>();
const [deckLayer, setDeckLayer] = useState<DeckLayer>(); const [deckLayer, setDeckLayer] = useState<DeckLayer>();
@@ -174,14 +258,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
); );
const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]); const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]);
const [isCompareMode, setCompareMode] = useState(false); const [isCompareMode, setCompareMode] = useState(false);
// junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染 // junctionData 为当前层级和视口内按 ID 去重的节点数据;pipeData 为管道标签数据。
// currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值 // pipeFragments 保留当前层级和视口内每个瓦片管道片段,水流动画按 instanceKey 渲染,不能按业务 ID 去重。
const [junctionData, setJunctionDataState] = useState<any[]>([]); const [junctionData, setJunctionDataState] = useState<any[]>([]);
const [pipeData, setPipeDataState] = useState<any[]>([]); const [pipeData, setPipeDataState] = useState<any[]>([]);
const junctionDataIds = useRef(new Set<string>()); const [pipeFragments, setPipeFragments] = useState<any[]>([]);
const pipeDataIds = useRef(new Set<string>()); const junctionIndexRef = useRef<TileFeatureIndex | null>(null);
const tileJunctionDataBuffer = useRef<any[]>([]); const pipeIndexRef = useRef<TileFeatureIndex | null>(null);
const tilePipeDataBuffer = useRef<any[]>([]);
const [showJunctionTextLayer, setShowJunctionTextLayer] = useState(false); // 控制节点文本图层显示 const [showJunctionTextLayer, setShowJunctionTextLayer] = useState(false); // 控制节点文本图层显示
const [showPipeTextLayer, setShowPipeTextLayer] = useState(false); // 控制管道文本图层显示 const [showPipeTextLayer, setShowPipeTextLayer] = useState(false); // 控制管道文本图层显示
@@ -191,7 +274,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const [junctionText, setJunctionText] = useState("pressure"); const [junctionText, setJunctionText] = useState("pressure");
const [pipeText, setPipeText] = useState("velocity"); const [pipeText, setPipeText] = useState("velocity");
const [contours, setContours] = useState<any[]>([]); const [contours, setContours] = useState<any[]>([]);
const flowAnimation = useRef(false); // 添加动画控制标志
const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示 const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示
const [isWaterflowLayerAvailable, setWaterflowLayerAvailable] = const [isWaterflowLayerAvailable, setWaterflowLayerAvailable] =
useState(false); // 控制等高线图层显示 useState(false); // 控制等高线图层显示
@@ -199,67 +281,40 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别 const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别
// 实时合并计算结果到基础地理数据中 // 实时合并计算结果到基础地理数据中
const mergedJunctionData = useMemo(() => { const mergedJunctionData = useMemo(
const nodeMap = new Map(currentJunctionCalData.map((r: any) => [r.ID, r])); () =>
return junctionData.map((j) => { mergeJunctionValues(junctionData, currentJunctionCalData, junctionText),
const record = nodeMap.get(j.id); [junctionData, currentJunctionCalData, junctionText],
let val = record ? record.value : undefined; );
if (val !== undefined && isLpsFlowProperty(junctionText)) { const mergedPipeData = useMemo(
val = toM3h(val, "lps"); () => mergePipeValues(pipeData, currentPipeCalData, pipeText),
} [pipeData, currentPipeCalData, pipeText],
return record ? { ...j, [junctionText]: val } : j; );
}); const mergedPipeFragments = useMemo(
}, [junctionData, currentJunctionCalData, junctionText]); () => mergePipeValues(pipeFragments, currentPipeCalData, pipeText),
[pipeFragments, currentPipeCalData, pipeText],
const mergedPipeData = useMemo(() => { );
const linkMap = new Map(currentPipeCalData.map((r: any) => [r.ID, r])); const mergedCompareJunctionData = useMemo(
return pipeData.map((p) => { () =>
const record = linkMap.get(p.id); isCompareMode
if (!record) return p; ? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText)
const isFlow = pipeText === "flow"; : [],
let val = record.value; [isCompareMode, junctionData, compareJunctionCalData, junctionText],
if (val !== undefined && isFlow) { );
val = toM3h(val, "lps"); const mergedComparePipeData = useMemo(
} () =>
return { isCompareMode
...p, ? mergePipeValues(pipeData, comparePipeCalData, pipeText)
[pipeText]: isFlow ? Math.abs(val) : val, : [],
flowFlag: isFlow && record.value < 0 ? -1 : 1, [isCompareMode, pipeData, comparePipeCalData, pipeText],
path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path, );
}; const mergedComparePipeFragments = useMemo(
}); () =>
}, [pipeData, currentPipeCalData, pipeText]); isCompareMode
? mergePipeValues(pipeFragments, comparePipeCalData, pipeText)
const mergedCompareJunctionData = useMemo(() => { : [],
const nodeMap = new Map(compareJunctionCalData.map((r: any) => [r.ID, r])); [isCompareMode, pipeFragments, comparePipeCalData, pipeText],
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 [diameterRange, setDiameterRange] = useState< const [diameterRange, setDiameterRange] = useState<
[number, number] | undefined [number, number] | undefined
@@ -271,7 +326,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
useState(0); useState(0);
const toggleCompareMode = useCallback(() => { const toggleCompareMode = useCallback(() => {
setCompareMode((prev) => !prev); setCompareMode((prev) => {
if (!prev) markCompareOpenStart();
return !prev;
});
}, []); }, []);
const maps = useMemo( const maps = useMemo(
@@ -288,91 +346,126 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
[compareDeckLayer, deckLayer, isCompareMode], [compareDeckLayer, deckLayer, isCompareMode],
); );
const setJunctionData = (newData: any[]) => { const buildPipeFragments = useCallback((instance: TileFeatureInstance) => {
const uniqueNewData = newData.filter((item) => { const tileCoordinates = lineStringFromFlatCoordinates(
if (!item || !item.id) return false; instance.flatCoordinates,
if (!junctionDataIds.current.has(item.id)) { instance.stride,
junctionDataIds.current.add(item.id); );
return true; 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<DebouncedFunction<() => 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<string, any>();
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( const operationalResources = useMemo(
() => () =>
createOperationalMapResources({ createOperationalMapResources({
@@ -380,8 +473,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
workspace: MAP_WORKSPACE, workspace: MAP_WORKSPACE,
extent: MAP_EXTENT, extent: MAP_EXTENT,
persistent: true, persistent: true,
sources: operationalSources,
}), }),
[MAP_URL, MAP_WORKSPACE, MAP_EXTENT], [MAP_URL, MAP_WORKSPACE, MAP_EXTENT, operationalSources],
); );
const { junctions: junctionSource, pipes: pipeSource } = const { junctions: junctionSource, pipes: pipeSource } =
operationalResources.sources; operationalResources.sources;
@@ -395,60 +489,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
return; return;
} }
isDisposingRef.current = false; isDisposingRef.current = false;
const activeJunctionDataIds = junctionDataIds.current;
const activePipeDataIds = pipeDataIds.current;
const addTimeout = (callback: () => void, delay: number) => { junctionIndexRef.current = new TileFeatureIndex("junctions", junctionSource);
const timerId = window.setTimeout(() => { pipeIndexRef.current = new TileFeatureIndex("pipes", pipeSource);
pendingTimeoutsRef.current = pendingTimeoutsRef.current.filter(
(id) => id !== timerId,
);
if (isDisposingRef.current) return;
callback();
}, delay);
pendingTimeoutsRef.current.push(timerId);
return timerId;
};
const clearPendingTimeouts = () => {
pendingTimeoutsRef.current.forEach((id) => clearTimeout(id));
pendingTimeoutsRef.current = [];
};
// 缓存 junction、pipe 数据,提供给 deck.gl 提供坐标供标签显示
const handleJunctionTileLoadEnd = (event: any) => { const handleJunctionTileLoadEnd = (event: any) => {
if (isDisposingRef.current) return; if (isDisposingRef.current) return;
try { try {
if (event.tile instanceof VectorTile) { junctionIndexRef.current?.registerTile(event.tile);
const renderFeatures = event.tile.getFeatures(); scheduleActiveTileSnapshot();
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?.();
}
}
} catch (error) { } catch (error) {
console.error("Junction tile load error:", error); console.error("Junction tile load error:", error);
} }
@@ -456,86 +505,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const handlePipeTileLoadEnd = (event: any) => { const handlePipeTileLoadEnd = (event: any) => {
if (isDisposingRef.current) return; if (isDisposingRef.current) return;
try { try {
if (event.tile instanceof VectorTile) { pipeIndexRef.current?.registerTile(event.tile);
const renderFeatures = event.tile.getFeatures(); scheduleActiveTileSnapshot();
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?.();
}
}
} catch (error) { } catch (error) {
console.error("Pipe tile load error:", error); console.error("Pipe tile load error:", error);
} }
}; };
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
// 监听 junctionsLayer 的 visible 变化 // 监听 junctionsLayer 的 visible 变化
const handleJunctionVisibilityChange = () => { const handleJunctionVisibilityChange = () => {
const isVisible = junctionsLayer.getVisible(); const isVisible = junctionsLayer.getVisible();
@@ -559,6 +534,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
layers: operationalResources.orderedLayers.slice(), layers: operationalResources.orderedLayers.slice(),
controls: [], controls: [],
}); });
const scheduleActiveTileSnapshot = debounce(
() => publishActiveTileSnapshot(map),
50,
);
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
map.getInteractions().forEach(markMapResourcePersistent); map.getInteractions().forEach(markMapResourcePersistent);
setMap(map); setMap(map);
@@ -596,14 +577,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
duration: 1000, duration: 1000,
}); });
} }
// 持久化视图(中心点 + 缩放),防抖写入 localStorage // 视图稳定后同步 Deck 数据并持久化,避免移动过程中重复扫描瓦片。
const persistView = debounce(() => { const handleViewChange = debounce(() => {
if (isDisposingRef.current) return; if (isDisposingRef.current) return;
const view = map.getView();
const zoom = view.getZoom() || 0;
setCurrentZoom(zoom);
junctionIndexRef.current?.scanLoadedTiles();
pipeIndexRef.current?.scanLoadedTiles();
scheduleActiveTileSnapshot();
try { try {
const view = map.getView();
const center = view.getCenter(); const center = view.getCenter();
const zoom = view.getZoom(); if (center) {
if (center && typeof zoom === "number") {
localStorage.setItem( localStorage.setItem(
MAP_VIEW_STORAGE_KEY, MAP_VIEW_STORAGE_KEY,
JSON.stringify({ center, zoom }), JSON.stringify({ center, zoom }),
@@ -613,21 +598,16 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
console.warn("Save map view failed", err); console.warn("Save map view failed", err);
} }
}, 250); }, 250);
// 监听缩放变化并持久化,同时更新 currentZoom
const handleViewChange = () => {
addTimeout(() => {
const zoom = map.getView().getZoom() || 0;
setCurrentZoom(zoom);
persistView();
}, 250);
};
map.getView().on("change", handleViewChange); map.getView().on("change", handleViewChange);
// 初始化当前缩放级别并强制触发瓦片加载 // 初始化当前缩放级别并强制触发瓦片加载
addTimeout(() => { const initializeTimer = window.setTimeout(() => {
if (isDisposingRef.current) return;
const initialZoom = map.getView().getZoom() || 11; const initialZoom = map.getView().getZoom() || 11;
setCurrentZoom(initialZoom); setCurrentZoom(initialZoom);
junctionIndexRef.current?.scanLoadedTiles();
pipeIndexRef.current?.scanLoadedTiles();
scheduleActiveTileSnapshot();
// 强制触发地图渲染,让瓦片加载事件触发 // 强制触发地图渲染,让瓦片加载事件触发
map.render(); map.render();
}, 100); }, 100);
@@ -656,9 +636,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// 清理函数 // 清理函数
return () => { return () => {
isDisposingRef.current = true; isDisposingRef.current = true;
clearPendingTimeouts(); window.clearTimeout(initializeTimer);
debouncedUpdateDataRef.current?.cancel(); scheduleActiveTileSnapshot.cancel();
persistView.cancel(); handleViewChange.cancel();
junctionSource.un("tileloadend", handleJunctionTileLoadEnd); junctionSource.un("tileloadend", handleJunctionTileLoadEnd);
pipeSource.un("tileloadend", handlePipeTileLoadEnd); pipeSource.un("tileloadend", handlePipeTileLoadEnd);
map.getView().un("change", handleViewChange); map.getView().un("change", handleViewChange);
@@ -677,10 +657,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// React Strict Mode re-runs effects with the same memoized layer instances. // 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. // Detach and clear them here, but leave final layer/source disposal to GC.
disposeMapResources(map, { disposeLayers: false }); disposeMapResources(map, { disposeLayers: false });
activeJunctionDataIds.clear(); junctionIndexRef.current = null;
activePipeDataIds.clear(); pipeIndexRef.current = null;
tileJunctionDataBuffer.current = []; setJunctionDataState([]);
tilePipeDataBuffer.current = []; setPipeDataState([]);
setPipeFragments([]);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [MAP_WORKSPACE, MAP_EXTENT]); }, [MAP_WORKSPACE, MAP_EXTENT]);
@@ -699,6 +680,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
mapUrl: MAP_URL, mapUrl: MAP_URL,
workspace: MAP_WORKSPACE, workspace: MAP_WORKSPACE,
extent: MAP_EXTENT, extent: MAP_EXTENT,
sources: operationalSources,
}); });
const nextCompareMap = new OlMap({ const nextCompareMap = new OlMap({
target: compareMapRef.current, target: compareMapRef.current,
@@ -706,6 +688,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
layers: compareResources.orderedLayers.slice(), layers: compareResources.orderedLayers.slice(),
controls: [], controls: [],
}); });
const handleCompareRenderComplete = () => markCompareOpenReady();
nextCompareMap.once("rendercomplete", handleCompareRenderComplete);
nextCompareMap.getAllLayers().forEach((layer) => { nextCompareMap.getAllLayers().forEach((layer) => {
const layerId = layer.get("value"); const layerId = layer.get("value");
if (!layerId) return; if (!layerId) return;
@@ -748,6 +732,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
return () => { return () => {
isCompareDisposingRef.current = true; isCompareDisposingRef.current = true;
window.clearTimeout(resizeTimerId); window.clearTimeout(resizeTimerId);
nextCompareMap.un("rendercomplete", handleCompareRenderComplete);
if ( if (
compareDeckLayerRef.current && compareDeckLayerRef.current &&
!compareDeckLayerRef.current.isDisposedLayer() !compareDeckLayerRef.current.isDisposedLayer()
@@ -762,7 +747,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
compareDeckLayerRef.current = null; compareDeckLayerRef.current = null;
setCompareDeckLayer(undefined); setCompareDeckLayer(undefined);
setCompareMap(undefined); setCompareMap(undefined);
disposeMapResources(nextCompareMap); disposeMapResources(nextCompareMap, { disposeSources: false });
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCompareMode, map]); }, [isCompareMode, map]);
@@ -1015,11 +1000,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// 控制流动动画开关 // 控制流动动画开关
useEffect(() => { useEffect(() => {
flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0; const hasFlowData = pipeText === "flow" && currentPipeCalData.length > 0;
const shouldShowWaterflow = const shouldShowWaterflow =
isWaterflowLayerAvailable && isWaterflowLayerAvailable &&
showWaterflowLayer && showWaterflowLayer &&
flowAnimation.current && hasFlowData &&
currentZoom >= 12 && currentZoom >= 12 &&
currentZoom <= 24; currentZoom <= 24;
@@ -1027,13 +1012,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const syncWaterflowLayer = ( const syncWaterflowLayer = (
targetDeckLayer: DeckLayer | null, targetDeckLayer: DeckLayer | null,
targetPipeData: any[], targetPipeFragments: any[],
disposing: boolean, disposing: boolean,
) => { ) => {
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) { if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
return; return;
} }
if (!shouldShowWaterflow || targetPipeData.length === 0) { if (!shouldShowWaterflow || targetPipeFragments.length === 0) {
targetDeckLayer.removeDeckLayer("waterflowLayer"); targetDeckLayer.removeDeckLayer("waterflowLayer");
return; return;
} }
@@ -1045,7 +1030,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const waterflowLayer = new TripsLayer({ const waterflowLayer = new TripsLayer({
id: "waterflowLayer", id: "waterflowLayer",
name: "水流", name: "水流",
data: targetPipeData, data: targetPipeFragments,
getObjectId: (d: any) => d.instanceKey,
getPath: (d) => d.path, getPath: (d) => d.path,
getTimestamps: (d) => d.timestamps, getTimestamps: (d) => d.timestamps,
getColor: [0, 220, 255], getColor: [0, 220, 255],
@@ -1067,13 +1053,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const animate = () => { const animate = () => {
syncWaterflowLayer( syncWaterflowLayer(
deckLayerRef.current, deckLayerRef.current,
mergedPipeData, mergedPipeFragments,
isDisposingRef.current, isDisposingRef.current,
); );
if (isCompareMode) { if (isCompareMode) {
syncWaterflowLayer( syncWaterflowLayer(
compareDeckLayerRef.current, compareDeckLayerRef.current,
mergedComparePipeData, mergedComparePipeFragments,
isCompareDisposingRef.current, isCompareDisposingRef.current,
); );
} }
@@ -1092,8 +1078,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
}, [ }, [
currentPipeCalData, currentPipeCalData,
currentZoom, currentZoom,
mergedPipeData, mergedPipeFragments,
mergedComparePipeData, mergedComparePipeFragments,
isCompareMode, isCompareMode,
pipeText, pipeText,
isWaterflowLayerAvailable, isWaterflowLayerAvailable,
@@ -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<string, unknown> = { 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);
});
});
@@ -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<string, FeatureStateValue>,
) {
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;
}
}
@@ -91,4 +91,29 @@ describe("map lifecycle", () => {
expect(layer.dispose).not.toHaveBeenCalled(); expect(layer.dispose).not.toHaveBeenCalled();
expect(map.dispose).toHaveBeenCalledTimes(1); 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<typeof createResource>[] = [];
const controls: ReturnType<typeof createResource>[] = [];
const overlays: ReturnType<typeof createResource>[] = [];
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);
});
}); });
+26 -13
View File
@@ -28,25 +28,34 @@ const removeTransientResources = <T extends MapResource>(
}); });
}; };
const releaseLayer = (layer: any, dispose: boolean) => { type ReleaseLayerOptions = {
disposeLayer: boolean;
disposeSource: boolean;
};
const releaseLayer = (layer: any, options: ReleaseLayerOptions) => {
const childLayers = layer.getLayers?.().getArray?.(); const childLayers = layer.getLayers?.().getArray?.();
if (Array.isArray(childLayers)) { if (Array.isArray(childLayers)) {
[...childLayers].forEach((childLayer) => releaseLayer(childLayer, dispose)); [...childLayers].forEach((childLayer) => releaseLayer(childLayer, options));
layer.getLayers().clear(); layer.getLayers().clear();
} }
const source = layer.getSource?.(); const source = layer.getSource?.();
try { if (options.disposeSource) {
source?.clear?.();
} catch {
// Some third-party sources do not support explicit cache clearing.
}
if (dispose) {
try { try {
source?.dispose?.(); source?.clear?.();
} catch { } 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 { try {
layer.dispose?.(); layer.dispose?.();
} catch { } catch {
@@ -59,7 +68,7 @@ export const cleanupTransientMapResources = (map: OlMap) => {
[...map.getLayers().getArray()].forEach((layer) => { [...map.getLayers().getArray()].forEach((layer) => {
if (isMapResourcePersistent(layer)) return; if (isMapResourcePersistent(layer)) return;
map.removeLayer(layer); map.removeLayer(layer);
releaseLayer(layer, true); releaseLayer(layer, { disposeLayer: true, disposeSource: true });
}); });
removeTransientResources(map.getInteractions().getArray(), (interaction) => removeTransientResources(map.getInteractions().getArray(), (interaction) =>
@@ -75,12 +84,16 @@ export const cleanupTransientMapResources = (map: OlMap) => {
export const disposeMapResources = ( export const disposeMapResources = (
map: OlMap, map: OlMap,
options: { disposeLayers?: boolean } = {}, options: { disposeLayers?: boolean; disposeSources?: boolean } = {},
) => { ) => {
const disposeLayers = options.disposeLayers ?? true; const disposeLayers = options.disposeLayers ?? true;
const disposeSources = options.disposeSources ?? true;
[...map.getLayers().getArray()].forEach((layer) => { [...map.getLayers().getArray()].forEach((layer) => {
map.removeLayer(layer); map.removeLayer(layer);
releaseLayer(layer, disposeLayers); releaseLayer(layer, {
disposeLayer: disposeLayers,
disposeSource: disposeSources,
});
}); });
map.getInteractions().clear(); map.getInteractions().clear();
map.getControls().clear(); map.getControls().clear();
@@ -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<string, unknown>();
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<string, unknown>();
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]);
});
});
});
+27 -6
View File
@@ -17,11 +17,25 @@ import { markMapResourcePersistent } from "./mapLifecycle";
type MapExtent = [number, number, number, number]; type MapExtent = [number, number, number, number];
type CreateOperationalLayersOptions = { type CreateOperationalMapSourcesOptions = {
mapUrl: string; mapUrl: string;
workspace: string; workspace: string;
};
type CreateOperationalLayersOptions = CreateOperationalMapSourcesOptions & {
extent: MapExtent; extent: MapExtent;
persistent?: boolean; 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; const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike;
@@ -85,18 +99,16 @@ const pipeProperties = [
{ name: "流速", value: "velocity" }, { name: "流速", value: "velocity" },
]; ];
export const createOperationalMapResources = ({ export const createOperationalMapSources = ({
mapUrl, mapUrl,
workspace, workspace,
extent, }: CreateOperationalMapSourcesOptions): OperationalMapSources => {
persistent = false,
}: CreateOperationalLayersOptions) => {
const vectorTileUrl = (name: string) => const vectorTileUrl = (name: string) =>
`${mapUrl}/gwc/service/tms/1.0.0/${workspace}:${name}@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`; `${mapUrl}/gwc/service/tms/1.0.0/${workspace}:${name}@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`;
const vectorUrl = (name: string) => const vectorUrl = (name: string) =>
`${mapUrl}/${workspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspace}:${name}&outputFormat=application/json`; `${mapUrl}/${workspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspace}:${name}&outputFormat=application/json`;
const sources = { return {
junctions: new VectorTileSource({ junctions: new VectorTileSource({
url: vectorTileUrl("geo_junctions"), url: vectorTileUrl("geo_junctions"),
format: new MVT(), format: new MVT(),
@@ -129,6 +141,15 @@ export const createOperationalMapResources = ({
format: new GeoJSON(), format: new GeoJSON(),
}), }),
}; };
};
export const createOperationalMapResources = ({
mapUrl,
workspace,
extent,
persistent = false,
sources = createOperationalMapSources({ mapUrl, workspace }),
}: CreateOperationalLayersOptions) => {
const layers = { const layers = {
junctions: new WebGLVectorTileLayer({ junctions: new WebGLVectorTileLayer({
@@ -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);
});
});
@@ -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<string, any>;
geometryType: string;
tileExtent: Extent;
flatCoordinates: number[];
stride: number;
};
type TileFeatureSnapshot = {
instancesById: Map<string, TileFeatureInstance[]>;
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<TileKey>();
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<TileKey>) {
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<string, TileFeatureInstance[]>();
instances.forEach((instance) => {
const featureInstances = instancesById.get(instance.featureId);
if (featureInstances) featureInstances.push(instance);
else instancesById.set(instance.featureId, [instance]);
});
return { instancesById, instances };
}
}
@@ -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<string, unknown>;
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<string, unknown> = { id };
return {
properties,
properties_: properties,
get: (key: string) => properties[key],
};
};
const makeTile = (...features: ReturnType<typeof makeFeature>[]) => ({
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);
});
});
@@ -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<string, FeatureStateValue>();
private version = 0;
private disposed = false;
private commitRevision = 0;
private bufferedCommitQueue: Promise<void> = Promise.resolve();
private readonly slotStates = [
new Map<string, FeatureStateValue>(),
new Map<string, FeatureStateValue>(),
];
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<string, FeatureStateValue>,
): Promise<boolean> | 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<boolean> | 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<string, FeatureStateValue>) {
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<string, FeatureStateValue>,
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<string, FeatureStateValue>,
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<any, any>({
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<boolean>((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<string, FeatureStateValue>,
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<string, any> = {};
Object.entries(remappedStyle as Record<string, any>).forEach(
([key, value]) => {
guarded[key] = [
"case",
["all", ["==", ["get", versionKey], version], ["has", propertyKey]],
value,
(fallbackStyle as Record<string, any>)[key] ?? value,
];
},
);
return guarded as FlatStyleLike;
};
@@ -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<string, any> =>
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);
};