fix(map): stabilize tiled style rendering
This commit is contained in:
@@ -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 } });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { StyleConfig, DefaultLayerStyleId } from "@components/olmap/core/Controls/styleEditorTypes";
|
||||
import type {
|
||||
ClassificationMethod,
|
||||
ColorType,
|
||||
StyleConfig,
|
||||
DefaultLayerStyleId,
|
||||
} from "@components/olmap/core/Controls/styleEditorTypes";
|
||||
|
||||
export type ApplyLayerStyleActionPayload = {
|
||||
layerId: DefaultLayerStyleId;
|
||||
@@ -48,6 +53,23 @@ const asStringArray = (value: unknown): string[] | undefined =>
|
||||
.filter((item): item is string => item !== undefined)
|
||||
: undefined;
|
||||
|
||||
const asClassificationMethod = (value: unknown): ClassificationMethod | undefined => {
|
||||
const normalized = asString(value);
|
||||
return normalized === "pretty_breaks" || normalized === "custom_breaks"
|
||||
? normalized
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const asColorType = (value: unknown): ColorType | undefined => {
|
||||
const normalized = asString(value);
|
||||
return normalized === "single" ||
|
||||
normalized === "gradient" ||
|
||||
normalized === "rainbow" ||
|
||||
normalized === "custom"
|
||||
? normalized
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => {
|
||||
const normalized = asString(value)?.toLowerCase();
|
||||
if (normalized === "junctions" || normalized === "pipes") {
|
||||
@@ -77,13 +99,25 @@ export const parseApplyLayerStylePayload = (
|
||||
? (params.styleConfig as Record<string, unknown>)
|
||||
: 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
|
||||
? {
|
||||
property: asString(rawStyleConfig.property),
|
||||
classificationMethod: asString(
|
||||
rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod,
|
||||
),
|
||||
segments: asNumber(rawStyleConfig.segments),
|
||||
classificationMethod: asClassificationMethod(classificationValue),
|
||||
segments,
|
||||
minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize),
|
||||
maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize),
|
||||
minStrokeWidth: asNumber(
|
||||
@@ -95,7 +129,7 @@ export const parseApplyLayerStylePayload = (
|
||||
fixedStrokeWidth: asNumber(
|
||||
rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth,
|
||||
),
|
||||
colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType),
|
||||
colorType: asColorType(colorTypeValue),
|
||||
singlePaletteIndex: asNumber(
|
||||
rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex,
|
||||
),
|
||||
@@ -121,6 +155,49 @@ export const parseApplyLayerStylePayload = (
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (styleConfig) {
|
||||
const numericValues = [
|
||||
styleConfig.minSize,
|
||||
styleConfig.maxSize,
|
||||
styleConfig.minStrokeWidth,
|
||||
styleConfig.maxStrokeWidth,
|
||||
styleConfig.fixedStrokeWidth,
|
||||
].filter((value): value is number => value !== undefined);
|
||||
if (numericValues.some((value) => value <= 0)) return null;
|
||||
const paletteIndexes: Array<[number | undefined, number]> = [
|
||||
[styleConfig.singlePaletteIndex, 7],
|
||||
[styleConfig.gradientPaletteIndex, 3],
|
||||
[styleConfig.rainbowPaletteIndex, 2],
|
||||
];
|
||||
if (
|
||||
paletteIndexes.some(
|
||||
([index, length]) =>
|
||||
index !== undefined &&
|
||||
(!Number.isInteger(index) || index < 0 || index >= length),
|
||||
)
|
||||
) return null;
|
||||
if (
|
||||
styleConfig.opacity !== undefined &&
|
||||
(styleConfig.opacity < 0 || styleConfig.opacity > 1)
|
||||
) return null;
|
||||
if (
|
||||
styleConfig.customBreaks &&
|
||||
styleConfig.customBreaks.some(
|
||||
(value, index, values) => index > 0 && value <= values[index - 1],
|
||||
)
|
||||
) return null;
|
||||
if (
|
||||
styleConfig.segments !== undefined &&
|
||||
styleConfig.customBreaks &&
|
||||
styleConfig.customBreaks.length !== styleConfig.segments + 1
|
||||
) return null;
|
||||
if (
|
||||
styleConfig.segments !== undefined &&
|
||||
styleConfig.customColors &&
|
||||
styleConfig.customColors.length !== styleConfig.segments
|
||||
) return null;
|
||||
}
|
||||
|
||||
const hasStyleOverrides =
|
||||
styleConfig &&
|
||||
Object.values(styleConfig).some((value) =>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Map as OlMap, VectorTile } from "ol";
|
||||
import { Map as OlMap } from "ol";
|
||||
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||
import VectorTileSource from "ol/source/VectorTile";
|
||||
import { FlatStyleLike } from "ol/style/flat";
|
||||
|
||||
import { config } from "@/config/config";
|
||||
import {
|
||||
VectorTileStyleSession,
|
||||
versionedPropertyCase,
|
||||
} from "@components/olmap/core/vectorTileStyleSession";
|
||||
import { getAreaColor } from "./utils";
|
||||
|
||||
const JUNCTION_LAYER_VALUE = "junctions";
|
||||
@@ -83,40 +87,6 @@ export const applyJunctionAreaRender = (
|
||||
}
|
||||
});
|
||||
|
||||
const applyFeatureAreaIndex = (renderFeature: any) => {
|
||||
const featureId = String(renderFeature.get("id") ?? "");
|
||||
const areaIndex = nodeAreaIndexMap.get(featureId);
|
||||
if (areaIndex !== undefined) {
|
||||
renderFeature.properties_[propertyKey] = areaIndex;
|
||||
}
|
||||
};
|
||||
|
||||
const sourceTiles = (source as any).sourceTiles_;
|
||||
if (sourceTiles) {
|
||||
Object.values(sourceTiles).forEach((vectorTile: any) => {
|
||||
const renderFeatures = vectorTile.getFeatures();
|
||||
if (!renderFeatures || renderFeatures.length === 0) return;
|
||||
renderFeatures.forEach((renderFeature: any) => {
|
||||
applyFeatureAreaIndex(renderFeature);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const listener = (event: any) => {
|
||||
try {
|
||||
if (!(event.tile instanceof VectorTile)) return;
|
||||
const renderFeatures = event.tile.getFeatures();
|
||||
if (!renderFeatures || renderFeatures.length === 0) return;
|
||||
renderFeatures.forEach((renderFeature: any) => {
|
||||
applyFeatureAreaIndex(renderFeature);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error applying junction area render:", error);
|
||||
}
|
||||
};
|
||||
|
||||
source.on("tileloadend", listener);
|
||||
|
||||
const fillCases: any[] = [];
|
||||
areaIds.forEach((areaId, index) => {
|
||||
fillCases.push(
|
||||
@@ -131,14 +101,34 @@ export const applyJunctionAreaRender = (
|
||||
);
|
||||
|
||||
junctionLayer.set(RENDER_OWNER_KEY, ownerId);
|
||||
junctionLayer.setStyle({
|
||||
...config.MAP_DEFAULT_STYLE,
|
||||
"circle-fill-color": ["case", ...fillCases, defaultFillColor],
|
||||
"circle-stroke-color": ["case", ...fillCases, defaultStrokeColor],
|
||||
} as FlatStyleLike);
|
||||
const session = new VectorTileStyleSession({
|
||||
layer: junctionLayer,
|
||||
source,
|
||||
propertyKey,
|
||||
defaultStyle: config.MAP_DEFAULT_STYLE as FlatStyleLike,
|
||||
buildStyle: (statePropertyKey, versionKey, version) =>
|
||||
({
|
||||
...config.MAP_DEFAULT_STYLE,
|
||||
"circle-fill-color": versionedPropertyCase(
|
||||
statePropertyKey,
|
||||
versionKey,
|
||||
version,
|
||||
fillCases,
|
||||
defaultFillColor,
|
||||
),
|
||||
"circle-stroke-color": versionedPropertyCase(
|
||||
statePropertyKey,
|
||||
versionKey,
|
||||
version,
|
||||
fillCases,
|
||||
defaultStrokeColor,
|
||||
),
|
||||
}) as FlatStyleLike,
|
||||
});
|
||||
session.commit(nodeAreaIndexMap);
|
||||
|
||||
return () => {
|
||||
source.un("tileloadend", listener);
|
||||
session.dispose();
|
||||
if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) {
|
||||
junctionLayer.unset(RENDER_OWNER_KEY, true);
|
||||
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||
import type VectorTileSource from "ol/source/VectorTile";
|
||||
import type { FlatStyleLike } from "ol/style/flat";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -31,6 +33,10 @@ import { config, NETWORK_NAME } from "@/config/config";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { useMap } from "@components/olmap/core/MapComponent";
|
||||
import { useTimelineTimeConfig } from "@components/olmap/core/Controls/useTimelineTimeConfig";
|
||||
import {
|
||||
VectorTileStyleSession,
|
||||
versionedPropertyCase,
|
||||
} from "@components/olmap/core/vectorTileStyleSession";
|
||||
import { useHealthRisk } from "./HealthRiskContext";
|
||||
import {
|
||||
PredictionResult,
|
||||
@@ -54,6 +60,50 @@ const getRoundedDate = (date: Date, stepMinutes: number): Date => {
|
||||
return roundedDate;
|
||||
};
|
||||
|
||||
const buildHealthRiskStyle = (
|
||||
propertyKey: string,
|
||||
versionKey: string,
|
||||
version: number,
|
||||
): FlatStyleLike => {
|
||||
const colorCases: any[] = [];
|
||||
const widthCases: any[] = [];
|
||||
RISK_BREAKS.forEach((breakValue, index) => {
|
||||
colorCases.push(
|
||||
["<=", ["get", propertyKey], breakValue],
|
||||
RAINBOW_COLORS[index],
|
||||
);
|
||||
widthCases.push(
|
||||
["<=", ["get", propertyKey], breakValue],
|
||||
2 + (1 - index / (RISK_BREAKS.length - 1)) * 4,
|
||||
);
|
||||
});
|
||||
return {
|
||||
"stroke-color": versionedPropertyCase(
|
||||
propertyKey,
|
||||
versionKey,
|
||||
version,
|
||||
colorCases,
|
||||
"rgba(128, 128, 128, 1)",
|
||||
),
|
||||
"stroke-width": versionedPropertyCase(
|
||||
propertyKey,
|
||||
versionKey,
|
||||
version,
|
||||
widthCases,
|
||||
2,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const getSurvivalProbabilityAtYear = (
|
||||
survivalFunction: SurvivalFunction,
|
||||
index: number,
|
||||
) => {
|
||||
const values = survivalFunction.y;
|
||||
if (values.length === 0) return 1;
|
||||
return values[Math.max(0, Math.min(index, values.length - 1))];
|
||||
};
|
||||
|
||||
interface TimelineProps {
|
||||
schemeDate?: Date;
|
||||
timeRange?: { start: Date; end: Date };
|
||||
@@ -93,10 +143,10 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
|
||||
const [isPredicting, setIsPredicting] = useState<boolean>(false);
|
||||
const [sliderPreviewYear, setSliderPreviewYear] = useState<number | null>(null);
|
||||
const { stepMinutes } = useTimelineTimeConfig();
|
||||
|
||||
// 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定
|
||||
const healthDataRef = useRef<Map<string, number>>(new Map());
|
||||
const healthStyleSessionRef = useRef<VectorTileStyleSession | null>(null);
|
||||
|
||||
// 计算时间轴范围 (4-73)
|
||||
const minTime = 4;
|
||||
@@ -105,9 +155,6 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 添加防抖引用
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 时间刻度数组 (4-73,每3个单位一个刻度)
|
||||
const valueMarks = Array.from({ length: 24 }, (_, i) => ({
|
||||
value: 4 + i * 3,
|
||||
@@ -129,15 +176,18 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
if (value < minTime || value > maxTime) {
|
||||
return;
|
||||
}
|
||||
// 防抖设置currentYear,避免频繁触发数据获取
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setCurrentYear(value);
|
||||
}, 500); // 500ms 防抖延迟
|
||||
setSliderPreviewYear(value);
|
||||
},
|
||||
[minTime, maxTime, setCurrentYear],
|
||||
[minTime, maxTime],
|
||||
);
|
||||
|
||||
const handleSliderChangeCommitted = useCallback(
|
||||
(_event: Event | React.SyntheticEvent, newValue: number | number[]) => {
|
||||
const value = Array.isArray(newValue) ? newValue[0] : newValue;
|
||||
setSliderPreviewYear(null);
|
||||
setCurrentYear(value);
|
||||
},
|
||||
[setCurrentYear],
|
||||
);
|
||||
|
||||
// 播放控制
|
||||
@@ -241,9 +291,6 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [stepMinutes]);
|
||||
|
||||
@@ -261,143 +308,50 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
) ?? null;
|
||||
}, [map]);
|
||||
|
||||
// 根据索引从 survival_function 中获取生存概率
|
||||
const getSurvivalProbabilityAtYear = useCallback(
|
||||
(survivalFunc: SurvivalFunction, index: number): number => {
|
||||
const { y } = survivalFunc;
|
||||
if (y.length === 0) return 1;
|
||||
|
||||
// 确保索引在范围内
|
||||
const safeIndex = Math.max(0, Math.min(index, y.length - 1));
|
||||
return y[safeIndex];
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 更新管道图层中的 healthRisk 属性
|
||||
const updatePipeHealthData = useCallback(
|
||||
(healthData: Map<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(() => {
|
||||
if (!pipeLayer) return;
|
||||
const source = pipeLayer.getSource() as any;
|
||||
const source = pipeLayer.getSource() as VectorTileSource | null;
|
||||
if (!source) return;
|
||||
|
||||
const listener = (event: any) => {
|
||||
const vectorTile = event.tile;
|
||||
const renderFeatures = vectorTile.getFeatures();
|
||||
if (!renderFeatures || renderFeatures.length === 0) return;
|
||||
|
||||
const healthData = healthDataRef.current;
|
||||
renderFeatures.forEach((renderFeature: any) => {
|
||||
const featureId = renderFeature.get("id");
|
||||
const value = healthData.get(featureId);
|
||||
if (value !== undefined) {
|
||||
renderFeature.properties_["healthRisk"] = value;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
source.on("tileloadend", listener);
|
||||
const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike;
|
||||
healthStyleSessionRef.current?.dispose();
|
||||
healthStyleSessionRef.current = new VectorTileStyleSession({
|
||||
layer: pipeLayer,
|
||||
source,
|
||||
propertyKey: "healthRisk",
|
||||
defaultStyle: defaultFlatStyle,
|
||||
buildStyle: buildHealthRiskStyle,
|
||||
map,
|
||||
buffered: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
source.un("tileloadend", listener);
|
||||
healthStyleSessionRef.current?.dispose();
|
||||
healthStyleSessionRef.current = null;
|
||||
pipeLayer.setStyle(defaultFlatStyle);
|
||||
};
|
||||
}, [pipeLayer]);
|
||||
}, [map, pipeLayer]);
|
||||
|
||||
// 应用样式到管道图层
|
||||
const applyPipeHealthStyle = useCallback(() => {
|
||||
if (!pipeLayer || predictionResults.length === 0) {
|
||||
useEffect(() => {
|
||||
const session = healthStyleSessionRef.current;
|
||||
if (!session) return;
|
||||
if (predictionResults.length === 0) {
|
||||
session.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// 为每条管道计算当前年份的生存概率
|
||||
const pipeHealthData = new Map<string, number>();
|
||||
predictionResults.forEach((result) => {
|
||||
const probability = getSurvivalProbabilityAtYear(
|
||||
result.survival_function,
|
||||
currentYear - 4, // 使用索引 (0-based)
|
||||
currentYear - 4,
|
||||
);
|
||||
pipeHealthData.set(result.link_id, probability);
|
||||
});
|
||||
session.commit(pipeHealthData);
|
||||
}, [currentYear, pipeLayer, predictionResults]);
|
||||
|
||||
// 更新 ref 数据
|
||||
healthDataRef.current = pipeHealthData;
|
||||
|
||||
// 更新图层数据
|
||||
updatePipeHealthData(pipeHealthData);
|
||||
|
||||
// 获取所有概率值用于分类
|
||||
const probabilities = Array.from(pipeHealthData.values());
|
||||
if (probabilities.length === 0) return;
|
||||
|
||||
// 使用等距分段,从0-1分为十类
|
||||
const breaks = RISK_BREAKS;
|
||||
|
||||
// 生成彩虹色(从紫色到红色,低生存概率=高风险=红色)
|
||||
const colors = RAINBOW_COLORS;
|
||||
|
||||
// 构建 WebGL 样式表达式
|
||||
const colorCases: any[] = [];
|
||||
const widthCases: any[] = [];
|
||||
|
||||
breaks.forEach((breakValue, index) => {
|
||||
const colorStr = colors[index];
|
||||
// 线宽根据健康风险调整:低生存概率(高风险)用粗线
|
||||
const width = 2 + (1 - index / (breaks.length - 1)) * 4;
|
||||
|
||||
colorCases.push(["<=", ["get", "healthRisk"], breakValue], colorStr);
|
||||
widthCases.push(["<=", ["get", "healthRisk"], breakValue], width);
|
||||
});
|
||||
// console.log(
|
||||
// `应用健康风险样式,年份: ${currentYear}, 分段: ${breaks.length}`,
|
||||
// );
|
||||
// console.log("颜色表达式:", colorCases);
|
||||
// console.log("宽度表达式:", widthCases);
|
||||
// 应用样式到图层
|
||||
pipeLayer.setStyle({
|
||||
"stroke-color": ["case", ...colorCases, "rgba(128, 128, 128, 1)"],
|
||||
"stroke-width": ["case", ...widthCases, 2],
|
||||
});
|
||||
}, [
|
||||
pipeLayer,
|
||||
predictionResults,
|
||||
currentYear,
|
||||
getSurvivalProbabilityAtYear,
|
||||
updatePipeHealthData,
|
||||
]);
|
||||
|
||||
// 监听依赖变化,更新样式
|
||||
useEffect(() => {
|
||||
if (predictionResults.length > 0 && pipeLayer) {
|
||||
applyPipeHealthStyle();
|
||||
}
|
||||
}, [applyPipeHealthStyle, pipeLayer, predictionResults.length]);
|
||||
|
||||
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
|
||||
// 缩放期间暂停时间轴,避免视图变化与自动播放同时推进。
|
||||
useEffect(() => {
|
||||
// 监听地图缩放事件,缩放时停止播放
|
||||
if (map) {
|
||||
@@ -640,12 +594,13 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
|
||||
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
|
||||
<Slider
|
||||
value={currentYear}
|
||||
value={sliderPreviewYear ?? currentYear}
|
||||
min={minTime}
|
||||
max={maxTime} // 4-73的范围
|
||||
step={1} // 每1个单位一个步进
|
||||
marks={valueMarks} // 显示刻度
|
||||
onChange={handleSliderChange}
|
||||
onChangeCommitted={handleSliderChangeCommitted}
|
||||
valueLabelDisplay="auto"
|
||||
sx={{
|
||||
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 },
|
||||
] as const;
|
||||
|
||||
const createTileLayer = (url: string, attributions: string) =>
|
||||
new TileLayer({
|
||||
source: new XYZ({
|
||||
url,
|
||||
tileSize: 512,
|
||||
maxZoom: 20,
|
||||
projection: "EPSG:3857",
|
||||
attributions,
|
||||
}),
|
||||
const createTileSource = (url: string, attributions: string) =>
|
||||
new XYZ({
|
||||
url,
|
||||
tileSize: 512,
|
||||
maxZoom: 20,
|
||||
projection: "EPSG:3857",
|
||||
attributions,
|
||||
});
|
||||
|
||||
const createBaseLayerEntries = () => {
|
||||
const streetsLayer = createTileLayer(
|
||||
export const createBaseLayerSources = () => ({
|
||||
streets: createTileSource(
|
||||
`https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
|
||||
);
|
||||
const lightMapLayer = createTileLayer(
|
||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
||||
),
|
||||
light: createTileSource(
|
||||
`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>'
|
||||
);
|
||||
const satelliteLayer = createTileLayer(
|
||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
||||
),
|
||||
satellite: createTileSource(
|
||||
`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>'
|
||||
);
|
||||
const satelliteStreetsLayer = createTileLayer(
|
||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
||||
),
|
||||
satelliteStreets: createTileSource(
|
||||
`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>'
|
||||
);
|
||||
|
||||
const tiandituVectorLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
||||
),
|
||||
tiandituVector: new XYZ({
|
||||
url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
});
|
||||
const tiandituVectorAnnotationLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
}),
|
||||
tiandituVectorAnnotation: new XYZ({
|
||||
url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
});
|
||||
const tiandituImageLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
}),
|
||||
tiandituImage: new XYZ({
|
||||
url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
});
|
||||
const tiandituImageAnnotationLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
}),
|
||||
tiandituImageAnnotation: new XYZ({
|
||||
url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<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 [
|
||||
{
|
||||
...BASE_LAYER_METADATA[0],
|
||||
layer: lightMapLayer,
|
||||
layer: tileLayer(sources.light),
|
||||
},
|
||||
{
|
||||
...BASE_LAYER_METADATA[1],
|
||||
layer: satelliteLayer,
|
||||
layer: tileLayer(sources.satellite),
|
||||
},
|
||||
{
|
||||
...BASE_LAYER_METADATA[2],
|
||||
layer: satelliteStreetsLayer,
|
||||
layer: tileLayer(sources.satelliteStreets),
|
||||
},
|
||||
{
|
||||
...BASE_LAYER_METADATA[3],
|
||||
layer: streetsLayer,
|
||||
layer: tileLayer(sources.streets),
|
||||
},
|
||||
{
|
||||
...BASE_LAYER_METADATA[4],
|
||||
layer: new Group({
|
||||
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
|
||||
layers: [
|
||||
tileLayer(sources.tiandituVector),
|
||||
tileLayer(sources.tiandituVectorAnnotation),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
...BASE_LAYER_METADATA[5],
|
||||
layer: new Group({
|
||||
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
|
||||
layers: [
|
||||
tileLayer(sources.tiandituImage),
|
||||
tileLayer(sources.tiandituImageAnnotation),
|
||||
],
|
||||
}),
|
||||
},
|
||||
].map((entry) => ({
|
||||
@@ -130,6 +131,7 @@ const BaseLayers: React.FC = () => {
|
||||
if (data?.maps?.length) return data.maps;
|
||||
return map ? [map] : [];
|
||||
}, [data?.maps, map]);
|
||||
const sharedSources = useMemo(() => createBaseLayerSources(), []);
|
||||
const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>());
|
||||
const [isShow, setShow] = useState(false);
|
||||
const [isExpanded, setExpanded] = useState(false);
|
||||
@@ -139,7 +141,7 @@ const BaseLayers: React.FC = () => {
|
||||
maps.forEach((targetMap) => {
|
||||
let layerEntries = layerSetsRef.current.get(targetMap);
|
||||
if (!layerEntries) {
|
||||
layerEntries = createBaseLayerEntries();
|
||||
layerEntries = createBaseLayerEntries(sharedSources);
|
||||
layerSetsRef.current.set(targetMap, layerEntries);
|
||||
}
|
||||
|
||||
@@ -151,7 +153,7 @@ const BaseLayers: React.FC = () => {
|
||||
layerInfo.layer.setVisible(layerInfo.id === activeId);
|
||||
});
|
||||
});
|
||||
}, [activeId, maps]);
|
||||
}, [activeId, maps, sharedSources]);
|
||||
|
||||
const changeMapLayers = (id: string) => {
|
||||
maps.forEach((targetMap) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,50 +1,38 @@
|
||||
import React from "react";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
import StyleEditorForm from "./StyleEditorForm";
|
||||
import { createDefaultLayerStyleState, createDefaultLayerStyleStates } from "./styleEditorPresets";
|
||||
import { LayerStyleState, StyleConfig, StyleEditorPanelProps } from "./styleEditorTypes";
|
||||
import type { StyleEditorPanelProps } from "./styleEditorTypes";
|
||||
|
||||
const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
||||
isReady,
|
||||
renderLayers,
|
||||
selectedRenderLayer,
|
||||
styleConfig,
|
||||
setStyleConfig,
|
||||
availableProperties,
|
||||
onLayerChange,
|
||||
onPropertyChange,
|
||||
onClassificationMethodChange,
|
||||
onSegmentsChange,
|
||||
onCustomBreakChange,
|
||||
onCustomBreakBlur,
|
||||
onColorTypeChange,
|
||||
onApply,
|
||||
onReset,
|
||||
...formProps
|
||||
}) => {
|
||||
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 (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
return <StyleEditorForm {...formProps} />;
|
||||
};
|
||||
|
||||
export default StyleEditorPanel;
|
||||
export type { LayerStyleState, StyleConfig } from "./styleEditorTypes";
|
||||
export { createDefaultLayerStyleState, createDefaultLayerStyleStates };
|
||||
|
||||
@@ -7,34 +7,31 @@ interface LegendStyleConfig {
|
||||
layerId: string;
|
||||
property: string;
|
||||
colors: string[];
|
||||
type: string; // 图例类型
|
||||
dimensions: number[]; // 尺寸大小
|
||||
breaks: number[]; // 分段值
|
||||
labels?: string[]; // 可选标签(用于离散分类)
|
||||
type: string;
|
||||
dimensions: number[];
|
||||
breaks: number[];
|
||||
labels?: string[];
|
||||
columns?: number;
|
||||
itemsPerColumn?: number;
|
||||
}
|
||||
// 图例组件
|
||||
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
|
||||
// 通过传入的配置对象动态生成图例内容,适用于不同的样式配置
|
||||
// 使用时需要确保传入的 colors、dimensions 和 breaks 数组长度一致
|
||||
|
||||
const StyleLegend: React.FC<LegendStyleConfig> = ({
|
||||
layerName,
|
||||
layerId,
|
||||
property,
|
||||
colors,
|
||||
type, // 图例类型
|
||||
type,
|
||||
dimensions,
|
||||
breaks,
|
||||
labels,
|
||||
columns = 1,
|
||||
itemsPerColumn,
|
||||
}) => {
|
||||
const itemCount = Math.min(colors.length, dimensions.length, Math.max(0, breaks.length - 1));
|
||||
return (
|
||||
<Box
|
||||
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>
|
||||
{layerName} - {property}
|
||||
@@ -56,39 +53,9 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
|
||||
rowGap: 0.5,
|
||||
}}
|
||||
>
|
||||
{[...Array(breaks.length)].map((_, index) => {
|
||||
const color = colors[index]; // 默认颜色为黑色
|
||||
const dimension = dimensions[index]; // 默认尺寸为16
|
||||
|
||||
// // 处理第一个区间(小于 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])
|
||||
{Array.from({ length: itemCount }, (_, index) => {
|
||||
const color = colors[index];
|
||||
const dimension = dimensions[index];
|
||||
if (index + 1 < breaks.length) {
|
||||
const prevValue = breaks[index];
|
||||
const currentValue = breaks[index + 1];
|
||||
|
||||
@@ -107,6 +107,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
const [calculatedInterval, setCalculatedInterval] =
|
||||
useState<number>(stepMinutes); // 分钟
|
||||
const [isCalculating, setIsCalculating] = useState<boolean>(false);
|
||||
const [sliderPreviewTime, setSliderPreviewTime] = useState<number | null>(null);
|
||||
|
||||
// 计算时间轴范围
|
||||
const minTime = timeRange
|
||||
@@ -146,8 +147,8 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
// 添加缓存引用
|
||||
const nodeCacheRef = useRef<Map<string, any[]>>(new Map());
|
||||
const linkCacheRef = useRef<Map<string, any[]>>(new Map());
|
||||
// 添加防抖引用
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const frameRequestRevisionRef = useRef(0);
|
||||
const frameAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const updateDataStates = useCallback(
|
||||
(
|
||||
@@ -202,6 +203,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
target,
|
||||
schemeName,
|
||||
schemeType,
|
||||
signal,
|
||||
}: {
|
||||
queryTime: Date;
|
||||
junctionProperties: string;
|
||||
@@ -210,6 +212,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
target: "primary" | "compare";
|
||||
schemeName?: string;
|
||||
schemeType?: string;
|
||||
signal?: AbortSignal;
|
||||
}) => {
|
||||
const query_time = queryTime.toISOString();
|
||||
let nodeRecords: any = { results: [] };
|
||||
@@ -233,10 +236,12 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
nodePromise =
|
||||
sourceType === "scheme" && schemeName
|
||||
? apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`
|
||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
|
||||
{ signal },
|
||||
)
|
||||
: apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`
|
||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
|
||||
{ signal },
|
||||
);
|
||||
requests.push(nodePromise);
|
||||
}
|
||||
@@ -260,10 +265,12 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
linkPromise =
|
||||
sourceType === "scheme" && schemeName
|
||||
? apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`
|
||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
|
||||
{ signal },
|
||||
)
|
||||
: apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`
|
||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
|
||||
{ signal },
|
||||
);
|
||||
requests.push(linkPromise);
|
||||
}
|
||||
@@ -309,9 +316,13 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
updateDataStates(nodeRecords.results || [], linkRecords.results || [], target);
|
||||
return {
|
||||
target,
|
||||
nodeResults: nodeRecords.results || [],
|
||||
linkResults: linkRecords.results || [],
|
||||
};
|
||||
},
|
||||
[buildCacheKey, updateDataStates]
|
||||
[buildCacheKey]
|
||||
);
|
||||
|
||||
const fetchFrameData = useCallback(
|
||||
@@ -322,6 +333,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
schemeName: string,
|
||||
schemeType: string
|
||||
) => {
|
||||
const revision = frameRequestRevisionRef.current + 1;
|
||||
frameRequestRevisionRef.current = revision;
|
||||
frameAbortControllerRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
frameAbortControllerRef.current = abortController;
|
||||
const primarySourceType =
|
||||
disableDateSelection && schemeName ? "scheme" : "realtime";
|
||||
const tasks = [
|
||||
@@ -333,6 +349,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
target: "primary",
|
||||
schemeName,
|
||||
schemeType,
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -344,13 +361,29 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
pipeProperties,
|
||||
sourceType: "realtime",
|
||||
target: "compare",
|
||||
signal: abortController.signal,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
try {
|
||||
const frames = await Promise.all(tasks);
|
||||
if (
|
||||
abortController.signal.aborted ||
|
||||
revision !== frameRequestRevisionRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
frames.forEach(({ nodeResults, linkResults, target }) => {
|
||||
updateDataStates(nodeResults, linkResults, target);
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
console.error("Timeline frame fetch failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[disableDateSelection, fetchDataBySource, isCompareMode]
|
||||
[disableDateSelection, fetchDataBySource, isCompareMode, updateDataStates]
|
||||
);
|
||||
|
||||
// 格式化时间显示
|
||||
@@ -427,15 +460,18 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
if (timeRange && (value < minTime || value > maxTime)) {
|
||||
return;
|
||||
}
|
||||
// 防抖设置currentTime,避免频繁触发数据获取
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setCurrentTime(value);
|
||||
}, 500); // 500ms 防抖延迟
|
||||
setSliderPreviewTime(value);
|
||||
},
|
||||
[timeRange, minTime, maxTime, setCurrentTime],
|
||||
[timeRange, minTime, maxTime],
|
||||
);
|
||||
|
||||
const handleSliderChangeCommitted = useCallback(
|
||||
(_event: Event | React.SyntheticEvent, newValue: number | number[]) => {
|
||||
const value = Array.isArray(newValue) ? newValue[0] : newValue;
|
||||
setSliderPreviewTime(null);
|
||||
setCurrentTime(value);
|
||||
},
|
||||
[setCurrentTime],
|
||||
);
|
||||
|
||||
// 播放控制
|
||||
@@ -585,9 +621,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
frameAbortControllerRef.current?.abort();
|
||||
};
|
||||
}, [durationMinutes, setCurrentTime, stepMinutes]);
|
||||
|
||||
@@ -731,7 +765,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
<Draggable nodeRef={draggableRef} handle=".drag-handle">
|
||||
<div
|
||||
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">
|
||||
<Paper
|
||||
@@ -937,12 +971,13 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
|
||||
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
|
||||
<Slider
|
||||
value={timelineCurrentTime}
|
||||
value={sliderPreviewTime ?? timelineCurrentTime}
|
||||
min={0}
|
||||
max={durationMinutes}
|
||||
step={stepMinutes}
|
||||
marks={timeMarks}
|
||||
onChange={handleSliderChange}
|
||||
onChangeCommitted={handleSliderChangeCommitted}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={formatTime}
|
||||
sx={{
|
||||
|
||||
@@ -138,6 +138,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
const styleEditor = useStyleEditor({
|
||||
layerStyleStates,
|
||||
setLayerStyleStates,
|
||||
workspace: project?.workspace || config.MAP_WORKSPACE,
|
||||
});
|
||||
|
||||
useToolbarChatActions({
|
||||
@@ -803,10 +804,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
onClassificationMethodChange={styleEditor.handleClassificationMethodChange}
|
||||
onSegmentsChange={styleEditor.handleSegmentsChange}
|
||||
onCustomBreakChange={styleEditor.handleCustomBreakChange}
|
||||
onCustomBreakBlur={styleEditor.handleCustomBreakBlur}
|
||||
onColorTypeChange={styleEditor.handleColorTypeChange}
|
||||
onApply={styleEditor.handleApply}
|
||||
onReset={styleEditor.handleReset}
|
||||
validationErrors={styleEditor.validationErrors}
|
||||
isDirty={styleEditor.isDirty}
|
||||
isApplying={styleEditor.isApplying}
|
||||
/>
|
||||
</div>
|
||||
<ToolbarHistoryPanel
|
||||
|
||||
@@ -83,7 +83,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record<
|
||||
styleConfig: {
|
||||
property: "pressure",
|
||||
classificationMethod: "custom_breaks",
|
||||
customBreaks: [16, 18, 20, 22, 24, 26],
|
||||
customBreaks: [16, 18, 20, 22, 24, 26, 28],
|
||||
customColors: [
|
||||
"rgba(255, 0, 0, 1)",
|
||||
"rgba(255, 127, 0, 1)",
|
||||
@@ -137,7 +137,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record<
|
||||
showId: false,
|
||||
opacity: 0.9,
|
||||
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: [],
|
||||
},
|
||||
legendConfig: {
|
||||
|
||||
@@ -3,16 +3,20 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||
|
||||
import { LegendStyleConfig } from "./StyleLegend";
|
||||
|
||||
export type ClassificationMethod = "pretty_breaks" | "custom_breaks";
|
||||
export type ColorType = "single" | "gradient" | "rainbow" | "custom";
|
||||
|
||||
export interface StyleConfig {
|
||||
property: string;
|
||||
classificationMethod: string;
|
||||
classificationMethod: ClassificationMethod;
|
||||
/** Number of rendered intervals. Boundaries always contain segments + 1 values. */
|
||||
segments: number;
|
||||
minSize: number;
|
||||
maxSize: number;
|
||||
minStrokeWidth: number;
|
||||
maxStrokeWidth: number;
|
||||
fixedStrokeWidth: number;
|
||||
colorType: string;
|
||||
colorType: ColorType;
|
||||
singlePaletteIndex: number;
|
||||
gradientPaletteIndex: number;
|
||||
rainbowPaletteIndex: number;
|
||||
@@ -24,6 +28,19 @@ export interface StyleConfig {
|
||||
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 {
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
@@ -37,6 +54,7 @@ export type DefaultLayerStyleId = "junctions" | "pipes";
|
||||
export interface StyleEditorStateProps {
|
||||
layerStyleStates: LayerStyleState[];
|
||||
setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>;
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
export interface AvailableProperty {
|
||||
@@ -50,15 +68,17 @@ export interface StyleEditorFormProps {
|
||||
styleConfig: StyleConfig;
|
||||
setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>;
|
||||
availableProperties: AvailableProperty[];
|
||||
onLayerChange: (index: number) => void;
|
||||
onLayerChange: (layerId: string) => void;
|
||||
onPropertyChange: (property: string) => void;
|
||||
onClassificationMethodChange: (method: string) => void;
|
||||
onClassificationMethodChange: (method: ClassificationMethod) => void;
|
||||
onSegmentsChange: (segments: number) => void;
|
||||
onCustomBreakChange: (index: number, value: string) => void;
|
||||
onCustomBreakBlur: () => void;
|
||||
onColorTypeChange: (colorType: string) => void;
|
||||
onColorTypeChange: (colorType: ColorType) => void;
|
||||
onApply: () => void;
|
||||
onReset: () => void;
|
||||
validationErrors: string[];
|
||||
isDirty: boolean;
|
||||
isApplying: boolean;
|
||||
}
|
||||
|
||||
export interface StyleEditorPanelProps extends StyleEditorFormProps {
|
||||
|
||||
@@ -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 { parseColor } from "@utils/parseColor";
|
||||
@@ -8,16 +8,34 @@ import {
|
||||
RAINBOW_PALETTES,
|
||||
SINGLE_COLOR_PALETTES,
|
||||
} from "./styleEditorPresets";
|
||||
import { StyleConfig } from "./styleEditorTypes";
|
||||
import type {
|
||||
ResolvedLayerStyle,
|
||||
StyleConfig,
|
||||
StyleValidationResult,
|
||||
} from "./styleEditorTypes";
|
||||
|
||||
export const MIN_CLASS_COUNT = 2;
|
||||
export const MAX_CLASS_COUNT = 10;
|
||||
|
||||
const clampIndex = (value: number, length: number) =>
|
||||
Math.min(Math.max(Math.round(value) || 0, 0), Math.max(length - 1, 0));
|
||||
|
||||
const arraysEqual = <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) => {
|
||||
try {
|
||||
const c = parseColor(rgba);
|
||||
const toHex = (n: number) => {
|
||||
const hex = Math.round(n).toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
};
|
||||
return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`;
|
||||
const color = parseColor(rgba);
|
||||
const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0");
|
||||
return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
|
||||
} catch {
|
||||
return "#000000";
|
||||
}
|
||||
@@ -28,25 +46,71 @@ export const hexToRgba = (hex: string) => {
|
||||
return result
|
||||
? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(
|
||||
result[3],
|
||||
16
|
||||
16,
|
||||
)}, 1)`
|
||||
: "rgba(0, 0, 0, 1)";
|
||||
};
|
||||
|
||||
export const getDefaultCustomColors = (
|
||||
segments: number,
|
||||
existingColors: string[] = []
|
||||
existingColors: string[] = [],
|
||||
) => {
|
||||
const nextColors = [...existingColors];
|
||||
const baseColors = RAINBOW_PALETTES[0].colors;
|
||||
|
||||
while (nextColors.length < segments) {
|
||||
nextColors.push(baseColors[nextColors.length % baseColors.length]);
|
||||
}
|
||||
|
||||
return nextColors.slice(0, segments);
|
||||
};
|
||||
|
||||
const createEqualBoundaries = (minimum: number, maximum: number, segments: number) => {
|
||||
if (minimum === maximum) {
|
||||
return Array.from({ length: segments + 1 }, () => minimum);
|
||||
}
|
||||
return Array.from(
|
||||
{ length: segments + 1 },
|
||||
(_, index) => minimum + ((maximum - minimum) * index) / segments,
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeCalculatedBoundaries = (
|
||||
calculated: number[],
|
||||
values: number[],
|
||||
segments: number,
|
||||
) => {
|
||||
const finiteValues = values.filter(Number.isFinite).sort((a, b) => a - b);
|
||||
if (finiteValues.length === 0) return [];
|
||||
const minimum = finiteValues[0];
|
||||
const maximum = finiteValues[finiteValues.length - 1];
|
||||
if (minimum === maximum) return createEqualBoundaries(minimum, maximum, segments);
|
||||
|
||||
const candidates = Array.from(
|
||||
new Set([minimum, ...calculated.filter(Number.isFinite), maximum]),
|
||||
)
|
||||
.filter((value) => value >= minimum && value <= maximum)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
if (candidates.length === segments + 1) return candidates;
|
||||
return createEqualBoundaries(minimum, maximum, segments);
|
||||
};
|
||||
|
||||
export const resolveBoundaries = (
|
||||
values: number[],
|
||||
styleConfig: StyleConfig,
|
||||
): number[] => {
|
||||
const segments = Math.min(
|
||||
MAX_CLASS_COUNT,
|
||||
Math.max(MIN_CLASS_COUNT, Math.round(styleConfig.segments)),
|
||||
);
|
||||
if (styleConfig.classificationMethod === "custom_breaks") {
|
||||
return [...(styleConfig.customBreaks || [])];
|
||||
}
|
||||
const finiteValues = values.filter(Number.isFinite);
|
||||
if (finiteValues.length === 0) return [];
|
||||
const calculated = calculateClassification(finiteValues, segments, "pretty_breaks");
|
||||
return normalizeCalculatedBoundaries(calculated, finiteValues, segments);
|
||||
};
|
||||
|
||||
export const getDefaultCustomBreaks = ({
|
||||
segments,
|
||||
property,
|
||||
@@ -64,261 +128,255 @@ export const getDefaultCustomBreaks = ({
|
||||
currentJunctionCalData?: any[];
|
||||
currentPipeCalData?: any[];
|
||||
}) => {
|
||||
if (!layerId || !property) {
|
||||
return Array.from({ length: segments }, () => 0);
|
||||
let values: number[] = [];
|
||||
if (layerId === "junctions" && property === "elevation" && elevationRange) {
|
||||
values = elevationRange;
|
||||
} else if (layerId === "pipes" && property === "diameter" && diameterRange) {
|
||||
values = diameterRange;
|
||||
} else if (layerId === "junctions") {
|
||||
values = (currentJunctionCalData || []).map((item: any) => Number(item.value));
|
||||
} else if (layerId === "pipes") {
|
||||
values = (currentPipeCalData || []).map((item: any) => Number(item.value));
|
||||
}
|
||||
|
||||
let dataArr: number[] = [];
|
||||
|
||||
const isElevation = layerId === "junctions" && property === "elevation";
|
||||
const isDiameter = layerId === "pipes" && property === "diameter";
|
||||
|
||||
if (isElevation && elevationRange) {
|
||||
dataArr = [elevationRange[0], elevationRange[1]];
|
||||
} else if (isDiameter && diameterRange) {
|
||||
dataArr = [diameterRange[0], diameterRange[1]];
|
||||
} else if (layerId === "junctions" && currentJunctionCalData) {
|
||||
dataArr = currentJunctionCalData.map((d: any) => d.value);
|
||||
} else if (layerId === "pipes" && currentPipeCalData) {
|
||||
dataArr = currentPipeCalData.map((d: any) => d.value);
|
||||
const finiteValues = values.filter(Number.isFinite);
|
||||
if (!property || finiteValues.length === 0) {
|
||||
return Array.from({ length: segments + 1 }, (_, index) => index);
|
||||
}
|
||||
|
||||
if (dataArr.length === 0) {
|
||||
return Array.from({ length: segments }, () => 0);
|
||||
const minimum = Math.min(...finiteValues);
|
||||
const maximum = Math.max(...finiteValues);
|
||||
if (minimum === maximum) {
|
||||
const padding = Math.max(Math.abs(minimum) * 0.01, 1);
|
||||
return createEqualBoundaries(minimum - padding, maximum + padding, segments);
|
||||
}
|
||||
|
||||
const defaultBreaks = calculateClassification(
|
||||
dataArr,
|
||||
return normalizeCalculatedBoundaries(
|
||||
calculateClassification(finiteValues, segments, "pretty_breaks"),
|
||||
finiteValues,
|
||||
segments,
|
||||
"pretty_breaks"
|
||||
).slice(0, segments);
|
||||
|
||||
while (defaultBreaks.length < segments) {
|
||||
defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0);
|
||||
}
|
||||
|
||||
return defaultBreaks;
|
||||
};
|
||||
|
||||
export const normalizeCustomBreaks = (breaks: number[], desired: number) => {
|
||||
const nextBreaks = [...breaks]
|
||||
.slice(0, desired)
|
||||
.filter((value) => value >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
while (nextBreaks.length < desired) {
|
||||
nextBreaks.push(nextBreaks[nextBreaks.length - 1] ?? 0);
|
||||
}
|
||||
|
||||
return nextBreaks;
|
||||
};
|
||||
|
||||
export const addBreakExtrema = (breaks: number[], dataValues: number[]) => {
|
||||
const nextBreaks = [...breaks];
|
||||
const minValue = Math.max(
|
||||
dataValues.reduce((min, value) => Math.min(min, value), Infinity),
|
||||
0
|
||||
);
|
||||
const maxValue = dataValues.reduce(
|
||||
(max, value) => Math.max(max, value),
|
||||
-Infinity
|
||||
);
|
||||
|
||||
if (!nextBreaks.includes(minValue)) {
|
||||
nextBreaks.push(minValue);
|
||||
}
|
||||
|
||||
if (!nextBreaks.includes(maxValue)) {
|
||||
nextBreaks.push(maxValue);
|
||||
}
|
||||
|
||||
nextBreaks.sort((a, b) => a - b);
|
||||
return nextBreaks;
|
||||
};
|
||||
|
||||
export const normalizeCustomBreaks = (breaks: number[], segments: number) => {
|
||||
const finite = breaks.filter(Number.isFinite).slice(0, segments + 1);
|
||||
if (finite.length === segments + 1) return finite;
|
||||
if (finite.length >= 2) {
|
||||
return createEqualBoundaries(finite[0], finite[finite.length - 1], segments);
|
||||
}
|
||||
return Array.from({ length: segments + 1 }, (_, index) => finite[0] ?? index);
|
||||
};
|
||||
|
||||
export const validateStyleConfig = (styleConfig: StyleConfig): StyleValidationResult => {
|
||||
const errors: string[] = [];
|
||||
if (!styleConfig.property) errors.push("请选择分级属性");
|
||||
if (
|
||||
!Number.isInteger(styleConfig.segments) ||
|
||||
styleConfig.segments < MIN_CLASS_COUNT ||
|
||||
styleConfig.segments > MAX_CLASS_COUNT
|
||||
) {
|
||||
errors.push(`分类数量必须是 ${MIN_CLASS_COUNT}-${MAX_CLASS_COUNT} 的整数`);
|
||||
}
|
||||
if (styleConfig.classificationMethod === "custom_breaks") {
|
||||
const boundaries = styleConfig.customBreaks || [];
|
||||
if (boundaries.length !== styleConfig.segments + 1) {
|
||||
errors.push(`需要 ${styleConfig.segments + 1} 个区间边界`);
|
||||
} else if (boundaries.some((value) => !Number.isFinite(value))) {
|
||||
errors.push("区间边界必须是有限数字");
|
||||
} else if (boundaries.some((value, index) => index > 0 && value <= boundaries[index - 1])) {
|
||||
errors.push("区间边界必须严格递增");
|
||||
}
|
||||
}
|
||||
if (styleConfig.colorType === "custom") {
|
||||
const colors = styleConfig.customColors || [];
|
||||
if (colors.length !== styleConfig.segments) {
|
||||
errors.push(`需要 ${styleConfig.segments} 个自定义颜色`);
|
||||
} else if (colors.some((color) => {
|
||||
try {
|
||||
parseColor(color);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})) {
|
||||
errors.push("自定义颜色格式无效");
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(styleConfig.opacity) || styleConfig.opacity < 0 || styleConfig.opacity > 1) {
|
||||
errors.push("透明度必须在 0 到 1 之间");
|
||||
}
|
||||
const paletteIndexes: Array<[number, number]> = [
|
||||
[styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length],
|
||||
[styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length],
|
||||
[styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length],
|
||||
];
|
||||
if (
|
||||
paletteIndexes.some(
|
||||
([index, length]) => !Number.isInteger(index) || index < 0 || index >= length,
|
||||
)
|
||||
) {
|
||||
errors.push("色板索引无效");
|
||||
}
|
||||
if (
|
||||
[
|
||||
styleConfig.minSize,
|
||||
styleConfig.maxSize,
|
||||
styleConfig.minStrokeWidth,
|
||||
styleConfig.maxStrokeWidth,
|
||||
styleConfig.fixedStrokeWidth,
|
||||
].some((value) => !Number.isFinite(value) || value <= 0)
|
||||
) {
|
||||
errors.push("符号尺寸必须大于 0");
|
||||
}
|
||||
if (styleConfig.minSize > styleConfig.maxSize) errors.push("节点最小尺寸不能大于最大尺寸");
|
||||
if (styleConfig.minStrokeWidth > styleConfig.maxStrokeWidth) {
|
||||
errors.push("管线最小宽度不能大于最大宽度");
|
||||
}
|
||||
return { valid: errors.length === 0, errors };
|
||||
};
|
||||
|
||||
export const requiresStyleApply = (
|
||||
applied: StyleConfig | undefined,
|
||||
draft: StyleConfig,
|
||||
) =>
|
||||
!applied ||
|
||||
applied.property !== draft.property ||
|
||||
applied.classificationMethod !== draft.classificationMethod ||
|
||||
applied.segments !== draft.segments ||
|
||||
(draft.classificationMethod === "custom_breaks" &&
|
||||
!arraysEqual(applied.customBreaks, draft.customBreaks));
|
||||
|
||||
export const resolveStyleColors = (
|
||||
styleConfig: StyleConfig,
|
||||
breaksLength: number
|
||||
classCount = styleConfig.segments,
|
||||
): string[] => {
|
||||
if (styleConfig.colorType === "single") {
|
||||
return Array.from(
|
||||
{ length: breaksLength },
|
||||
() => SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color
|
||||
);
|
||||
const palette = SINGLE_COLOR_PALETTES[
|
||||
clampIndex(styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length)
|
||||
];
|
||||
return Array.from({ length: classCount }, () => palette.color);
|
||||
}
|
||||
|
||||
if (styleConfig.colorType === "gradient") {
|
||||
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
|
||||
const startColor = parseColor(start);
|
||||
const endColor = parseColor(end);
|
||||
|
||||
return Array.from({ length: breaksLength }, (_, index) => {
|
||||
const ratio = breaksLength > 1 ? index / (breaksLength - 1) : 1;
|
||||
const r = Math.round(startColor.r + (endColor.r - startColor.r) * ratio);
|
||||
const g = Math.round(startColor.g + (endColor.g - startColor.g) * ratio);
|
||||
const b = Math.round(startColor.b + (endColor.b - startColor.b) * ratio);
|
||||
return `rgba(${r}, ${g}, ${b}, 1)`;
|
||||
const palette = GRADIENT_PALETTES[
|
||||
clampIndex(styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length)
|
||||
];
|
||||
const start = parseColor(palette.start);
|
||||
const end = parseColor(palette.end);
|
||||
return Array.from({ length: classCount }, (_, index) => {
|
||||
const ratio = classCount > 1 ? index / (classCount - 1) : 0;
|
||||
return `rgba(${Math.round(start.r + (end.r - start.r) * ratio)}, ${Math.round(
|
||||
start.g + (end.g - start.g) * ratio,
|
||||
)}, ${Math.round(start.b + (end.b - start.b) * ratio)}, 1)`;
|
||||
});
|
||||
}
|
||||
|
||||
if (styleConfig.colorType === "rainbow") {
|
||||
const baseColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
|
||||
const palette = RAINBOW_PALETTES[
|
||||
clampIndex(styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length)
|
||||
];
|
||||
return Array.from(
|
||||
{ length: breaksLength },
|
||||
(_, index) => baseColors[index % baseColors.length]
|
||||
{ length: classCount },
|
||||
(_, index) => palette.colors[index % palette.colors.length],
|
||||
);
|
||||
}
|
||||
|
||||
const customColors = styleConfig.customColors || [];
|
||||
const reverseRainbowColors = RAINBOW_PALETTES[1].colors;
|
||||
const result = [...customColors];
|
||||
|
||||
while (result.length < breaksLength) {
|
||||
result.push(
|
||||
reverseRainbowColors[
|
||||
(result.length - customColors.length) % reverseRainbowColors.length
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return result.slice(0, breaksLength);
|
||||
};
|
||||
|
||||
export const getSizePreviewColors = (styleConfig: StyleConfig) => {
|
||||
if (styleConfig.colorType === "single") {
|
||||
const color = SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color;
|
||||
return [color, color];
|
||||
}
|
||||
|
||||
if (styleConfig.colorType === "gradient") {
|
||||
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
if (styleConfig.colorType === "rainbow") {
|
||||
const rainbowColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
|
||||
return [rainbowColors[0], rainbowColors[rainbowColors.length - 1]];
|
||||
}
|
||||
|
||||
const customColors = styleConfig.customColors || [];
|
||||
return [
|
||||
customColors[0] || "rgba(0,0,0,1)",
|
||||
customColors[customColors.length - 1] || "rgba(0,0,0,1)",
|
||||
];
|
||||
return getDefaultCustomColors(classCount, styleConfig.customColors || []);
|
||||
};
|
||||
|
||||
export const resolveDimensions = ({
|
||||
layerType,
|
||||
styleConfig,
|
||||
breaksLength,
|
||||
classCount = styleConfig.segments,
|
||||
}: {
|
||||
layerType: string;
|
||||
styleConfig: StyleConfig;
|
||||
breaksLength: number;
|
||||
classCount?: number;
|
||||
}) => {
|
||||
const interpolate = (minimum: number, maximum: number, index: number) => {
|
||||
const ratio = classCount > 1 ? index / (classCount - 1) : 0;
|
||||
return minimum + (maximum - minimum) * ratio;
|
||||
};
|
||||
if (layerType === "linestring") {
|
||||
if (styleConfig.adjustWidthByProperty) {
|
||||
return Array.from({ length: breaksLength }, (_, index) => {
|
||||
const ratio = index / (breaksLength - 1);
|
||||
return (
|
||||
styleConfig.minStrokeWidth +
|
||||
(styleConfig.maxStrokeWidth - styleConfig.minStrokeWidth) * ratio
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
{ length: breaksLength },
|
||||
() => styleConfig.fixedStrokeWidth
|
||||
return Array.from({ length: classCount }, (_, index) =>
|
||||
styleConfig.adjustWidthByProperty
|
||||
? interpolate(styleConfig.minStrokeWidth, styleConfig.maxStrokeWidth, index)
|
||||
: styleConfig.fixedStrokeWidth,
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from({ length: breaksLength }, (_, index) => {
|
||||
const ratio = index / (breaksLength - 1);
|
||||
return styleConfig.minSize + (styleConfig.maxSize - styleConfig.minSize) * ratio;
|
||||
});
|
||||
return Array.from({ length: classCount }, (_, index) =>
|
||||
interpolate(styleConfig.minSize, styleConfig.maxSize, index),
|
||||
);
|
||||
};
|
||||
|
||||
export const buildDynamicStyle = ({
|
||||
export const resolveLayerStyle = ({
|
||||
layerType,
|
||||
styleConfig,
|
||||
breaks,
|
||||
colors,
|
||||
dimensions,
|
||||
values,
|
||||
}: {
|
||||
layerType: string;
|
||||
styleConfig: StyleConfig;
|
||||
breaks: number[];
|
||||
colors: string[];
|
||||
dimensions: number[];
|
||||
}): FlatStyleLike => {
|
||||
const generateColorConditions = (property: string): any[] => {
|
||||
const conditions: any[] = ["case"];
|
||||
for (let index = 1; index < breaks.length; index++) {
|
||||
if (property === "unit_headloss") {
|
||||
conditions.push([
|
||||
"<=",
|
||||
["/", ["get", "unit_headloss"], ["/", ["get", "length"], 1000]],
|
||||
breaks[index],
|
||||
]);
|
||||
} else {
|
||||
conditions.push(["<=", ["get", property], breaks[index]]);
|
||||
}
|
||||
const colorObj = parseColor(colors[index - 1]);
|
||||
conditions.push(
|
||||
`rgba(${colorObj.r}, ${colorObj.g}, ${colorObj.b}, ${styleConfig.opacity})`
|
||||
values: number[];
|
||||
}): ResolvedLayerStyle | null => {
|
||||
const boundaries = resolveBoundaries(values, styleConfig);
|
||||
if (boundaries.length !== styleConfig.segments + 1) return null;
|
||||
const colors = resolveStyleColors(styleConfig, styleConfig.segments);
|
||||
const dimensions = resolveDimensions({ layerType, styleConfig });
|
||||
const isConstant = boundaries.every((value) => value === boundaries[0]);
|
||||
const labels = isConstant
|
||||
? [`${formatBoundary(boundaries[0])}`]
|
||||
: Array.from(
|
||||
{ length: styleConfig.segments },
|
||||
(_, index) => `${formatBoundary(boundaries[index])} - ${formatBoundary(boundaries[index + 1])}`,
|
||||
);
|
||||
}
|
||||
const defaultColor = parseColor(colors[0]);
|
||||
conditions.push(
|
||||
`rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${styleConfig.opacity})`
|
||||
return { boundaries, colors, dimensions, labels, isConstant };
|
||||
};
|
||||
|
||||
const valueExpression = (property: string): any[] => ["get", property];
|
||||
|
||||
const buildVariableCase = (property: string, classCount: number, variablePrefix: string) => {
|
||||
const expression: any[] = ["case"];
|
||||
for (let index = 0; index < classCount - 1; index += 1) {
|
||||
expression.push(
|
||||
["<=", valueExpression(property), ["var", `tj_break_${index + 1}`]],
|
||||
["var", `${variablePrefix}_${index}`],
|
||||
);
|
||||
return conditions;
|
||||
};
|
||||
|
||||
const generateDimensionConditions = (property: string): any[] => {
|
||||
const conditions: any[] = ["case"];
|
||||
for (let index = 0; index < breaks.length; index++) {
|
||||
if (property === "unit_headloss") {
|
||||
conditions.push([
|
||||
"<=",
|
||||
["/", ["get", "headloss"], ["get", "length"]],
|
||||
breaks[index],
|
||||
]);
|
||||
} else {
|
||||
conditions.push(["<=", ["get", property], breaks[index]]);
|
||||
}
|
||||
conditions.push(dimensions[index]);
|
||||
}
|
||||
conditions.push(dimensions[dimensions.length - 1]);
|
||||
return conditions;
|
||||
};
|
||||
|
||||
const generatePointDimensionConditions = (property: string): any[] => {
|
||||
const conditions: any[] = ["case"];
|
||||
for (let index = 0; index < breaks.length; index++) {
|
||||
conditions.push(["<=", ["get", property], breaks[index]]);
|
||||
conditions.push(["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimensions[index]]);
|
||||
}
|
||||
conditions.push(dimensions[dimensions.length - 1]);
|
||||
return conditions;
|
||||
};
|
||||
|
||||
const dynamicStyle: FlatStyleLike = {};
|
||||
|
||||
if (layerType === "linestring") {
|
||||
dynamicStyle["stroke-color"] = generateColorConditions(styleConfig.property);
|
||||
dynamicStyle["stroke-width"] = generateDimensionConditions(styleConfig.property);
|
||||
} else if (layerType === "point") {
|
||||
dynamicStyle["circle-fill-color"] = generateColorConditions(styleConfig.property);
|
||||
dynamicStyle["circle-radius"] = generatePointDimensionConditions(
|
||||
styleConfig.property
|
||||
);
|
||||
dynamicStyle["circle-stroke-color"] = generateColorConditions(styleConfig.property);
|
||||
dynamicStyle["circle-stroke-width"] = 2;
|
||||
}
|
||||
expression.push(["var", `${variablePrefix}_${classCount - 1}`]);
|
||||
return expression;
|
||||
};
|
||||
|
||||
return dynamicStyle;
|
||||
export const buildDynamicStyleTemplate = ({
|
||||
layerType,
|
||||
property,
|
||||
classCount,
|
||||
}: {
|
||||
layerType: string;
|
||||
property: string;
|
||||
classCount: number;
|
||||
}): FlatStyleLike => {
|
||||
const color = buildVariableCase(property, classCount, "tj_color");
|
||||
const dimension = buildVariableCase(property, classCount, "tj_size");
|
||||
if (layerType === "linestring") {
|
||||
return { "stroke-color": color, "stroke-width": dimension };
|
||||
}
|
||||
return {
|
||||
"circle-fill-color": color,
|
||||
"circle-radius": ["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimension],
|
||||
"circle-stroke-color": color,
|
||||
"circle-stroke-width": 2,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildStyleVariables = (
|
||||
styleConfig: StyleConfig,
|
||||
resolvedStyle: ResolvedLayerStyle,
|
||||
): StyleVariables => {
|
||||
const variables: StyleVariables = {};
|
||||
resolvedStyle.boundaries.slice(1, -1).forEach((boundary, index) => {
|
||||
variables[`tj_break_${index + 1}`] = boundary;
|
||||
});
|
||||
resolvedStyle.colors.forEach((color, index) => {
|
||||
variables[`tj_color_${index}`] = withOpacity(color, styleConfig.opacity);
|
||||
});
|
||||
resolvedStyle.dimensions.forEach((dimension, index) => {
|
||||
variables[`tj_size_${index}`] = dimension;
|
||||
});
|
||||
return variables;
|
||||
};
|
||||
|
||||
export const buildContourDefinitions = ({
|
||||
@@ -329,20 +387,12 @@ export const buildContourDefinitions = ({
|
||||
styleConfig: StyleConfig;
|
||||
breaks: number[];
|
||||
colors: string[];
|
||||
}) => {
|
||||
const contours = [];
|
||||
for (let index = 0; index < breaks.length - 1; index++) {
|
||||
const colorObj = parseColor(colors[index]);
|
||||
contours.push({
|
||||
}) =>
|
||||
colors.map((color, index) => {
|
||||
const parsed = parseColor(color);
|
||||
return {
|
||||
threshold: [breaks[index], breaks[index + 1]],
|
||||
color: [
|
||||
colorObj.r,
|
||||
colorObj.g,
|
||||
colorObj.b,
|
||||
Math.round(styleConfig.opacity * 255),
|
||||
],
|
||||
color: [parsed.r, parsed.g, parsed.b, Math.round(styleConfig.opacity * 255)],
|
||||
strokeWidth: 0,
|
||||
});
|
||||
}
|
||||
return contours;
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import React, {
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Map as OlMap, VectorTile } from "ol";
|
||||
import { Map as OlMap } from "ol";
|
||||
import View from "ol/View.js";
|
||||
import "ol/ol.css";
|
||||
import MapTools from "./MapTools";
|
||||
@@ -31,13 +31,47 @@ import {
|
||||
disposeMapResources,
|
||||
markMapResourcePersistent,
|
||||
} from "./mapLifecycle";
|
||||
import { createOperationalMapResources } from "./operationalLayers";
|
||||
import {
|
||||
createOperationalMapResources,
|
||||
createOperationalMapSources,
|
||||
} from "./operationalLayers";
|
||||
import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime";
|
||||
import { useTimelineTimeConfig } from "./Controls/useTimelineTimeConfig";
|
||||
import {
|
||||
TileFeatureIndex,
|
||||
clipLineStringPartsToExtent,
|
||||
coordinatesToLonLat,
|
||||
lineStringFromFlatCoordinates,
|
||||
type TileFeatureInstance,
|
||||
} from "./tileFeatureIndex";
|
||||
|
||||
interface MapComponentProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const COMPARE_OPEN_START_MARK = "tjwater:compare-open-start";
|
||||
const COMPARE_OPEN_READY_MARK = "tjwater:compare-open-ready";
|
||||
const COMPARE_OPEN_MEASURE = "tjwater:compare-open";
|
||||
|
||||
const markCompareOpenStart = () => {
|
||||
performance.clearMarks(COMPARE_OPEN_START_MARK);
|
||||
performance.clearMarks(COMPARE_OPEN_READY_MARK);
|
||||
performance.clearMeasures(COMPARE_OPEN_MEASURE);
|
||||
performance.mark(COMPARE_OPEN_START_MARK);
|
||||
};
|
||||
|
||||
const markCompareOpenReady = () => {
|
||||
if (performance.getEntriesByName(COMPARE_OPEN_START_MARK).length === 0) {
|
||||
return;
|
||||
}
|
||||
performance.mark(COMPARE_OPEN_READY_MARK);
|
||||
performance.measure(
|
||||
COMPARE_OPEN_MEASURE,
|
||||
COMPARE_OPEN_START_MARK,
|
||||
COMPARE_OPEN_READY_MARK,
|
||||
);
|
||||
};
|
||||
|
||||
interface DataContextType {
|
||||
currentTime?: number; // 当前时间
|
||||
setCurrentTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||
@@ -122,6 +156,57 @@ function debounce<F extends (...args: any[]) => any>(
|
||||
return debounced;
|
||||
}
|
||||
|
||||
const indexCalculationRecords = (records: any[]) =>
|
||||
new Map(records.map((record) => [String(record.ID), record]));
|
||||
|
||||
const mergeJunctionValues = (
|
||||
features: any[],
|
||||
records: any[],
|
||||
property: string,
|
||||
) => {
|
||||
const recordsById = indexCalculationRecords(records);
|
||||
return features.map((feature) => {
|
||||
const record = recordsById.get(String(feature.id));
|
||||
if (!record) return feature;
|
||||
const value = isLpsFlowProperty(property)
|
||||
? toM3h(record.value, "lps")
|
||||
: record.value;
|
||||
return { ...feature, [property]: value };
|
||||
});
|
||||
};
|
||||
|
||||
const mergePipeValues = (
|
||||
features: any[],
|
||||
records: any[],
|
||||
property: string,
|
||||
) => {
|
||||
const recordsById = indexCalculationRecords(records);
|
||||
const isFlow = property === "flow";
|
||||
return features.map((feature) => {
|
||||
const record = recordsById.get(String(feature.id));
|
||||
if (!record) return feature;
|
||||
const value = isFlow ? toM3h(record.value, "lps") : record.value;
|
||||
const reverseFlow = isFlow && record.value < 0;
|
||||
return {
|
||||
...feature,
|
||||
[property]: isFlow ? Math.abs(value) : value,
|
||||
flowFlag: reverseFlow ? -1 : 1,
|
||||
path: reverseFlow ? [...feature.path].reverse() : feature.path,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getNumericRange = (values: number[]): [number, number] | undefined => {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
values.forEach((value) => {
|
||||
if (!Number.isFinite(value)) return;
|
||||
min = Math.min(min, value);
|
||||
max = Math.max(max, value);
|
||||
});
|
||||
return min === Infinity ? undefined : [min, max];
|
||||
};
|
||||
|
||||
export const useMap = () => {
|
||||
return useContext(MapContext);
|
||||
};
|
||||
@@ -151,7 +236,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
const compareDeckLayerRef = useRef<DeckLayer | null>(null);
|
||||
const isDisposingRef = useRef(false);
|
||||
const isCompareDisposingRef = useRef(false);
|
||||
const pendingTimeoutsRef = useRef<number[]>([]);
|
||||
|
||||
const [map, setMap] = useState<OlMap>();
|
||||
const [deckLayer, setDeckLayer] = useState<DeckLayer>();
|
||||
@@ -174,14 +258,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
);
|
||||
const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]);
|
||||
const [isCompareMode, setCompareMode] = useState(false);
|
||||
// junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染
|
||||
// currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值
|
||||
// junctionData 为当前层级和视口内按 ID 去重的节点数据;pipeData 为管道标签数据。
|
||||
// pipeFragments 保留当前层级和视口内每个瓦片管道片段,水流动画按 instanceKey 渲染,不能按业务 ID 去重。
|
||||
const [junctionData, setJunctionDataState] = useState<any[]>([]);
|
||||
const [pipeData, setPipeDataState] = useState<any[]>([]);
|
||||
const junctionDataIds = useRef(new Set<string>());
|
||||
const pipeDataIds = useRef(new Set<string>());
|
||||
const tileJunctionDataBuffer = useRef<any[]>([]);
|
||||
const tilePipeDataBuffer = useRef<any[]>([]);
|
||||
const [pipeFragments, setPipeFragments] = useState<any[]>([]);
|
||||
const junctionIndexRef = useRef<TileFeatureIndex | null>(null);
|
||||
const pipeIndexRef = useRef<TileFeatureIndex | null>(null);
|
||||
|
||||
const [showJunctionTextLayer, setShowJunctionTextLayer] = useState(false); // 控制节点文本图层显示
|
||||
const [showPipeTextLayer, setShowPipeTextLayer] = useState(false); // 控制管道文本图层显示
|
||||
@@ -191,7 +274,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
const [junctionText, setJunctionText] = useState("pressure");
|
||||
const [pipeText, setPipeText] = useState("velocity");
|
||||
const [contours, setContours] = useState<any[]>([]);
|
||||
const flowAnimation = useRef(false); // 添加动画控制标志
|
||||
const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示
|
||||
const [isWaterflowLayerAvailable, setWaterflowLayerAvailable] =
|
||||
useState(false); // 控制等高线图层显示
|
||||
@@ -199,67 +281,40 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别
|
||||
|
||||
// 实时合并计算结果到基础地理数据中
|
||||
const mergedJunctionData = useMemo(() => {
|
||||
const nodeMap = new Map(currentJunctionCalData.map((r: any) => [r.ID, r]));
|
||||
return junctionData.map((j) => {
|
||||
const record = nodeMap.get(j.id);
|
||||
let val = record ? record.value : undefined;
|
||||
if (val !== undefined && isLpsFlowProperty(junctionText)) {
|
||||
val = toM3h(val, "lps");
|
||||
}
|
||||
return record ? { ...j, [junctionText]: val } : j;
|
||||
});
|
||||
}, [junctionData, currentJunctionCalData, junctionText]);
|
||||
|
||||
const mergedPipeData = useMemo(() => {
|
||||
const linkMap = new Map(currentPipeCalData.map((r: any) => [r.ID, r]));
|
||||
return pipeData.map((p) => {
|
||||
const record = linkMap.get(p.id);
|
||||
if (!record) return p;
|
||||
const isFlow = pipeText === "flow";
|
||||
let val = record.value;
|
||||
if (val !== undefined && isFlow) {
|
||||
val = toM3h(val, "lps");
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
[pipeText]: isFlow ? Math.abs(val) : val,
|
||||
flowFlag: isFlow && record.value < 0 ? -1 : 1,
|
||||
path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path,
|
||||
};
|
||||
});
|
||||
}, [pipeData, currentPipeCalData, pipeText]);
|
||||
|
||||
const mergedCompareJunctionData = useMemo(() => {
|
||||
const nodeMap = new Map(compareJunctionCalData.map((r: any) => [r.ID, r]));
|
||||
return junctionData.map((j) => {
|
||||
const record = nodeMap.get(j.id);
|
||||
let val = record ? record.value : undefined;
|
||||
if (val !== undefined && isLpsFlowProperty(junctionText)) {
|
||||
val = toM3h(val, "lps");
|
||||
}
|
||||
return record ? { ...j, [junctionText]: val } : j;
|
||||
});
|
||||
}, [junctionData, compareJunctionCalData, junctionText]);
|
||||
|
||||
const mergedComparePipeData = useMemo(() => {
|
||||
const linkMap = new Map(comparePipeCalData.map((r: any) => [r.ID, r]));
|
||||
return pipeData.map((p) => {
|
||||
const record = linkMap.get(p.id);
|
||||
if (!record) return p;
|
||||
const isFlow = pipeText === "flow";
|
||||
let val = record.value;
|
||||
if (val !== undefined && isFlow) {
|
||||
val = toM3h(val, "lps");
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
[pipeText]: isFlow ? Math.abs(val) : val,
|
||||
flowFlag: isFlow && record.value < 0 ? -1 : 1,
|
||||
path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path,
|
||||
};
|
||||
});
|
||||
}, [pipeData, comparePipeCalData, pipeText]);
|
||||
const mergedJunctionData = useMemo(
|
||||
() =>
|
||||
mergeJunctionValues(junctionData, currentJunctionCalData, junctionText),
|
||||
[junctionData, currentJunctionCalData, junctionText],
|
||||
);
|
||||
const mergedPipeData = useMemo(
|
||||
() => mergePipeValues(pipeData, currentPipeCalData, pipeText),
|
||||
[pipeData, currentPipeCalData, pipeText],
|
||||
);
|
||||
const mergedPipeFragments = useMemo(
|
||||
() => mergePipeValues(pipeFragments, currentPipeCalData, pipeText),
|
||||
[pipeFragments, currentPipeCalData, pipeText],
|
||||
);
|
||||
const mergedCompareJunctionData = useMemo(
|
||||
() =>
|
||||
isCompareMode
|
||||
? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText)
|
||||
: [],
|
||||
[isCompareMode, junctionData, compareJunctionCalData, junctionText],
|
||||
);
|
||||
const mergedComparePipeData = useMemo(
|
||||
() =>
|
||||
isCompareMode
|
||||
? mergePipeValues(pipeData, comparePipeCalData, pipeText)
|
||||
: [],
|
||||
[isCompareMode, pipeData, comparePipeCalData, pipeText],
|
||||
);
|
||||
const mergedComparePipeFragments = useMemo(
|
||||
() =>
|
||||
isCompareMode
|
||||
? mergePipeValues(pipeFragments, comparePipeCalData, pipeText)
|
||||
: [],
|
||||
[isCompareMode, pipeFragments, comparePipeCalData, pipeText],
|
||||
);
|
||||
|
||||
const [diameterRange, setDiameterRange] = useState<
|
||||
[number, number] | undefined
|
||||
@@ -271,7 +326,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
useState(0);
|
||||
|
||||
const toggleCompareMode = useCallback(() => {
|
||||
setCompareMode((prev) => !prev);
|
||||
setCompareMode((prev) => {
|
||||
if (!prev) markCompareOpenStart();
|
||||
return !prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const maps = useMemo(
|
||||
@@ -288,91 +346,126 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
[compareDeckLayer, deckLayer, isCompareMode],
|
||||
);
|
||||
|
||||
const setJunctionData = (newData: any[]) => {
|
||||
const uniqueNewData = newData.filter((item) => {
|
||||
if (!item || !item.id) return false;
|
||||
if (!junctionDataIds.current.has(item.id)) {
|
||||
junctionDataIds.current.add(item.id);
|
||||
return true;
|
||||
const buildPipeFragments = useCallback((instance: TileFeatureInstance) => {
|
||||
const tileCoordinates = lineStringFromFlatCoordinates(
|
||||
instance.flatCoordinates,
|
||||
instance.stride,
|
||||
);
|
||||
const clippedParts = clipLineStringPartsToExtent(
|
||||
tileCoordinates,
|
||||
instance.tileExtent,
|
||||
);
|
||||
return clippedParts.flatMap((clippedCoordinates, partIndex) => {
|
||||
const path = coordinatesToLonLat(clippedCoordinates);
|
||||
const lineStringFeature = lineString(path);
|
||||
const fragmentLength = length(lineStringFeature);
|
||||
if (fragmentLength <= 0) return [];
|
||||
|
||||
const timestamps = [0];
|
||||
let cumulativeLength = 0;
|
||||
for (let i = 1; i < path.length; i += 1) {
|
||||
cumulativeLength += length(lineString([path[i - 1], path[i]]));
|
||||
timestamps.push((cumulativeLength / fragmentLength) * 10);
|
||||
}
|
||||
return false;
|
||||
|
||||
const midPoint = along(lineStringFeature, fragmentLength / 2).geometry
|
||||
.coordinates;
|
||||
const prevPoint = along(lineStringFeature, fragmentLength * 0.49).geometry
|
||||
.coordinates;
|
||||
const nextPoint = along(lineStringFeature, fragmentLength * 0.51).geometry
|
||||
.coordinates;
|
||||
let lineAngle = bearing(prevPoint, nextPoint);
|
||||
lineAngle = -lineAngle + 90;
|
||||
if (lineAngle < -90 || lineAngle > 90) {
|
||||
lineAngle += 180;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
instanceKey: `${instance.instanceKey}/${partIndex}`,
|
||||
id: instance.featureId,
|
||||
diameter: instance.properties.diameter || 0,
|
||||
length: instance.properties.length || fragmentLength * 1000,
|
||||
path,
|
||||
position: midPoint,
|
||||
angle: lineAngle,
|
||||
timestamps,
|
||||
fragmentLength,
|
||||
},
|
||||
];
|
||||
});
|
||||
if (uniqueNewData.length > 0) {
|
||||
setJunctionDataState((prev) => prev.concat(uniqueNewData));
|
||||
setElevationRange((prev) => {
|
||||
const elevations = uniqueNewData
|
||||
.map((d) => d.elevation)
|
||||
.filter((v) => typeof v === "number");
|
||||
if (elevations.length === 0) return prev;
|
||||
|
||||
let newMin = elevations[0];
|
||||
let newMax = elevations[0];
|
||||
for (let i = 1; i < elevations.length; i++) {
|
||||
if (elevations[i] < newMin) newMin = elevations[i];
|
||||
if (elevations[i] > newMax) newMax = elevations[i];
|
||||
}
|
||||
|
||||
if (!prev) {
|
||||
return [newMin, newMax];
|
||||
}
|
||||
return [Math.min(prev[0], newMin), Math.max(prev[1], newMax)];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const setPipeData = (newData: any[]) => {
|
||||
const uniqueNewData = newData.filter((item) => {
|
||||
if (!item || !item.id) return false;
|
||||
if (!pipeDataIds.current.has(item.id)) {
|
||||
pipeDataIds.current.add(item.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (uniqueNewData.length > 0) {
|
||||
setPipeDataState((prev) => prev.concat(uniqueNewData));
|
||||
setDiameterRange((prev) => {
|
||||
const diameters = uniqueNewData
|
||||
.map((d) => d.diameter)
|
||||
.filter((v) => typeof v === "number");
|
||||
if (diameters.length === 0) return prev;
|
||||
|
||||
let newMin = diameters[0];
|
||||
let newMax = diameters[0];
|
||||
for (let i = 1; i < diameters.length; i++) {
|
||||
if (diameters[i] < newMin) newMin = diameters[i];
|
||||
if (diameters[i] > newMax) newMax = diameters[i];
|
||||
}
|
||||
|
||||
if (!prev) {
|
||||
return [newMin, newMax];
|
||||
}
|
||||
return [Math.min(prev[0], newMin), Math.max(prev[1], newMax)];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedUpdateDataRef = useRef<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(
|
||||
() =>
|
||||
createOperationalMapResources({
|
||||
@@ -380,8 +473,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
workspace: MAP_WORKSPACE,
|
||||
extent: MAP_EXTENT,
|
||||
persistent: true,
|
||||
sources: operationalSources,
|
||||
}),
|
||||
[MAP_URL, MAP_WORKSPACE, MAP_EXTENT],
|
||||
[MAP_URL, MAP_WORKSPACE, MAP_EXTENT, operationalSources],
|
||||
);
|
||||
const { junctions: junctionSource, pipes: pipeSource } =
|
||||
operationalResources.sources;
|
||||
@@ -395,60 +489,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
isDisposingRef.current = false;
|
||||
const activeJunctionDataIds = junctionDataIds.current;
|
||||
const activePipeDataIds = pipeDataIds.current;
|
||||
|
||||
const addTimeout = (callback: () => void, delay: number) => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
pendingTimeoutsRef.current = pendingTimeoutsRef.current.filter(
|
||||
(id) => id !== timerId,
|
||||
);
|
||||
if (isDisposingRef.current) return;
|
||||
callback();
|
||||
}, delay);
|
||||
pendingTimeoutsRef.current.push(timerId);
|
||||
return timerId;
|
||||
};
|
||||
junctionIndexRef.current = new TileFeatureIndex("junctions", junctionSource);
|
||||
pipeIndexRef.current = new TileFeatureIndex("pipes", pipeSource);
|
||||
|
||||
const clearPendingTimeouts = () => {
|
||||
pendingTimeoutsRef.current.forEach((id) => clearTimeout(id));
|
||||
pendingTimeoutsRef.current = [];
|
||||
};
|
||||
|
||||
// 缓存 junction、pipe 数据,提供给 deck.gl 提供坐标供标签显示
|
||||
const handleJunctionTileLoadEnd = (event: any) => {
|
||||
if (isDisposingRef.current) return;
|
||||
try {
|
||||
if (event.tile instanceof VectorTile) {
|
||||
const renderFeatures = event.tile.getFeatures();
|
||||
const data = new Map();
|
||||
|
||||
renderFeatures.forEach((renderFeature: any) => {
|
||||
const props = renderFeature.getProperties();
|
||||
const featureId = props.id;
|
||||
if (featureId && !junctionDataIds.current.has(featureId)) {
|
||||
const geometry = renderFeature.getGeometry();
|
||||
if (geometry) {
|
||||
const coordinates = geometry.getFlatCoordinates();
|
||||
const coordWGS84 = toLonLat(coordinates);
|
||||
data.set(featureId, {
|
||||
id: featureId,
|
||||
position: coordWGS84,
|
||||
elevation: props.elevation || 0,
|
||||
demand: props.demand || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueData = Array.from(data.values());
|
||||
if (uniqueData.length > 0) {
|
||||
uniqueData.forEach((item) =>
|
||||
tileJunctionDataBuffer.current.push(item),
|
||||
);
|
||||
debouncedUpdateDataRef.current?.();
|
||||
}
|
||||
}
|
||||
junctionIndexRef.current?.registerTile(event.tile);
|
||||
scheduleActiveTileSnapshot();
|
||||
} catch (error) {
|
||||
console.error("Junction tile load error:", error);
|
||||
}
|
||||
@@ -456,86 +505,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
const handlePipeTileLoadEnd = (event: any) => {
|
||||
if (isDisposingRef.current) return;
|
||||
try {
|
||||
if (event.tile instanceof VectorTile) {
|
||||
const renderFeatures = event.tile.getFeatures();
|
||||
const data = new Map();
|
||||
|
||||
renderFeatures.forEach((renderFeature: any) => {
|
||||
try {
|
||||
const props = renderFeature.getProperties();
|
||||
const featureId = props.id;
|
||||
if (featureId && !pipeDataIds.current.has(featureId)) {
|
||||
const geometry = renderFeature.getGeometry();
|
||||
if (geometry) {
|
||||
const flatCoordinates = geometry.getFlatCoordinates();
|
||||
const stride = geometry.getStride(); // 获取步长,通常为 2
|
||||
// 重建为 LineString GeoJSON 格式的 coordinates: [[x1, y1], [x2, y2], ...]
|
||||
const lineCoords = [];
|
||||
for (let i = 0; i < flatCoordinates.length; i += stride) {
|
||||
lineCoords.push([
|
||||
flatCoordinates[i],
|
||||
flatCoordinates[i + 1],
|
||||
]);
|
||||
}
|
||||
const lineCoordsWGS84 = lineCoords.map((coord) => {
|
||||
const [lon, lat] = toLonLat(coord);
|
||||
return [lon, lat];
|
||||
});
|
||||
// 添加验证:确保至少有 2 个坐标点
|
||||
if (lineCoordsWGS84.length < 2) return; // 跳过此特征
|
||||
// 计算中点
|
||||
const lineStringFeature = lineString(lineCoordsWGS84);
|
||||
const lineLength = length(lineStringFeature);
|
||||
const midPoint = along(lineStringFeature, lineLength / 2)
|
||||
.geometry.coordinates;
|
||||
// 计算角度
|
||||
const prevPoint = along(lineStringFeature, lineLength * 0.49)
|
||||
.geometry.coordinates;
|
||||
const nextPoint = along(lineStringFeature, lineLength * 0.51)
|
||||
.geometry.coordinates;
|
||||
let lineAngle = bearing(prevPoint, nextPoint);
|
||||
lineAngle = -lineAngle + 90;
|
||||
if (lineAngle < -90 || lineAngle > 90) {
|
||||
lineAngle += 180;
|
||||
}
|
||||
|
||||
// 计算时间戳(可选)
|
||||
const numSegments = lineCoordsWGS84.length - 1;
|
||||
const timestamps = [0];
|
||||
if (numSegments > 0) {
|
||||
for (let i = 1; i <= numSegments; i++) {
|
||||
timestamps.push((i / numSegments) * 10);
|
||||
}
|
||||
}
|
||||
|
||||
data.set(featureId, {
|
||||
id: featureId,
|
||||
diameter: props.diameter || 0,
|
||||
length: props.length || 0,
|
||||
path: lineCoordsWGS84, // 使用重建后的坐标
|
||||
position: midPoint,
|
||||
angle: lineAngle,
|
||||
timestamps,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (geomError) {
|
||||
console.error("Geometry calculation error:", geomError);
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueData = Array.from(data.values());
|
||||
if (uniqueData.length > 0) {
|
||||
uniqueData.forEach((item) => tilePipeDataBuffer.current.push(item));
|
||||
debouncedUpdateDataRef.current?.();
|
||||
}
|
||||
}
|
||||
pipeIndexRef.current?.registerTile(event.tile);
|
||||
scheduleActiveTileSnapshot();
|
||||
} catch (error) {
|
||||
console.error("Pipe tile load error:", error);
|
||||
}
|
||||
};
|
||||
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
|
||||
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
|
||||
// 监听 junctionsLayer 的 visible 变化
|
||||
const handleJunctionVisibilityChange = () => {
|
||||
const isVisible = junctionsLayer.getVisible();
|
||||
@@ -559,6 +534,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
layers: operationalResources.orderedLayers.slice(),
|
||||
controls: [],
|
||||
});
|
||||
const scheduleActiveTileSnapshot = debounce(
|
||||
() => publishActiveTileSnapshot(map),
|
||||
50,
|
||||
);
|
||||
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
|
||||
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
|
||||
map.getInteractions().forEach(markMapResourcePersistent);
|
||||
setMap(map);
|
||||
|
||||
@@ -596,14 +577,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
duration: 1000,
|
||||
});
|
||||
}
|
||||
// 持久化视图(中心点 + 缩放),防抖写入 localStorage
|
||||
const persistView = debounce(() => {
|
||||
// 视图稳定后同步 Deck 数据并持久化,避免移动过程中重复扫描瓦片。
|
||||
const handleViewChange = debounce(() => {
|
||||
if (isDisposingRef.current) return;
|
||||
const view = map.getView();
|
||||
const zoom = view.getZoom() || 0;
|
||||
setCurrentZoom(zoom);
|
||||
junctionIndexRef.current?.scanLoadedTiles();
|
||||
pipeIndexRef.current?.scanLoadedTiles();
|
||||
scheduleActiveTileSnapshot();
|
||||
try {
|
||||
const view = map.getView();
|
||||
const center = view.getCenter();
|
||||
const zoom = view.getZoom();
|
||||
if (center && typeof zoom === "number") {
|
||||
if (center) {
|
||||
localStorage.setItem(
|
||||
MAP_VIEW_STORAGE_KEY,
|
||||
JSON.stringify({ center, zoom }),
|
||||
@@ -613,21 +598,16 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
console.warn("Save map view failed", err);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
// 监听缩放变化并持久化,同时更新 currentZoom
|
||||
const handleViewChange = () => {
|
||||
addTimeout(() => {
|
||||
const zoom = map.getView().getZoom() || 0;
|
||||
setCurrentZoom(zoom);
|
||||
persistView();
|
||||
}, 250);
|
||||
};
|
||||
map.getView().on("change", handleViewChange);
|
||||
|
||||
// 初始化当前缩放级别并强制触发瓦片加载
|
||||
addTimeout(() => {
|
||||
const initializeTimer = window.setTimeout(() => {
|
||||
if (isDisposingRef.current) return;
|
||||
const initialZoom = map.getView().getZoom() || 11;
|
||||
setCurrentZoom(initialZoom);
|
||||
junctionIndexRef.current?.scanLoadedTiles();
|
||||
pipeIndexRef.current?.scanLoadedTiles();
|
||||
scheduleActiveTileSnapshot();
|
||||
// 强制触发地图渲染,让瓦片加载事件触发
|
||||
map.render();
|
||||
}, 100);
|
||||
@@ -656,9 +636,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
// 清理函数
|
||||
return () => {
|
||||
isDisposingRef.current = true;
|
||||
clearPendingTimeouts();
|
||||
debouncedUpdateDataRef.current?.cancel();
|
||||
persistView.cancel();
|
||||
window.clearTimeout(initializeTimer);
|
||||
scheduleActiveTileSnapshot.cancel();
|
||||
handleViewChange.cancel();
|
||||
junctionSource.un("tileloadend", handleJunctionTileLoadEnd);
|
||||
pipeSource.un("tileloadend", handlePipeTileLoadEnd);
|
||||
map.getView().un("change", handleViewChange);
|
||||
@@ -677,10 +657,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
// React Strict Mode re-runs effects with the same memoized layer instances.
|
||||
// Detach and clear them here, but leave final layer/source disposal to GC.
|
||||
disposeMapResources(map, { disposeLayers: false });
|
||||
activeJunctionDataIds.clear();
|
||||
activePipeDataIds.clear();
|
||||
tileJunctionDataBuffer.current = [];
|
||||
tilePipeDataBuffer.current = [];
|
||||
junctionIndexRef.current = null;
|
||||
pipeIndexRef.current = null;
|
||||
setJunctionDataState([]);
|
||||
setPipeDataState([]);
|
||||
setPipeFragments([]);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [MAP_WORKSPACE, MAP_EXTENT]);
|
||||
@@ -699,6 +680,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
mapUrl: MAP_URL,
|
||||
workspace: MAP_WORKSPACE,
|
||||
extent: MAP_EXTENT,
|
||||
sources: operationalSources,
|
||||
});
|
||||
const nextCompareMap = new OlMap({
|
||||
target: compareMapRef.current,
|
||||
@@ -706,6 +688,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
layers: compareResources.orderedLayers.slice(),
|
||||
controls: [],
|
||||
});
|
||||
const handleCompareRenderComplete = () => markCompareOpenReady();
|
||||
nextCompareMap.once("rendercomplete", handleCompareRenderComplete);
|
||||
nextCompareMap.getAllLayers().forEach((layer) => {
|
||||
const layerId = layer.get("value");
|
||||
if (!layerId) return;
|
||||
@@ -748,6 +732,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
return () => {
|
||||
isCompareDisposingRef.current = true;
|
||||
window.clearTimeout(resizeTimerId);
|
||||
nextCompareMap.un("rendercomplete", handleCompareRenderComplete);
|
||||
if (
|
||||
compareDeckLayerRef.current &&
|
||||
!compareDeckLayerRef.current.isDisposedLayer()
|
||||
@@ -762,7 +747,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
compareDeckLayerRef.current = null;
|
||||
setCompareDeckLayer(undefined);
|
||||
setCompareMap(undefined);
|
||||
disposeMapResources(nextCompareMap);
|
||||
disposeMapResources(nextCompareMap, { disposeSources: false });
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCompareMode, map]);
|
||||
@@ -1015,11 +1000,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
|
||||
// 控制流动动画开关
|
||||
useEffect(() => {
|
||||
flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0;
|
||||
const hasFlowData = pipeText === "flow" && currentPipeCalData.length > 0;
|
||||
const shouldShowWaterflow =
|
||||
isWaterflowLayerAvailable &&
|
||||
showWaterflowLayer &&
|
||||
flowAnimation.current &&
|
||||
hasFlowData &&
|
||||
currentZoom >= 12 &&
|
||||
currentZoom <= 24;
|
||||
|
||||
@@ -1027,13 +1012,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
|
||||
const syncWaterflowLayer = (
|
||||
targetDeckLayer: DeckLayer | null,
|
||||
targetPipeData: any[],
|
||||
targetPipeFragments: any[],
|
||||
disposing: boolean,
|
||||
) => {
|
||||
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldShowWaterflow || targetPipeData.length === 0) {
|
||||
if (!shouldShowWaterflow || targetPipeFragments.length === 0) {
|
||||
targetDeckLayer.removeDeckLayer("waterflowLayer");
|
||||
return;
|
||||
}
|
||||
@@ -1045,7 +1030,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
const waterflowLayer = new TripsLayer({
|
||||
id: "waterflowLayer",
|
||||
name: "水流",
|
||||
data: targetPipeData,
|
||||
data: targetPipeFragments,
|
||||
getObjectId: (d: any) => d.instanceKey,
|
||||
getPath: (d) => d.path,
|
||||
getTimestamps: (d) => d.timestamps,
|
||||
getColor: [0, 220, 255],
|
||||
@@ -1067,13 +1053,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
const animate = () => {
|
||||
syncWaterflowLayer(
|
||||
deckLayerRef.current,
|
||||
mergedPipeData,
|
||||
mergedPipeFragments,
|
||||
isDisposingRef.current,
|
||||
);
|
||||
if (isCompareMode) {
|
||||
syncWaterflowLayer(
|
||||
compareDeckLayerRef.current,
|
||||
mergedComparePipeData,
|
||||
mergedComparePipeFragments,
|
||||
isCompareDisposingRef.current,
|
||||
);
|
||||
}
|
||||
@@ -1092,8 +1078,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
}, [
|
||||
currentPipeCalData,
|
||||
currentZoom,
|
||||
mergedPipeData,
|
||||
mergedComparePipeData,
|
||||
mergedPipeFragments,
|
||||
mergedComparePipeFragments,
|
||||
isCompareMode,
|
||||
pipeText,
|
||||
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(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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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?.();
|
||||
if (Array.isArray(childLayers)) {
|
||||
[...childLayers].forEach((childLayer) => releaseLayer(childLayer, dispose));
|
||||
[...childLayers].forEach((childLayer) => releaseLayer(childLayer, options));
|
||||
layer.getLayers().clear();
|
||||
}
|
||||
|
||||
const source = layer.getSource?.();
|
||||
try {
|
||||
source?.clear?.();
|
||||
} catch {
|
||||
// Some third-party sources do not support explicit cache clearing.
|
||||
}
|
||||
if (dispose) {
|
||||
if (options.disposeSource) {
|
||||
try {
|
||||
source?.dispose?.();
|
||||
source?.clear?.();
|
||||
} catch {
|
||||
// Source may already be disposed by its owning layer.
|
||||
// Some third-party sources do not support explicit cache clearing.
|
||||
}
|
||||
if (options.disposeLayer) {
|
||||
try {
|
||||
source?.dispose?.();
|
||||
} catch {
|
||||
// Source may already be disposed by its owning layer.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.disposeLayer) {
|
||||
try {
|
||||
layer.dispose?.();
|
||||
} catch {
|
||||
@@ -59,7 +68,7 @@ export const cleanupTransientMapResources = (map: OlMap) => {
|
||||
[...map.getLayers().getArray()].forEach((layer) => {
|
||||
if (isMapResourcePersistent(layer)) return;
|
||||
map.removeLayer(layer);
|
||||
releaseLayer(layer, true);
|
||||
releaseLayer(layer, { disposeLayer: true, disposeSource: true });
|
||||
});
|
||||
|
||||
removeTransientResources(map.getInteractions().getArray(), (interaction) =>
|
||||
@@ -75,12 +84,16 @@ export const cleanupTransientMapResources = (map: OlMap) => {
|
||||
|
||||
export const disposeMapResources = (
|
||||
map: OlMap,
|
||||
options: { disposeLayers?: boolean } = {},
|
||||
options: { disposeLayers?: boolean; disposeSources?: boolean } = {},
|
||||
) => {
|
||||
const disposeLayers = options.disposeLayers ?? true;
|
||||
const disposeSources = options.disposeSources ?? true;
|
||||
[...map.getLayers().getArray()].forEach((layer) => {
|
||||
map.removeLayer(layer);
|
||||
releaseLayer(layer, disposeLayers);
|
||||
releaseLayer(layer, {
|
||||
disposeLayer: disposeLayers,
|
||||
disposeSource: disposeSources,
|
||||
});
|
||||
});
|
||||
map.getInteractions().clear();
|
||||
map.getControls().clear();
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,11 +17,25 @@ import { markMapResourcePersistent } from "./mapLifecycle";
|
||||
|
||||
type MapExtent = [number, number, number, number];
|
||||
|
||||
type CreateOperationalLayersOptions = {
|
||||
type CreateOperationalMapSourcesOptions = {
|
||||
mapUrl: string;
|
||||
workspace: string;
|
||||
};
|
||||
|
||||
type CreateOperationalLayersOptions = CreateOperationalMapSourcesOptions & {
|
||||
extent: MapExtent;
|
||||
persistent?: boolean;
|
||||
sources?: OperationalMapSources;
|
||||
};
|
||||
|
||||
export type OperationalMapSources = {
|
||||
junctions: VectorTileSource;
|
||||
pipes: VectorTileSource;
|
||||
valves: VectorTileSource;
|
||||
reservoirs: VectorSource;
|
||||
pumps: VectorSource;
|
||||
tanks: VectorSource;
|
||||
scada: VectorSource;
|
||||
};
|
||||
|
||||
const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike;
|
||||
@@ -85,18 +99,16 @@ const pipeProperties = [
|
||||
{ name: "流速", value: "velocity" },
|
||||
];
|
||||
|
||||
export const createOperationalMapResources = ({
|
||||
export const createOperationalMapSources = ({
|
||||
mapUrl,
|
||||
workspace,
|
||||
extent,
|
||||
persistent = false,
|
||||
}: CreateOperationalLayersOptions) => {
|
||||
}: CreateOperationalMapSourcesOptions): OperationalMapSources => {
|
||||
const vectorTileUrl = (name: string) =>
|
||||
`${mapUrl}/gwc/service/tms/1.0.0/${workspace}:${name}@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`;
|
||||
const vectorUrl = (name: string) =>
|
||||
`${mapUrl}/${workspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspace}:${name}&outputFormat=application/json`;
|
||||
|
||||
const sources = {
|
||||
return {
|
||||
junctions: new VectorTileSource({
|
||||
url: vectorTileUrl("geo_junctions"),
|
||||
format: new MVT(),
|
||||
@@ -129,6 +141,15 @@ export const createOperationalMapResources = ({
|
||||
format: new GeoJSON(),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const createOperationalMapResources = ({
|
||||
mapUrl,
|
||||
workspace,
|
||||
extent,
|
||||
persistent = false,
|
||||
sources = createOperationalMapSources({ mapUrl, workspace }),
|
||||
}: CreateOperationalLayersOptions) => {
|
||||
|
||||
const layers = {
|
||||
junctions: new WebGLVectorTileLayer({
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user