fix(map): stabilize tiled style rendering
This commit is contained in:
@@ -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
Reference in New Issue
Block a user