feat(3d): integrate ZJB scene with project context
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FiAlertCircle,
|
||||
FiAlertTriangle,
|
||||
FiBox,
|
||||
FiCheckCircle,
|
||||
FiRefreshCw,
|
||||
} from "react-icons/fi";
|
||||
|
||||
import { useProject } from "@/contexts/ProjectContext";
|
||||
import { getRoundedCurrentTimelineMinutes } from "@components/olmap/core/Controls/timelineTime";
|
||||
import { useTimelineTimeConfig } from "@components/olmap/core/Controls/useTimelineTimeConfig";
|
||||
import {
|
||||
ThreeDimensionalControls,
|
||||
type ControlTab,
|
||||
} from "./ThreeDimensionalControls";
|
||||
import { ThreeDimensionalTimeline } from "./ThreeDimensionalTimeline";
|
||||
import {
|
||||
fetchPressureDevices,
|
||||
fetchSceneFrame,
|
||||
supportsThreeDimensionalScene,
|
||||
type PressureDevice,
|
||||
type SceneFrame,
|
||||
type SceneModelIndex,
|
||||
ZJB_PROJECT_CODE,
|
||||
ZJB_SCENE_MODEL_ID,
|
||||
} from "./sceneData";
|
||||
import {
|
||||
isSceneRuntimeMessage,
|
||||
SCENE_CHANNEL,
|
||||
SCENE_PROTOCOL_VERSION,
|
||||
type SceneAssetSelection,
|
||||
type SceneCommand,
|
||||
type SceneHostMessage,
|
||||
type SceneRuntimeState,
|
||||
} from "./sceneProtocol";
|
||||
|
||||
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2";
|
||||
const SCENE_LOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
const initialRuntimeState: SceneRuntimeState = {
|
||||
mode: "network",
|
||||
status: "正在载入三维模型",
|
||||
contextVisible: true,
|
||||
roofVisible: true,
|
||||
displayMode: "global",
|
||||
style: {
|
||||
scale: 8,
|
||||
mode: "uniform",
|
||||
color: "#098ed0",
|
||||
missingColor: "#89949d",
|
||||
lowColor: "#2b83ba",
|
||||
highColor: "#e66c37",
|
||||
opacity: 1,
|
||||
roughness: 0.3,
|
||||
metalness: 0.22,
|
||||
nodes: true,
|
||||
direction: "none",
|
||||
arrowColor: "#f2b447",
|
||||
autoRange: true,
|
||||
min: 0,
|
||||
max: 3,
|
||||
},
|
||||
styleSummary: {},
|
||||
appearance: {
|
||||
preset: "day",
|
||||
exposure: 0.95,
|
||||
shadows: true,
|
||||
effects: true,
|
||||
quality: "standard",
|
||||
},
|
||||
camera: { active: null, note: "正在准备观察位置", views: [] },
|
||||
};
|
||||
|
||||
const emptyFrame: SceneFrame = {
|
||||
selectedTime: "",
|
||||
resultTime: null,
|
||||
payload: null,
|
||||
stats: {
|
||||
simulationNodes: 0,
|
||||
simulationLinks: 0,
|
||||
scadaOverrides: 0,
|
||||
missingNodes: 0,
|
||||
missingLinks: 0,
|
||||
ignoredElements: 0,
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
type SceneHostPayload =
|
||||
| Pick<Extract<SceneHostMessage, { type: "results" }>, "type" | "payload">
|
||||
| Pick<Extract<SceneHostMessage, { type: "clear-results" }>, "type">
|
||||
| Pick<Extract<SceneHostMessage, { type: "command" }>, "type" | "command">;
|
||||
|
||||
const formatDateTime = (value: Date | string) =>
|
||||
new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).format(typeof value === "string" ? new Date(value) : value);
|
||||
|
||||
const toQueryTime = (selectedDate: Date, currentTime: number) => {
|
||||
const queryTime = new Date(selectedDate);
|
||||
queryTime.setHours(Math.floor(currentTime / 60), currentTime % 60, 0, 0);
|
||||
return queryTime;
|
||||
};
|
||||
|
||||
export default function ThreeDimensionalScene() {
|
||||
const project = useProject();
|
||||
const projectCode = project?.networkName?.trim().toLowerCase() ?? "";
|
||||
const isZjbProject = supportsThreeDimensionalScene(projectCode);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const frameRevisionRef = useRef(0);
|
||||
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
||||
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
||||
const [currentTime, setCurrentTime] = useState(() =>
|
||||
getRoundedCurrentTimelineMinutes(),
|
||||
);
|
||||
const [model, setModel] = useState<SceneModelIndex | null>(null);
|
||||
const [runtimeState, setRuntimeState] =
|
||||
useState<SceneRuntimeState>(initialRuntimeState);
|
||||
const [selection, setSelection] = useState<SceneAssetSelection | null>(null);
|
||||
const [controlsOpen, setControlsOpen] = useState(false);
|
||||
const [controlTab, setControlTab] = useState<ControlTab>("scene");
|
||||
const [timelineOpen, setTimelineOpen] = useState(true);
|
||||
const [pressureDevices, setPressureDevices] = useState<PressureDevice[]>([]);
|
||||
const [pressureMappingWarning, setPressureMappingWarning] = useState<string | null>(null);
|
||||
const [frame, setFrame] = useState<SceneFrame>(emptyFrame);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [sceneError, setSceneError] = useState<string | null>(null);
|
||||
const [refreshVersion, setRefreshVersion] = useState(0);
|
||||
const [sceneKey, setSceneKey] = useState(0);
|
||||
|
||||
const resolvedCurrentTime = useMemo(() => {
|
||||
const bounded = Math.min(durationMinutes, Math.max(0, currentTime));
|
||||
return Math.floor(bounded / stepMinutes) * stepMinutes;
|
||||
}, [currentTime, durationMinutes, stepMinutes]);
|
||||
|
||||
const postToScene = useCallback((message: SceneHostPayload) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
channel: SCENE_CHANNEL,
|
||||
version: SCENE_PROTOCOL_VERSION,
|
||||
projectCode: ZJB_PROJECT_CODE,
|
||||
modelId: ZJB_SCENE_MODEL_ID,
|
||||
...message,
|
||||
} satisfies SceneHostMessage,
|
||||
window.location.origin,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const sendCommand = useCallback(
|
||||
(command: SceneCommand) => postToScene({ type: "command", command }),
|
||||
[postToScene],
|
||||
);
|
||||
|
||||
const retryScene = useCallback(() => {
|
||||
setModel(null);
|
||||
setSelection(null);
|
||||
setRuntimeState(initialRuntimeState);
|
||||
setSceneError(null);
|
||||
setSceneKey((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isZjbProject) return;
|
||||
const onMessage = (event: MessageEvent<unknown>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
event.source !== iframeRef.current?.contentWindow ||
|
||||
!isSceneRuntimeMessage(event.data) ||
|
||||
event.data.projectCode !== ZJB_PROJECT_CODE ||
|
||||
event.data.modelId !== ZJB_SCENE_MODEL_ID
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = event.data;
|
||||
if (message.type === "ready") {
|
||||
if (!Array.isArray(message.nodeIds) || !Array.isArray(message.linkIds) || !message.state) {
|
||||
setSceneError("三维场景返回了无效的模型索引。");
|
||||
return;
|
||||
}
|
||||
setModel({
|
||||
modelId: message.modelId,
|
||||
nodeIds: new Set(message.nodeIds.map(String)),
|
||||
linkIds: new Set(message.linkIds.map(String)),
|
||||
});
|
||||
setRuntimeState(message.state);
|
||||
setSceneError(null);
|
||||
return;
|
||||
}
|
||||
if (message.type === "scene-state") {
|
||||
setRuntimeState(message.state);
|
||||
setSceneError(null);
|
||||
} else if (message.type === "selection-changed") {
|
||||
setSelection(message.selection);
|
||||
if (message.selection) {
|
||||
setControlsOpen(true);
|
||||
setControlTab("properties");
|
||||
}
|
||||
} else if (message.type === "error") {
|
||||
setSceneError(message.message || "三维场景执行命令失败。");
|
||||
} else if (message.type === "results-applied") {
|
||||
setSceneError(null);
|
||||
} else if (message.type === "results-cleared") {
|
||||
setSceneError(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [isZjbProject, sceneKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isZjbProject || model) return;
|
||||
const timerId = window.setTimeout(() => {
|
||||
setSceneError("三维模型载入超时,请检查静态资源后重试。");
|
||||
}, SCENE_LOAD_TIMEOUT_MS);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, [isZjbProject, model, sceneKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isZjbProject) return;
|
||||
const controller = new AbortController();
|
||||
fetchPressureDevices(controller.signal)
|
||||
.then((devices) => {
|
||||
setPressureDevices(devices);
|
||||
setPressureMappingWarning(null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setPressureDevices([]);
|
||||
setPressureMappingWarning(
|
||||
error instanceof Error
|
||||
? `压力测点映射不可用:${error.message}`
|
||||
: "压力测点映射不可用。",
|
||||
);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [isZjbProject]);
|
||||
|
||||
const queryTime = useMemo(
|
||||
() => toQueryTime(selectedDate, resolvedCurrentTime),
|
||||
[resolvedCurrentTime, selectedDate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isZjbProject || !model) return;
|
||||
const revision = frameRevisionRef.current + 1;
|
||||
frameRevisionRef.current = revision;
|
||||
const controller = new AbortController();
|
||||
const timerId = window.setTimeout(() => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
fetchSceneFrame({ queryTime, model, pressureDevices, signal: controller.signal })
|
||||
.then((nextFrame) => {
|
||||
if (controller.signal.aborted || revision !== frameRevisionRef.current) return;
|
||||
setFrame(nextFrame);
|
||||
postToScene(
|
||||
nextFrame.payload
|
||||
? { type: "results", payload: nextFrame.payload }
|
||||
: { type: "clear-results" },
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted || revision !== frameRevisionRef.current) return;
|
||||
setFrame({ ...emptyFrame, selectedTime: queryTime.toISOString() });
|
||||
postToScene({ type: "clear-results" });
|
||||
setLoadError(error instanceof Error ? error.message : "当前时间帧加载失败。");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted && revision === frameRevisionRef.current) setLoading(false);
|
||||
});
|
||||
}, 180);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timerId);
|
||||
controller.abort();
|
||||
};
|
||||
}, [isZjbProject, model, postToScene, pressureDevices, queryTime, refreshVersion]);
|
||||
|
||||
if (!isZjbProject) {
|
||||
return (
|
||||
<main className="h-full bg-slate-100 p-4 md:p-8">
|
||||
<section className="flex max-w-2xl gap-3 rounded-2xl border border-blue-200/70 bg-white/70 p-4 text-slate-700 shadow-lg shadow-slate-900/5 backdrop-blur-xl">
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-blue-600 text-xl text-white">
|
||||
<FiBox aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="font-semibold text-slate-900">当前项目未配置三维场景</h1>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||
目前仅高铁湛江北站项目提供三维模型。请切换到 ZJB 项目后再打开此页面。
|
||||
</p>
|
||||
<Link href="/network-simulation" className="mt-2 inline-flex min-h-10 items-center text-sm font-medium text-blue-700 hover:text-blue-800 hover:underline">
|
||||
返回管网在线模拟
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const hasFrame = frame.payload !== null;
|
||||
const frameStatusText = loading
|
||||
? "正在读取时间帧"
|
||||
: loadError
|
||||
? "数据请求失败"
|
||||
: hasFrame
|
||||
? "时间帧已应用"
|
||||
: "该时刻无模拟结果";
|
||||
const dataWarnings = [pressureMappingWarning, ...frame.warnings].filter(
|
||||
(warning): warning is string => Boolean(warning),
|
||||
);
|
||||
|
||||
return (
|
||||
<main lang="zh-CN" className="relative h-full min-h-0 overflow-hidden bg-slate-200">
|
||||
<iframe key={sceneKey} ref={iframeRef} src={SCENE_URL} title="高铁湛江北站供水系统三维场景" referrerPolicy="same-origin" className="block h-full w-full border-0" />
|
||||
|
||||
{!model && !sceneError && (
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 bg-slate-100/55 text-sm text-slate-600 backdrop-blur-sm">
|
||||
<span className="h-8 w-8 animate-spin rounded-full border-[3px] border-blue-200 border-t-blue-600" />
|
||||
正在载入三维模型
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(loadError || sceneError) && (
|
||||
<div role="alert" className="absolute left-1/2 top-28 z-40 flex w-[min(560px,calc(100%-24px))] -translate-x-1/2 items-center gap-3 rounded-xl border border-red-200/70 bg-red-50/80 px-4 py-3 text-sm text-red-800 shadow-xl shadow-red-950/10 backdrop-blur-xl md:top-4">
|
||||
<FiAlertCircle className="shrink-0 text-lg" />
|
||||
<span className="min-w-0 flex-1">{loadError || sceneError}</span>
|
||||
{sceneError && <button type="button" onClick={retryScene} className="min-h-10 shrink-0 rounded-lg px-3 font-medium transition hover:bg-red-100/80 active:scale-95">重试场景</button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section
|
||||
aria-label="三维场景数据状态"
|
||||
className="absolute left-2 top-16 z-20 flex max-w-[calc(100%-1rem)] items-center gap-2 rounded-xl bg-[linear-gradient(135deg,rgba(255,255,255,0.50),rgba(224,239,250,0.28))] py-1.5 pl-3 pr-1 [backdrop-filter:blur(24px)_saturate(170%)_contrast(94%)] [-webkit-backdrop-filter:blur(24px)_saturate(170%)_contrast(94%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-1px_0_rgba(112,145,168,0.18),0_12px_35px_rgba(15,43,69,0.18)] ring-1 ring-white/55 md:left-[76px] md:top-4 md:max-w-[420px]"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="h-[18px] w-[18px] shrink-0 animate-spin rounded-full border-2 border-blue-200 border-t-blue-600" />
|
||||
) : loadError || !hasFrame ? (
|
||||
<FiAlertCircle className={loadError ? "shrink-0 text-lg text-red-600" : "shrink-0 text-lg text-slate-400"} />
|
||||
) : (
|
||||
<FiCheckCircle className="shrink-0 text-lg text-emerald-600" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-semibold text-slate-900">
|
||||
{frameStatusText}
|
||||
</p>
|
||||
<p className="truncate text-[11px] tabular-nums text-slate-500">
|
||||
{frame.resultTime
|
||||
? `${formatDateTime(frame.resultTime)} · ${frame.stats.simulationNodes} 节点 · ${frame.stats.simulationLinks} 连接 · SCADA ${frame.stats.scadaOverrides}`
|
||||
: `选择 ${formatDateTime(queryTime)}`}
|
||||
</p>
|
||||
</div>
|
||||
{dataWarnings.length > 0 && (
|
||||
<FiAlertTriangle aria-label="数据警告" title={dataWarnings.join(" ")} className="shrink-0 text-lg text-amber-600" />
|
||||
)}
|
||||
<button type="button" aria-label="重新读取当前时间帧" title="重新读取当前时间帧" disabled={loading || !model} onClick={() => setRefreshVersion((value) => value + 1)} className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-lg text-slate-500 transition hover:bg-white/60 hover:text-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:opacity-35">
|
||||
<FiRefreshCw className={loading ? "animate-spin" : undefined} />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{timelineOpen && (
|
||||
<ThreeDimensionalTimeline
|
||||
selectedDate={selectedDate}
|
||||
currentTime={resolvedCurrentTime}
|
||||
durationMinutes={durationMinutes}
|
||||
stepMinutes={stepMinutes}
|
||||
disabled={!model}
|
||||
sidePanelOpen={controlsOpen}
|
||||
onClose={() => setTimelineOpen(false)}
|
||||
onSelectedDateChange={setSelectedDate}
|
||||
onCurrentTimeChange={setCurrentTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ThreeDimensionalControls
|
||||
open={controlsOpen}
|
||||
activeTab={controlTab}
|
||||
ready={Boolean(model)}
|
||||
state={runtimeState}
|
||||
selection={selection}
|
||||
timelineOpen={timelineOpen}
|
||||
onOpenChange={setControlsOpen}
|
||||
onTimelineOpenChange={setTimelineOpen}
|
||||
onTabChange={setControlTab}
|
||||
onCommand={sendCommand}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user