完成时间轴前后端数据连通

This commit is contained in:
JIANG
2025-10-10 15:12:23 +08:00
parent 5d54ad11d4
commit fa0970bd79
13 changed files with 416 additions and 285 deletions

View File

@@ -74,10 +74,11 @@ export default function Home() {
return ( return (
<div className="relative w-full h-full overflow-hidden"> <div className="relative w-full h-full overflow-hidden">
<MapComponent /> <MapComponent>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[800px] opacity-90 hover:opacity-100 transition-opacity duration-300"> <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[800px] opacity-90 hover:opacity-100 transition-opacity duration-300">
<Timeline /> <Timeline />
</div> </div>
</MapComponent>
<SCADADeviceList <SCADADeviceList
devices={devices} devices={devices}
onDeviceClick={handleDeviceClick} onDeviceClick={handleDeviceClick}

View File

@@ -300,7 +300,7 @@ const DrawPanel: React.FC = () => {
const isSaveDisabled = drawnFeatures.length === 0; const isSaveDisabled = drawnFeatures.length === 0;
return ( return (
<div className="absolute top-20 left-4 bg-white p-1 rounded-xl shadow-lg flex flex-col opacity-85 hover:opacity-100 transition-opacity"> <div className="absolute top-20 left-4 bg-white p-1 rounded-xl shadow-lg flex flex-col opacity-85 hover:opacity-100 transition-opacity z-10">
<div className="flex"> <div className="flex">
<ToolbarButton <ToolbarButton
icon={<BackHandOutlinedIcon />} icon={<BackHandOutlinedIcon />}

View File

@@ -20,7 +20,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
}) => { }) => {
if (!id) { if (!id) {
return ( return (
<div className="absolute top-0 right-0 h-auto bg-white p-6 rounded-bl-2xl shadow-lg min-w-[320px]"> <div className="absolute top-0 right-0 h-auto bg-white p-6 rounded-bl-2xl shadow-lg min-w-[320px] z-10">
<h3 className="text-lg font-semibold mb-4"></h3> <h3 className="text-lg font-semibold mb-4"></h3>
<p className="text-gray-500"></p> <p className="text-gray-500"></p>
</div> </div>
@@ -38,7 +38,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
}; };
return ( return (
<div className="absolute top-0 right-0 h-auto bg-white p-6 rounded-bl-2xl shadow-lg min-w-[320px]"> <div className="absolute top-0 right-0 h-auto bg-white p-6 rounded-bl-2xl shadow-lg min-w-[320px] z-10">
<h3 className="text-lg font-semibold mb-4"></h3> <h3 className="text-lg font-semibold mb-4"></h3>
<div className="space-y-2"> <div className="space-y-2">
<div> <div>

View File

@@ -36,9 +36,9 @@ const Scale: React.FC = () => {
return ( return (
<div className="absolute bottom-0 right-0 flex col-auto px-2 bg-white bg-opacity-70 text-black rounded-tl shadow-md text-sm"> <div className="absolute bottom-0 right-0 flex col-auto px-2 bg-white bg-opacity-70 text-black rounded-tl shadow-md text-sm">
<div className="px-1">Zoom Level: {zoomLevel.toFixed(1)}</div> <div className="px-1">: {zoomLevel.toFixed(1)}</div>
<div className="px-1"> <div className="px-1">
Coordinates: {coordinates[0]}, {coordinates[1]} : {coordinates[0]}, {coordinates[1]}
</div> </div>
</div> </div>
); );

View File

@@ -19,11 +19,14 @@ import {
// 导入OpenLayers样式相关模块 // 导入OpenLayers样式相关模块
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import { useMap } from "../MapComponent"; import { useData, useMap } from "../MapComponent";
import StyleLegend, { LegendStyleConfig } from "./StyleLegend"; import StyleLegend, { LegendStyleConfig } from "./StyleLegend";
import { FlatStyleLike } from "ol/style/flat"; import { FlatStyleLike } from "ol/style/flat";
import { calculateClassification } from "@utils/breaks_classification";
import { parseColor } from "@utils/parseColor";
interface StyleConfig { interface StyleConfig {
property: string; property: string;
classificationMethod: string; // 分类方法 classificationMethod: string; // 分类方法
@@ -96,6 +99,22 @@ const CLASSIFICATION_METHODS = [
const StyleEditorPanel: React.FC = () => { const StyleEditorPanel: React.FC = () => {
const map = useMap(); const map = useMap();
const data = useData();
if (!data) {
return <div>Loading...</div>; // 或其他占位符
}
const {
junctionData,
pipeData,
setShowJunctionText,
setShowPipeText,
setJunctionText,
setPipeText,
} = data;
const [applyJunctionStyle, setApplyJunctionStyle] = useState(false);
const [applyPipeStyle, setApplyPipeStyle] = useState(false);
const [renderLayers, setRenderLayers] = useState<WebGLVectorTileLayer[]>([]); const [renderLayers, setRenderLayers] = useState<WebGLVectorTileLayer[]>([]);
const [selectedRenderLayer, setSelectedRenderLayer] = const [selectedRenderLayer, setSelectedRenderLayer] =
useState<WebGLVectorTileLayer>(); useState<WebGLVectorTileLayer>();
@@ -134,75 +153,10 @@ const StyleEditorPanel: React.FC = () => {
breaks: [], breaks: [],
} }
); );
// 样式状态管理 - 存储多个图层的样式状态 // 样式状态管理 - 存储多个图层的样式状态
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>( const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>(
[] []
); );
// 通用颜色解析函数
const parseColor = useCallback((color: string) => {
// 解析 rgba 格式的颜色
const match = color.match(
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/
);
if (match) {
return {
r: parseInt(match[1], 10),
g: parseInt(match[2], 10),
b: parseInt(match[3], 10),
// 如果没有 alpha 值,默认为 1
a: match[4] ? parseFloat(match[4]) : 1,
};
}
// 如果还是十六进制格式,保持原来的解析方式
const hex = color.replace("#", "");
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
};
}, []);
// 获取数据分段分类结果
const fetchClassification = async (
layer_name: string,
prop: string,
n_classes: number,
algorithm: string
) => {
if (!algorithm) {
algorithm = "pretty_breaks"; // 默认算法
}
const response = await fetch("http://localhost:8000/jenks-classification", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
layer_name: layer_name, // 图层名称
prop: prop, // 属性名称
n_classes: n_classes, // 分段数
algorithm: algorithm,
// algorithm: "pretty_breaks",
// algorithm: "hybrid_jenks"
}),
});
if (!response.ok) {
console.error("API 请求失败:", response.status, response.statusText);
return false;
}
const data = await response.json();
const breaks = data.breaks; // 从响应对象中提取 breaks 数组
// console.log(breaks);
// 验证返回的数据
if (!Array.isArray(breaks) || breaks.length === 0) {
console.error("API 返回的 breaks 不是有效数组:", breaks);
return false;
}
return breaks;
};
// 颜色方案选择 // 颜色方案选择
const [singlePaletteIndex, setSinglePaletteIndex] = useState(0); const [singlePaletteIndex, setSinglePaletteIndex] = useState(0);
const [gradientPaletteIndex, setGradientPaletteIndex] = useState(0); const [gradientPaletteIndex, setGradientPaletteIndex] = useState(0);
@@ -232,21 +186,45 @@ const StyleEditorPanel: React.FC = () => {
[gradientPaletteIndex, parseColor] [gradientPaletteIndex, parseColor]
); );
// 应用分类样式 // 应用分类样式
const applyStyle = (breaks?: number[]) => { const setStyleState = (layer: any) => {
if (
layer.get("value") !== undefined &&
styleConfig.property !== undefined
) {
// 更新文字标签设置
if (layer.get("value") === "junctions") {
if (setJunctionText && setShowJunctionText) {
setJunctionText(styleConfig.property);
setShowJunctionText(styleConfig.showLabels);
setApplyJunctionStyle(true);
}
}
if (layer.get("value") === "pipes") {
if (setPipeText && setShowPipeText) {
setPipeText(styleConfig.property);
setShowPipeText(styleConfig.showLabels);
setApplyPipeStyle(true);
}
}
}
};
const applyStyle = (layerId: string, breaks?: number[]) => {
// 使用传入的 breaks 数据 // 使用传入的 breaks 数据
if (!breaks) { if (!breaks || breaks.length === 0) {
console.warn("没有有效的 breaks 数据"); console.warn("没有有效的 breaks 数据");
return; return;
} }
if (!selectedRenderLayer || !styleConfig.property) return; const styleConfig = layerStyleStates.find(
(s) => s.layerId === layerId
)?.styleConfig;
const selectedRenderLayer = renderLayers.find(
(l) => l.get("id") === layerId
);
if (!selectedRenderLayer || !styleConfig?.property) return;
const layerType: string = selectedRenderLayer?.get("type"); const layerType: string = selectedRenderLayer?.get("type");
const source = selectedRenderLayer.getSource(); const source = selectedRenderLayer.getSource();
if (!source) return; if (!source) return;
if (breaks.length === 0) {
console.log("没有有效的 breaks 数据,无法应用样式");
return;
}
const breaksLength = breaks.length; const breaksLength = breaks.length;
// 根据 breaks 计算每个分段的颜色,线条粗细 // 根据 breaks 计算每个分段的颜色,线条粗细
const colors: string[] = const colors: string[] =
@@ -385,8 +363,45 @@ const StyleEditorPanel: React.FC = () => {
setLayerStyleStates((prev) => setLayerStyleStates((prev) =>
prev.filter((state) => state.layerId !== layerId) prev.filter((state) => state.layerId !== layerId)
); );
// 重置样式应用状态
if (layerId === "junctions") {
setApplyJunctionStyle(false);
} else if (layerId === "pipes") {
setApplyPipeStyle(false);
}
} }
}, [selectedRenderLayer]); }, [selectedRenderLayer]);
useEffect(() => {
if (applyJunctionStyle && junctionData.length > 0) {
// 应用节点样式
const junctionStyleConfigState = layerStyleStates.find(
(s) => s.layerId === "junctions"
);
if (!junctionStyleConfigState) return;
const segments = junctionStyleConfigState?.styleConfig.segments;
const breaks = calculateClassification(
junctionData,
segments,
styleConfig.classificationMethod
);
applyStyle(junctionStyleConfigState.layerId, breaks);
}
if (applyPipeStyle && pipeData.length > 0) {
// 应用管道样式
const pipeStyleConfigState = layerStyleStates.find(
(s) => s.layerId === "pipes"
);
if (!pipeStyleConfigState) return;
const segments = pipeStyleConfigState?.styleConfig.segments;
const breaks = calculateClassification(
pipeData,
segments,
styleConfig.classificationMethod
);
applyStyle(pipeStyleConfigState.layerId, breaks);
}
}, [junctionData, pipeData, applyJunctionStyle, applyPipeStyle]);
// 样式状态管理功能 // 样式状态管理功能
// 保存当前图层的样式状态 // 保存当前图层的样式状态
const saveLayerStyle = useCallback( const saveLayerStyle = useCallback(
@@ -436,8 +451,9 @@ const StyleEditorPanel: React.FC = () => {
const updateVisibleLayers = () => { const updateVisibleLayers = () => {
const layers = map.getAllLayers(); const layers = map.getAllLayers();
// 筛选矢量瓦片图层 // 筛选矢量瓦片图层
const webGLVectorTileLayers = layers.filter((layer) => const webGLVectorTileLayers = layers.filter(
layer.get("value") (layer) =>
layer.get("value") === "junctions" || layer.get("value") === "pipes" // 暂时只处理这两个图层
) as WebGLVectorTileLayer[]; ) as WebGLVectorTileLayer[];
setRenderLayers(webGLVectorTileLayers); setRenderLayers(webGLVectorTileLayers);
@@ -505,6 +521,7 @@ const StyleEditorPanel: React.FC = () => {
})); }));
} }
}, [styleConfig.colorType]); }, [styleConfig.colorType]);
// 获取所有激活的图例配置 // 获取所有激活的图例配置
const getActiveLegendConfigs = useCallback(() => { const getActiveLegendConfigs = useCallback(() => {
return layerStyleStates return layerStyleStates
@@ -803,7 +820,7 @@ const StyleEditorPanel: React.FC = () => {
return ( return (
<> <>
<div className="absolute top-20 left-4 bg-white p-4 rounded-xl shadow-lg opacity-95 hover:opacity-100 transition-opacity w-80"> <div className="absolute top-20 left-4 bg-white p-4 rounded-xl shadow-lg opacity-95 hover:opacity-100 transition-opacity w-80 z-10">
{/* 图层选择 */} {/* 图层选择 */}
<FormControl variant="standard" fullWidth margin="dense"> <FormControl variant="standard" fullWidth margin="dense">
<InputLabel></InputLabel> <InputLabel></InputLabel>
@@ -948,25 +965,8 @@ const StyleEditorPanel: React.FC = () => {
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
onClick={async () => { onClick={() => {
// 获取分段数据后应用样式 setStyleState(selectedRenderLayer);
if (
selectedRenderLayer &&
selectedRenderLayer.get("value") !== undefined &&
styleConfig.property !== undefined &&
styleConfig.segments !== undefined
) {
const newBreaks = await fetchClassification(
selectedRenderLayer?.get("value"),
styleConfig.property,
styleConfig.segments,
styleConfig.classificationMethod
);
if (newBreaks) {
applyStyle(newBreaks);
// setShowLegend(true); // 应用样式后显示图例
}
}
}} }}
disabled={!selectedRenderLayer || !styleConfig.property} disabled={!selectedRenderLayer || !styleConfig.property}
startIcon={<ApplyIcon />} startIcon={<ApplyIcon />}
@@ -989,7 +989,7 @@ const StyleEditorPanel: React.FC = () => {
</div> </div>
{/* 显示多图层图例 */} {/* 显示多图层图例 */}
{getActiveLegendConfigs().length > 0 && ( {getActiveLegendConfigs().length > 0 && (
<div className="fixed bottom-35 right-4 flex flex-row items-end max-w-screen-lg overflow-x-auto z-10"> <div className=" absolute bottom-40 right-4 shadow-lg flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
<div className="flex flex-row gap-3"> <div className="flex flex-row gap-3">
{getActiveLegendConfigs().map((config, index) => ( {getActiveLegendConfigs().map((config, index) => (
<StyleLegend key={`${config.layerId}-${index}`} {...config} /> <StyleLegend key={`${config.layerId}-${index}`} {...config} />

View File

@@ -27,7 +27,7 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
return ( return (
<Box <Box
key={layerId} key={layerId}
className="bg-white p-3 rounded-xl shadow-lg max-w-xs opacity-95" className="bg-white p-3 rounded-xl shadow-lg max-w-xs opacity-95 transition-opacity duration-300 hover:opacity-100"
> >
<Typography variant="subtitle2" gutterBottom> <Typography variant="subtitle2" gutterBottom>
{layerName} - {property} {layerName} - {property}

View File

@@ -7,7 +7,6 @@ import {
Slider, Slider,
Typography, Typography,
Paper, Paper,
TextField,
MenuItem, MenuItem,
Select, Select,
FormControl, FormControl,
@@ -22,26 +21,18 @@ import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import { zhCN } from "date-fns/locale"; import { zhCN } from "date-fns/locale";
import { PlayArrow, Pause, Stop, Refresh } from "@mui/icons-material"; import { PlayArrow, Pause, Stop, Refresh } from "@mui/icons-material";
import { TbRewindBackward5, TbRewindForward5 } from "react-icons/tb"; import { TbRewindBackward5, TbRewindForward5 } from "react-icons/tb";
import { useData } from "../MapComponent";
import { config } from "@/config/config";
interface TimelineProps { const backendUrl = config.backendUrl;
onTimeChange?: (time: string) => void; const Timeline: React.FC = () => {
onDateChange?: (date: Date) => void; const data = useData();
onPlay?: () => void; if (!data) {
onPause?: () => void; return <div>Loading...</div>; // 或其他占位符
onStop?: () => void; }
onRefresh?: () => void; const { setJunctionDataState, setPipeDataState, junctionText, pipeText } =
onFetch?: () => void; data;
}
const Timeline: React.FC<TimelineProps> = ({
onTimeChange,
onDateChange,
onPlay,
onPause,
onStop,
onRefresh,
onFetch,
}) => {
const [currentTime, setCurrentTime] = useState<number>(0); // 分钟数 (0-1439) const [currentTime, setCurrentTime] = useState<number>(0); // 分钟数 (0-1439)
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [isPlaying, setIsPlaying] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false);
@@ -51,6 +42,114 @@ const Timeline: React.FC<TimelineProps> = ({
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
// 添加缓存引用
const cacheRef = useRef<
Map<string, { nodeRecords: any[]; linkRecords: any[] }>
>(new Map());
// 添加防抖引用
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const fetchFrameData = async (queryTime: Date) => {
const query_time = queryTime.toISOString();
const cacheKey = query_time;
// 检查缓存
if (cacheRef.current.has(cacheKey)) {
const { nodeRecords, linkRecords } = cacheRef.current.get(cacheKey)!;
// 使用缓存数据更新状态
updateDataStates(nodeRecords, linkRecords);
return;
}
try {
// 定义需要查询的属性
const junctionProperties = junctionText;
const pipeProperties = pipeText;
if (
!junctionProperties ||
!pipeProperties ||
junctionProperties === "" ||
pipeProperties === ""
) {
return;
}
console.log(
"Query Time:",
queryTime.toLocaleDateString() + " " + queryTime.toLocaleTimeString()
);
// 同时查询节点和管道数据
const [nodeResponse, linkResponse] = await Promise.all([
fetch(
`${backendUrl}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}`
),
fetch(
`${backendUrl}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}`
),
]);
const nodeRecords = await nodeResponse.json();
const linkRecords = await linkResponse.json();
// 缓存数据
cacheRef.current.set(cacheKey, {
nodeRecords: nodeRecords.results,
linkRecords: linkRecords.results,
});
// 更新状态
updateDataStates(nodeRecords.results, linkRecords.results);
} catch (error) {
console.error("Error fetching data:", error);
}
};
// 提取更新状态的逻辑
const updateDataStates = (nodeResults: any[], linkResults: any[]) => {
const junctionProperties = junctionText;
const pipeProperties = pipeText;
// 将 nodeRecords 转换为 Map 以提高查找效率
const nodeMap: Map<string, any> = new Map(
nodeResults.map((r: any) => [r.ID, r])
);
// 将 linkRecords 转换为 Map 以提高查找效率
const linkMap: Map<string, any> = new Map(
linkResults.map((r: any) => [r.ID, r])
);
// 更新junctionData
setJunctionDataState((prev: any[]) =>
prev.map((j) => {
const record = nodeMap.get(j.id);
if (record) {
return {
...j,
[junctionProperties]: record.value,
};
}
return j;
})
);
// 更新pipeData
setPipeDataState((prev: any[]) =>
prev.map((p) => {
const record = linkMap.get(p.id);
if (record) {
return {
...p,
flowFlag: pipeProperties === "flow" && record.value < 0 ? -1 : 1,
path:
pipeProperties === "flow" && record.value < 0 && p.flowFlag > 0
? [...p.path].reverse()
: p.path,
[pipeProperties]: record.value,
};
}
return p;
})
);
};
// 时间刻度数组 (每5分钟一个刻度) // 时间刻度数组 (每5分钟一个刻度)
const timeMarks = Array.from({ length: 288 }, (_, i) => ({ const timeMarks = Array.from({ length: 288 }, (_, i) => ({
@@ -67,14 +166,22 @@ const Timeline: React.FC<TimelineProps> = ({
.padStart(2, "0")}`; .padStart(2, "0")}`;
} }
function currentTimeToDate(selectedDate: Date, minutes: number): Date {
const date = new Date(selectedDate);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
date.setHours(hours, mins, 0, 0);
return date;
}
// 播放时间间隔选项 // 播放时间间隔选项
const intervalOptions = [ const intervalOptions = [
{ value: 1000, label: "1秒" }, // { value: 1000, label: "1秒" },
{ value: 2000, label: "2秒" }, { value: 2000, label: "2秒" },
{ value: 5000, label: "5秒" }, { value: 5000, label: "5秒" },
{ value: 10000, label: "10秒" }, { value: 10000, label: "10秒" },
]; ];
// 播放时间间隔选项 // 强制计算时间段选项
const calculatedIntervalOptions = [ const calculatedIntervalOptions = [
{ value: 1440, label: "1 天" }, { value: 1440, label: "1 天" },
{ value: 60, label: "1 小时" }, { value: 60, label: "1 小时" },
@@ -88,79 +195,73 @@ const Timeline: React.FC<TimelineProps> = ({
(event: Event, newValue: number | number[]) => { (event: Event, newValue: number | number[]) => {
const value = Array.isArray(newValue) ? newValue[0] : newValue; const value = Array.isArray(newValue) ? newValue[0] : newValue;
setSliderValue(value); setSliderValue(value);
setCurrentTime(value); // 防抖设置currentTime,避免频繁触发数据获取
onTimeChange?.(formatTime(value)); if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setCurrentTime(value);
}, 300); // 300ms 防抖延迟
}, },
[onTimeChange] []
); );
// 播放控制 // 播放控制
const handlePlay = useCallback(() => { const handlePlay = useCallback(() => {
if (!isPlaying) { if (!isPlaying) {
setIsPlaying(true); setIsPlaying(true);
onPlay?.();
intervalRef.current = setInterval(() => { intervalRef.current = setInterval(() => {
setCurrentTime((prev) => { setCurrentTime((prev) => {
const next = prev >= 1435 ? 0 : prev + 5; // 到达23:55后回到00:00 const next = prev >= 1435 ? 0 : prev + 5; // 到达23:55后回到00:00
setSliderValue(next); setSliderValue(next);
onTimeChange?.(formatTime(next));
return next; return next;
}); });
}, playInterval); }, playInterval);
} }
}, [isPlaying, playInterval, onPlay, onTimeChange]); }, [isPlaying, playInterval]);
const handlePause = useCallback(() => { const handlePause = useCallback(() => {
setIsPlaying(false); setIsPlaying(false);
onPause?.();
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
intervalRef.current = null; intervalRef.current = null;
} }
}, [onPause]); }, []);
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
setIsPlaying(false); setIsPlaying(false);
setCurrentTime(0); setCurrentTime(0);
setSliderValue(0); setSliderValue(0);
onStop?.();
onTimeChange?.(formatTime(0));
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
intervalRef.current = null; intervalRef.current = null;
} }
}, [onStop, onTimeChange]); }, []);
// 步进控制 // 步进控制
const handleStepBackward = useCallback(() => { const handleStepBackward = useCallback(() => {
setCurrentTime((prev) => { setCurrentTime((prev) => {
const next = prev <= 0 ? 1435 : prev - 5; const next = prev <= 0 ? 1435 : prev - 5;
setSliderValue(next); setSliderValue(next);
onTimeChange?.(formatTime(next));
return next; return next;
}); });
}, [onTimeChange]); }, []);
const handleStepForward = useCallback(() => { const handleStepForward = useCallback(() => {
setCurrentTime((prev) => { setCurrentTime((prev) => {
const next = prev >= 1435 ? 0 : prev + 5; const next = prev >= 1435 ? 0 : prev + 5;
setSliderValue(next); setSliderValue(next);
onTimeChange?.(formatTime(next));
return next; return next;
}); });
}, [onTimeChange]); }, []);
// 日期选择处理 // 日期选择处理
const handleDateChange = useCallback( const handleDateChange = useCallback((newDate: Date | null) => {
(newDate: Date | null) => { if (newDate) {
if (newDate) { setSelectedDate(newDate);
setSelectedDate(newDate); }
onDateChange?.(newDate); }, []);
}
},
[onDateChange]
);
// 播放间隔改变处理 // 播放间隔改变处理
const handleIntervalChange = useCallback( const handleIntervalChange = useCallback(
@@ -175,25 +276,33 @@ const Timeline: React.FC<TimelineProps> = ({
setCurrentTime((prev) => { setCurrentTime((prev) => {
const next = prev >= 1435 ? 0 : prev + 5; const next = prev >= 1435 ? 0 : prev + 5;
setSliderValue(next); setSliderValue(next);
onTimeChange?.(formatTime(next));
return next; return next;
}); });
}, newInterval); }, newInterval);
} }
}, },
[isPlaying, onTimeChange] [isPlaying]
); );
// 计算时间段改变处理 // 计算时间段改变处理
const handleCalculatedIntervalChange = useCallback((event: any) => { const handleCalculatedIntervalChange = useCallback((event: any) => {
const newInterval = event.target.value; const newInterval = event.target.value;
setCalculatedInterval(newInterval); setCalculatedInterval(newInterval);
}, []); }, []);
// 组件卸载时清理定时器
// 添加 useEffect 来监听 currentTime 和 selectedDate 的变化,并获取数据
useEffect(() => {
fetchFrameData(currentTimeToDate(selectedDate, currentTime));
}, [currentTime, selectedDate]);
// 组件卸载时清理定时器和防抖
useEffect(() => { useEffect(() => {
return () => { return () => {
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
} }
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
}; };
}, []); }, []);
@@ -224,7 +333,13 @@ const Timeline: React.FC<TimelineProps> = ({
<DatePicker <DatePicker
label="模拟数据日期选择" label="模拟数据日期选择"
value={selectedDate} value={selectedDate}
onChange={(newValue) => handleDateChange(newValue)} onChange={(newValue) =>
handleDateChange(
newValue && "toDate" in newValue
? newValue.toDate()
: (newValue as Date | null)
)
}
enableAccessibleFieldDOMStructure={false} enableAccessibleFieldDOMStructure={false}
format="yyyy-MM-dd" format="yyyy-MM-dd"
sx={{ width: 180, "& .MuiInputBase-root": { height: 40 } }} sx={{ width: 180, "& .MuiInputBase-root": { height: 40 } }}
@@ -308,7 +423,7 @@ const Timeline: React.FC<TimelineProps> = ({
variant="outlined" variant="outlined"
size="small" size="small"
startIcon={<Refresh />} startIcon={<Refresh />}
onClick={onRefresh} // onClick={onRefresh}
> >
</Button> </Button>

View File

@@ -24,7 +24,24 @@ import { bearing } from "@turf/turf";
import { Deck } from "@deck.gl/core"; import { Deck } from "@deck.gl/core";
import { TextLayer } from "@deck.gl/layers"; import { TextLayer } from "@deck.gl/layers";
import { TripsLayer } from "@deck.gl/geo-layers"; import { TripsLayer } from "@deck.gl/geo-layers";
import { tr } from "date-fns/locale";
interface MapComponentProps {
children?: React.ReactNode;
}
interface DataContextType {
junctionData: any[];
pipeData: any[];
setJunctionDataState: React.Dispatch<React.SetStateAction<any[]>>;
setPipeDataState: React.Dispatch<React.SetStateAction<any[]>>;
showJunctionText?: boolean; // 是否显示节点文本
showPipeText?: boolean; // 是否显示管道文本
setShowJunctionText?: React.Dispatch<React.SetStateAction<boolean>>;
setShowPipeText?: React.Dispatch<React.SetStateAction<boolean>>;
junctionText: string;
pipeText: string;
setJunctionText?: React.Dispatch<React.SetStateAction<string>>;
setPipeText?: React.Dispatch<React.SetStateAction<string>>;
}
// 创建自定义Layer类来包装deck.gl // 创建自定义Layer类来包装deck.gl
class DeckLayer extends Layer { class DeckLayer extends Layer {
@@ -50,8 +67,9 @@ class DeckLayer extends Layer {
} }
// 跨组件传递 // 跨组件传递
const MapContext = createContext<OlMap | undefined>(undefined); const MapContext = createContext<OlMap | undefined>(undefined);
const DataContext = createContext<DataContextType | undefined>(undefined);
const extent = config.mapExtent; const extent = config.mapExtent;
const backendUrl = config.backendUrl;
const mapUrl = config.mapUrl; const mapUrl = config.mapUrl;
// 添加防抖函数 // 添加防抖函数
@@ -69,16 +87,15 @@ function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
export const useMap = () => { export const useMap = () => {
return useContext(MapContext); return useContext(MapContext);
}; };
export const useData = () => {
return useContext(DataContext);
};
const MapComponent: React.FC = () => { const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const mapRef = useRef<HTMLDivElement | null>(null); const mapRef = useRef<HTMLDivElement | null>(null);
const deckRef = useRef<Deck | null>(null); const deckRef = useRef<Deck | null>(null);
const [map, setMap] = useState<OlMap>(); const [map, setMap] = useState<OlMap>();
const [currentTime, setCurrentTime] = useState(
new Date("2025-09-17T00:30:00+08:00")
);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const [junctionData, setJunctionDataState] = useState<any[]>([]); const [junctionData, setJunctionDataState] = useState<any[]>([]);
const [pipeData, setPipeDataState] = useState<any[]>([]); const [pipeData, setPipeDataState] = useState<any[]>([]);
const junctionDataIds = useRef(new Set<string>()); const junctionDataIds = useRef(new Set<string>());
@@ -86,12 +103,11 @@ const MapComponent: React.FC = () => {
const tileJunctionDataBuffer = useRef<any[]>([]); const tileJunctionDataBuffer = useRef<any[]>([]);
const tilePipeDataBuffer = useRef<any[]>([]); const tilePipeDataBuffer = useRef<any[]>([]);
let showJunctionText = true; // 控制节点文本显示 const [showJunctionText, setShowJunctionText] = useState(false); // 控制节点文本显示
let showPipeText = true; // 控制管道文本显示 const [showPipeText, setShowPipeText] = useState(false); // 控制管道文本显示
let junctionText = "pressure"; const [junctionText, setJunctionText] = useState("");
let pipeText = "flow"; const [pipeText, setPipeText] = useState("");
let animate = false; // 控制是否动画 const flowAnimation = useRef(true); // 添加动画控制标志
const isAnimating = useRef(false); // 添加动画控制标志
const [currentZoom, setCurrentZoom] = useState(12); // 当前缩放级别 const [currentZoom, setCurrentZoom] = useState(12); // 当前缩放级别
// 防抖更新函数 // 防抖更新函数
const debouncedUpdateData = useRef( const debouncedUpdateData = useRef(
@@ -134,80 +150,6 @@ const MapComponent: React.FC = () => {
} }
}; };
const setFrameData = async (queryTime: Date) => {
const query_time = queryTime.toISOString();
console.log("Query Time:", query_time);
try {
// 定义需要查询的属性
const junctionProperties = junctionText;
const pipeProperties = pipeText;
// 同时查询节点和管道数据
const starttime = Date.now();
const [nodeResponse, linkResponse] = await Promise.all([
fetch(
`${backendUrl}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}`
),
fetch(
`${backendUrl}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}`
),
]);
const nodeRecords = await nodeResponse.json();
const linkRecords = await linkResponse.json();
// 将 nodeRecords 转换为 Map 以提高查找效率
const nodeMap: Map<string, any> = new Map(
nodeRecords.results.map((r: any) => [r.ID, r])
);
// 将 linkRecords 转换为 Map 以提高查找效率
const linkMap: Map<string, any> = new Map(
linkRecords.results.map((r: any) => [r.ID, r])
);
// 更新junctionData
setJunctionDataState((prev) =>
prev.map((j) => {
const record = nodeMap.get(j.id);
if (record) {
return {
...j,
[junctionProperties]: record.value,
};
}
return j;
})
);
// 更新pipeData
setPipeDataState((prev) =>
prev.map((p) => {
const record = linkMap.get(p.id);
if (record) {
return {
...p,
flowFlag: pipeProperties === "flow" && record.value < 0 ? -1 : 1,
path:
pipeProperties === "flow" && record.value < 0 && p.flowFlag > 0
? [...p.path].reverse()
: p.path,
[pipeProperties]: record.value,
};
}
return p;
})
);
// 属性为 flow 时启动动画
if (pipeProperties === "flow" && animate) {
isAnimating.current = true;
} else {
isAnimating.current = false;
}
const endtime = Date.now();
console.log("Data fetch and update time:", endtime - starttime, "ms");
} catch (error) {
console.error("Error fetching data:", error);
}
};
useEffect(() => { useEffect(() => {
if (!mapRef.current) return; if (!mapRef.current) return;
// 添加 MVT 瓦片加载逻辑 // 添加 MVT 瓦片加载逻辑
@@ -361,8 +303,12 @@ const MapComponent: React.FC = () => {
value: "junctions", value: "junctions",
type: "point", type: "point",
properties: [ properties: [
{ name: "需求量", value: "demand" }, // { name: "需求量", value: "demand" },
{ name: "海拔高度", value: "elevation" }, // { name: "海拔高度", value: "elevation" },
{ name: "实际需求量", value: "actualdemand" },
{ name: "水头", value: "head" },
{ name: "压力", value: "pressure" },
{ name: "水质", value: "quality" },
], ],
}, },
}); });
@@ -378,9 +324,17 @@ const MapComponent: React.FC = () => {
value: "pipes", value: "pipes",
type: "linestring", type: "linestring",
properties: [ properties: [
{ name: "直径", value: "diameter" }, // { name: "直径", value: "diameter" },
{ name: "粗糙度", value: "roughness" }, // { name: "粗糙度", value: "roughness" },
{ name: "局部损失", value: "minor_loss" }, // { name: "局部损失", value: "minor_loss" },
{ name: "流量", value: "flow" },
{ name: "摩阻系数", value: "friction" },
{ name: "水头损失", value: "headloss" },
{ name: "水质", value: "quality" },
{ name: "反应速率", value: "reaction" },
{ name: "设置值", value: "setting" },
{ name: "状态", value: "status" },
{ name: "流速", value: "velocity" },
], ],
}, },
}); });
@@ -436,7 +390,7 @@ const MapComponent: React.FC = () => {
const newLayers = [ const newLayers = [
new TextLayer({ new TextLayer({
id: "junctionTextLayer", id: "junctionTextLayer",
zIndex: 1000, zIndex: 10,
data: showJunctionText ? junctionData : [], data: showJunctionText ? junctionData : [],
getPosition: (d: any) => d.position, getPosition: (d: any) => d.position,
fontFamily: "Monaco, monospace", fontFamily: "Monaco, monospace",
@@ -456,7 +410,7 @@ const MapComponent: React.FC = () => {
}), }),
new TextLayer({ new TextLayer({
id: "pipeTextLayer", id: "pipeTextLayer",
zIndex: 1000, zIndex: 10,
data: showPipeText ? pipeData : [], data: showPipeText ? pipeData : [],
getPosition: (d: any) => d.position, getPosition: (d: any) => d.position,
fontFamily: "Monaco, monospace", fontFamily: "Monaco, monospace",
@@ -479,7 +433,7 @@ const MapComponent: React.FC = () => {
// 动画循环 // 动画循环
const animate = () => { const animate = () => {
if (!deck || !isAnimating.current) return; // 添加检查,防止空数据或停止旧循环 if (!deck || !flowAnimation.current) return; // 添加检查,防止空数据或停止旧循环
// 动画总时长(秒) // 动画总时长(秒)
if (pipeData.length === 0) { if (pipeData.length === 0) {
requestAnimationFrame(animate); requestAnimationFrame(animate);
@@ -496,7 +450,7 @@ const MapComponent: React.FC = () => {
const waterflowLayer = new TripsLayer({ const waterflowLayer = new TripsLayer({
id: "waterflowLayer", id: "waterflowLayer",
data: pipeData, data: pipeData,
getPath: (d) => (isAnimating.current ? d.path : []), getPath: (d) => (flowAnimation.current ? d.path : []),
getTimestamps: (d) => { getTimestamps: (d) => {
return d.timestamps; // 这些应该是与 currentTime 匹配的数值 return d.timestamps; // 这些应该是与 currentTime 匹配的数值
}, },
@@ -522,35 +476,31 @@ const MapComponent: React.FC = () => {
requestAnimationFrame(animate); requestAnimationFrame(animate);
}; };
animate(); animate();
}, [isAnimating, junctionData, pipeData]); }, [flowAnimation, junctionData, pipeData]);
// 启动时间更新interval
useEffect(() => {
intervalRef.current = setInterval(() => {
setCurrentTime((prev) => new Date(prev.getTime() + 1800 * 1000));
}, 10 * 1000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
// 当currentTime改变时获取数据
useEffect(() => {
const fetchData = async () => {
await setFrameData(currentTime);
};
fetchData();
}, [currentTime]);
return ( return (
<> <>
<MapContext.Provider value={map}> <DataContext.Provider
<div className="relative w-full h-full"> value={{
<div ref={mapRef} className="w-full h-full"></div> junctionData,
<MapTools /> pipeData,
</div> setJunctionDataState,
<canvas id="deck-canvas" /> setPipeDataState,
</MapContext.Provider> showJunctionText,
showPipeText,
junctionText,
pipeText,
}}
>
<MapContext.Provider value={map}>
<div className="relative w-full h-full">
<div ref={mapRef} className="w-full h-full"></div>
<MapTools />
{children}
</div>
<canvas id="deck-canvas" />
</MapContext.Provider>
</DataContext.Provider>
</> </>
); );
}; };

View File

@@ -4,8 +4,9 @@ import BaseLayers from "./Controls/BaseLayers";
import MapToolbar from "./Controls/Toolbar"; import MapToolbar from "./Controls/Toolbar";
import ScaleLine from "./Controls/ScaleLine"; import ScaleLine from "./Controls/ScaleLine";
import LayerControl from "./Controls/LayerControl"; import LayerControl from "./Controls/LayerControl";
interface MapToolsProps {}
const MapTools = () => { const MapTools: React.FC<MapToolsProps> = () => {
return ( return (
<> <>
<Zoom /> <Zoom />

View File

@@ -294,6 +294,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
justifyContent: "center", justifyContent: "center",
py: 6, py: 6,
color: "text.secondary", color: "text.secondary",
height: 376,
}} }}
> >
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
@@ -307,7 +308,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const chartSection = hasData ? ( const chartSection = hasData ? (
<LineChart <LineChart
dataset={dataset} dataset={dataset}
height={360} height={376}
margin={{ left: 50, right: 50, top: 20, bottom: 80 }} margin={{ left: 50, right: 50, top: 20, bottom: 80 }}
xAxis={[ xAxis={[
{ {
@@ -354,8 +355,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
return ( return (
<Paper <Paper
className={clsx( className={clsx(
"absolute right-4 top-20 w-4xl h-2xl bg-white/95 backdrop-blur-[10px] rounded-xl shadow-lg overflow-hidden flex flex-col transition-opacity duration-300", "absolute right-4 top-20 w-4xl h-2xl bg-white rounded-xl shadow-lg overflow-hidden flex flex-col transition-opacity duration-300",
visible ? "opacity-95" : "opacity-0" visible ? "opacity-95 hover:opacity-100" : "opacity-0 -z-10"
)} )}
> >
{/* Header */} {/* Header */}

View File

@@ -178,7 +178,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
}; };
return ( return (
<Paper className="absolute left-4 top-20 w-90 max-h-[calc(100vh-100px)] bg-white/95 backdrop-blur-[10px] rounded-xl shadow-lg overflow-hidden flex flex-col opacity-95 transition-opacity duration-200 ease-in-out hover:opacity-100"> <Paper className="absolute left-4 top-20 w-90 max-h-[calc(100vh-100px)] bg-white rounded-xl shadow-lg overflow-hidden flex flex-col opacity-95 transition-opacity duration-200 ease-in-out hover:opacity-100">
{/* 头部控制栏 */} {/* 头部控制栏 */}
<Box <Box
sx={{ sx={{
@@ -228,7 +228,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
<Box sx={{ p: 2, backgroundColor: "grey.50" }}> <Box sx={{ p: 2, backgroundColor: "grey.50" }}>
<Stack spacing={2}> <Stack spacing={2}>
{/* 搜索框 */} {/* 搜索框 */}
<Box className="h-10 flex items-center border border-gray-300 rounded-lg p-0.5"> <Box className="h-10 flex items-center border border-gray-300 rounded-md p-0.5">
<InputBase <InputBase
sx={{ ml: 1, flex: 1 }} sx={{ ml: 1, flex: 1 }}
placeholder="搜索设备名称、ID 或类型..." placeholder="搜索设备名称、ID 或类型..."
@@ -395,6 +395,11 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
</Typography> </Typography>
</Stack> </Stack>
} }
slotProps={{
secondary: {
component: "div", // 使其支持多行
},
}}
/> />
<Tooltip title="缩放到设备位置"> <Tooltip title="缩放到设备位置">

View File

@@ -151,4 +151,27 @@ function jenks_with_stratified_sampling(data, n_classes, sample_size = 10000) {
return jenks_breaks_jenkspy(sampled_data, n_classes); return jenks_breaks_jenkspy(sampled_data, n_classes);
} }
module.exports = { prettyBreaksClassification, jenks_breaks_jenkspy, jenks_with_stratified_sampling }; /**
* 根据指定的方法计算数据的分类断点。
* @param {Array<number>} data - 要分类的数值数据数组。
* @param {number} segments - 要创建的段数或类别数。
* @param {string} classificationMethod - 要使用的分类方法。支持的值:"pretty_breaks" 或 "jenks_optimized"。
* @returns {Array<number>} 分类的断点数组。如果数据为空或无效,则返回空数组。
*/
function calculateClassification(
data,
segments,
classificationMethod
) {
if (!data || data.length === 0) {
return [];
}
if (classificationMethod === "pretty_breaks") {
return prettyBreaksClassification(data, segments);
}
if (classificationMethod === "jenks_optimized") {
return jenks_with_stratified_sampling(data, segments);
}
}
module.exports = { prettyBreaksClassification, jenks_breaks_jenkspy, jenks_with_stratified_sampling, calculateClassification };

35
src/utils/parseColor.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* 将颜色字符串解析为包含红色、绿色、蓝色和 alpha 分量的对象。
* 支持 rgba、rgb 和十六进制颜色格式。对于 rgba 和 rgb提取 r、g、b 和 a如果未提供则默认为 1
* 对于十六进制(例如 #RRGGBB提取 r、g、b。(e.g., "rgba(255, 0, 0, 0.5)", "rgb(255, 0, 0)", or "#FF0000").
* @param {string} color - 要解析的颜色字符串(例如 "rgba(255, 0, 0, 0.5)"、"rgb(255, 0, 0)" 或 "#FF0000")。
* @returns {{r: number, g: number, b: number, a?: number}} 包含颜色分量的对象:
* - r: 红色分量 (0-255)
* - g: 绿色分量 (0-255)
* - b: 蓝色分量 (0-255)
* - a: Alpha 分量 (0-1),如果未指定则默认为 1
**/
function parseColor(color) {
// 解析 rgba 格式的颜色
const match = color.match(
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/
);
if (match) {
return {
r: parseInt(match[1], 10),
g: parseInt(match[2], 10),
b: parseInt(match[3], 10),
// 如果没有 alpha 值,默认为 1
a: match[4] ? parseFloat(match[4]) : 1,
};
}
// 如果还是十六进制格式,保持原来的解析方式
const hex = color.replace("#", "");
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
};
}
module.exports = { parseColor };