feat(3d): integrate ZJB scene with project context
This commit is contained in:
@@ -46,8 +46,8 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
const [showProjectSelector, setShowProjectSelector] = useState(false);
|
||||
const [showChatbox, setShowChatbox] = useState(false);
|
||||
const open = Boolean(anchorEl);
|
||||
const setCurrentProjectId = useProjectStore(
|
||||
(state) => state.setCurrentProjectId,
|
||||
const setActiveProjectContext = useProjectStore(
|
||||
(state) => state.setCurrentProject,
|
||||
);
|
||||
|
||||
const { data: user } = useGetIdentity<IUser>();
|
||||
@@ -78,7 +78,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
localStorage.setItem(NETWORK_NAME_STORAGE_KEY, networkName);
|
||||
localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(","));
|
||||
localStorage.removeItem(`${workspace}_map_view`);
|
||||
setCurrentProjectId(projectId || networkName || workspace);
|
||||
setActiveProjectContext(projectId || networkName || workspace, networkName);
|
||||
setShowProjectSelector(false);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { ThreeDimensionalControls } from "./ThreeDimensionalControls";
|
||||
import type { SceneRuntimeState } from "./sceneProtocol";
|
||||
|
||||
const state: 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: "overview",
|
||||
note: "供水总览",
|
||||
views: [
|
||||
{
|
||||
id: "overview",
|
||||
label: "供水总览",
|
||||
mode: "network",
|
||||
saved: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe("ThreeDimensionalControls", () => {
|
||||
it("sends typed scene commands from platform controls", () => {
|
||||
const onCommand = jest.fn();
|
||||
const onTimelineOpenChange = jest.fn();
|
||||
render(
|
||||
<ThreeDimensionalControls
|
||||
open
|
||||
activeTab="scene"
|
||||
ready
|
||||
state={state}
|
||||
selection={null}
|
||||
timelineOpen
|
||||
onOpenChange={jest.fn()}
|
||||
onTimelineOpenChange={onTimelineOpenChange}
|
||||
onTabChange={jest.fn()}
|
||||
onCommand={onCommand}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "站房精细版" }));
|
||||
expect(onCommand).toHaveBeenCalledWith({ name: "set-mode", mode: "detail" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "时间轴" }));
|
||||
expect(onTimelineOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("shows selected asset fields and linked assets in the property tab", () => {
|
||||
const onCommand = jest.fn();
|
||||
render(
|
||||
<ThreeDimensionalControls
|
||||
open
|
||||
activeTab="properties"
|
||||
ready
|
||||
state={state}
|
||||
selection={{
|
||||
assetId: "inp:node:J-1",
|
||||
elementId: "J-1",
|
||||
kind: "节点",
|
||||
title: "节点 · J-1",
|
||||
sections: [
|
||||
{
|
||||
title: "运行结果",
|
||||
fields: [{ label: "压力", value: "31.200 mH₂O" }],
|
||||
},
|
||||
],
|
||||
neighbors: [{ id: "P-1", label: "P-1" }],
|
||||
}}
|
||||
timelineOpen
|
||||
onOpenChange={jest.fn()}
|
||||
onTimelineOpenChange={jest.fn()}
|
||||
onTabChange={jest.fn()}
|
||||
onCommand={onCommand}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("节点 · J-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("31.200 mH₂O")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "P-1" }));
|
||||
expect(onCommand).toHaveBeenCalledWith({ name: "select-asset", assetId: "P-1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,482 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
FiBox,
|
||||
FiCamera,
|
||||
FiChevronRight,
|
||||
FiClock,
|
||||
FiCrosshair,
|
||||
FiDroplet,
|
||||
FiHome,
|
||||
FiInfo,
|
||||
FiLayers,
|
||||
FiMaximize,
|
||||
FiPlus,
|
||||
FiRotateCcw,
|
||||
FiSliders,
|
||||
FiTrash2,
|
||||
FiX,
|
||||
} from "react-icons/fi";
|
||||
|
||||
import type {
|
||||
SceneAssetSelection,
|
||||
SceneCommand,
|
||||
SceneMode,
|
||||
SceneRuntimeState,
|
||||
} from "./sceneProtocol";
|
||||
|
||||
const sceneModes: Array<{ mode: SceneMode; label: string }> = [
|
||||
{ mode: "network", label: "供水管网" },
|
||||
{ mode: "hydraulic", label: "泵组与管网" },
|
||||
{ mode: "map", label: "站区轻量版" },
|
||||
{ mode: "detail", label: "站房精细版" },
|
||||
{ mode: "pump", label: "CAD 泵房参考" },
|
||||
{ mode: "meters", label: "设备样件" },
|
||||
];
|
||||
|
||||
const glassSurface =
|
||||
"bg-[linear-gradient(135deg,rgba(255,255,255,0.50),rgba(224,239,250,0.28))] [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_18px_50px_rgba(15,43,69,0.20)] ring-1 ring-white/55";
|
||||
const fieldClass =
|
||||
"h-10 w-full rounded-lg border border-slate-300/70 bg-white/55 px-3 text-sm text-slate-800 outline-none transition focus:border-blue-500 focus:bg-white/80 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
export type ControlTab = "scene" | "style" | "properties";
|
||||
|
||||
export type ThreeDimensionalControlsProps = {
|
||||
open: boolean;
|
||||
activeTab: ControlTab;
|
||||
ready: boolean;
|
||||
state: SceneRuntimeState;
|
||||
selection: SceneAssetSelection | null;
|
||||
timelineOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onTimelineOpenChange: (open: boolean) => void;
|
||||
onTabChange: (tab: ControlTab) => void;
|
||||
onCommand: (command: SceneCommand) => void;
|
||||
};
|
||||
|
||||
export function ThreeDimensionalControls({
|
||||
open,
|
||||
activeTab,
|
||||
ready,
|
||||
state,
|
||||
selection,
|
||||
timelineOpen,
|
||||
onOpenChange,
|
||||
onTimelineOpenChange,
|
||||
onTabChange,
|
||||
onCommand,
|
||||
}: ThreeDimensionalControlsProps) {
|
||||
const [cameraLabel, setCameraLabel] = useState("");
|
||||
|
||||
const selectTab = (tab: ControlTab) => {
|
||||
onTabChange(tab);
|
||||
onOpenChange(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
aria-label="三维场景快捷工具"
|
||||
className={clsx(
|
||||
glassSurface,
|
||||
"absolute left-2 top-2 z-20 flex max-w-[calc(100%-1rem)] items-center gap-0.5 rounded-xl p-1 opacity-90 transition-opacity duration-200 hover:opacity-100 md:left-4 md:top-4 md:flex-col",
|
||||
)}
|
||||
>
|
||||
<ToolButton
|
||||
label="场景与视角"
|
||||
active={open && activeTab === "scene"}
|
||||
disabled={!ready}
|
||||
onClick={() => selectTab("scene")}
|
||||
>
|
||||
<FiBox />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="管网样式"
|
||||
active={open && activeTab === "style"}
|
||||
disabled={!ready}
|
||||
onClick={() => selectTab("style")}
|
||||
>
|
||||
<FiDroplet />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="时间轴"
|
||||
active={timelineOpen}
|
||||
disabled={!ready}
|
||||
onClick={() => {
|
||||
onTimelineOpenChange(!timelineOpen);
|
||||
if (!timelineOpen && window.matchMedia("(max-width: 767px)").matches) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FiClock />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="构件属性"
|
||||
active={open && activeTab === "properties"}
|
||||
disabled={!ready}
|
||||
onClick={() => selectTab("properties")}
|
||||
>
|
||||
<FiInfo />
|
||||
</ToolButton>
|
||||
<span aria-hidden="true" className="mx-1 h-6 w-px bg-slate-300/70 md:my-1 md:h-px md:w-6" />
|
||||
<ToolButton
|
||||
label="供水总览"
|
||||
disabled={!ready}
|
||||
onClick={() => onCommand({ name: "visit-camera", viewId: "overview" })}
|
||||
>
|
||||
<FiHome />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="管网俯视"
|
||||
disabled={!ready}
|
||||
onClick={() => onCommand({ name: "visit-camera", viewId: "plan" })}
|
||||
>
|
||||
<FiLayers />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="适应视图"
|
||||
disabled={!ready}
|
||||
onClick={() => onCommand({ name: "fit-view" })}
|
||||
>
|
||||
<FiMaximize />
|
||||
</ToolButton>
|
||||
</nav>
|
||||
|
||||
{open && (
|
||||
<aside
|
||||
aria-label="三维场景控制面板"
|
||||
className={clsx(
|
||||
glassSurface,
|
||||
"absolute inset-x-2 bottom-2 z-30 flex max-h-[min(64dvh,560px)] flex-col overflow-hidden rounded-2xl md:inset-x-auto md:bottom-4 md:right-4 md:top-4 md:h-auto md:max-h-[760px] md:w-96",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-14 items-center gap-3 border-b border-white/45 bg-white/10 px-4">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-blue-600 text-white shadow-sm shadow-blue-700/20">
|
||||
<FiSliders aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-slate-900">三维场景工具</p>
|
||||
<p className="truncate text-[11px] text-slate-500">{state.status}</p>
|
||||
</div>
|
||||
<IconButton label="收起三维场景工具" onClick={() => onOpenChange(false)}>
|
||||
<FiX />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div role="tablist" aria-label="三维场景工具分类" className="grid grid-cols-3 border-b border-white/45 bg-sky-50/10 px-2 pt-1">
|
||||
<TabButton active={activeTab === "scene"} onClick={() => onTabChange("scene")} icon={<FiLayers />}>场景</TabButton>
|
||||
<TabButton active={activeTab === "style"} onClick={() => onTabChange("style")} icon={<FiDroplet />}>样式</TabButton>
|
||||
<TabButton active={activeTab === "properties"} onClick={() => onTabChange("properties")} icon={<FiInfo />}>属性</TabButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-disabled={!ready}
|
||||
className={clsx(
|
||||
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 [scrollbar-color:rgba(100,116,139,.45)_transparent] [scrollbar-width:thin]",
|
||||
!ready && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
{activeTab === "scene" && (
|
||||
<div className="space-y-6">
|
||||
<ControlSection title="显示内容">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{sceneModes.map((item) => (
|
||||
<button
|
||||
key={item.mode}
|
||||
type="button"
|
||||
onClick={() => onCommand({ name: "set-mode", mode: item.mode })}
|
||||
className={clsx(
|
||||
"min-h-10 rounded-lg border px-2 text-sm font-medium transition active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40",
|
||||
state.mode === item.mode
|
||||
? "border-blue-600 bg-blue-600 text-white shadow-sm shadow-blue-700/20"
|
||||
: "border-slate-300/70 bg-white/35 text-slate-700 hover:border-blue-400 hover:bg-blue-50/70 hover:text-blue-700",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="观察位置" description={state.camera.note}>
|
||||
<div className="space-y-1">
|
||||
{state.camera.views.map((view) => (
|
||||
<div key={view.id} className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCommand({ name: "visit-camera", viewId: view.id })}
|
||||
className={clsx(
|
||||
"flex min-h-10 min-w-0 flex-1 items-center gap-2 rounded-lg px-3 text-left text-sm transition active:scale-[0.99] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40",
|
||||
state.camera.active === view.id
|
||||
? "bg-blue-600 text-white shadow-sm"
|
||||
: "text-slate-700 hover:bg-white/55 hover:text-blue-700",
|
||||
)}
|
||||
>
|
||||
<FiCamera className="shrink-0" />
|
||||
<span className="truncate">{view.label}</span>
|
||||
<FiChevronRight className="ml-auto shrink-0 opacity-50" />
|
||||
</button>
|
||||
{view.saved && (
|
||||
<IconButton
|
||||
label={`移除视角 ${view.label}`}
|
||||
onClick={() => onCommand({ name: "remove-camera", viewId: view.id })}
|
||||
>
|
||||
<FiTrash2 />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
aria-label="当前视角名称"
|
||||
className={fieldClass}
|
||||
maxLength={24}
|
||||
placeholder="当前视角名称"
|
||||
value={cameraLabel}
|
||||
onChange={(event) => setCameraLabel(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!cameraLabel.trim()}
|
||||
onClick={() => {
|
||||
onCommand({ name: "save-camera", label: cameraLabel.trim() });
|
||||
setCameraLabel("");
|
||||
}}
|
||||
className="inline-flex min-h-10 shrink-0 items-center gap-1.5 rounded-lg bg-blue-600 px-3 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<FiPlus /> 保存
|
||||
</button>
|
||||
</div>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="场景显示">
|
||||
<SwitchRow label="建筑背景" checked={state.contextVisible} onChange={() => onCommand({ name: "toggle-context" })} />
|
||||
<SwitchRow label="屋盖与吊顶" checked={state.roofVisible} onChange={() => onCommand({ name: "toggle-roof" })} />
|
||||
<SelectField
|
||||
label="管网展示比例"
|
||||
value={state.displayMode}
|
||||
onChange={(value) => onCommand({ name: "set-display-mode", mode: value as "global" | "coordinated" })}
|
||||
options={[{ value: "global", label: "全局比例" }, { value: "coordinated", label: "泵房协调比例" }]}
|
||||
/>
|
||||
</ControlSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "style" && (
|
||||
<div className="space-y-6">
|
||||
<ControlSection title="管网表达">
|
||||
<SelectField
|
||||
label="着色方式"
|
||||
value={state.style.mode}
|
||||
onChange={(value) => onCommand({ name: "set-style", patch: { mode: value as SceneRuntimeState["style"]["mode"] } })}
|
||||
options={[
|
||||
{ value: "uniform", label: "统一颜色" },
|
||||
{ value: "pressure", label: "压力" },
|
||||
{ value: "velocity", label: "流速" },
|
||||
{ value: "direction", label: "流向" },
|
||||
]}
|
||||
/>
|
||||
<LabeledSlider label={`管径倍率 ${state.style.scale}×`} value={state.style.scale} min={1} max={12} step={1} onChange={(value) => onCommand({ name: "set-style", patch: { scale: value } })} />
|
||||
<LabeledSlider label={`不透明度 ${Math.round(state.style.opacity * 100)}%`} value={state.style.opacity} min={0.15} max={1} step={0.05} onChange={(value) => onCommand({ name: "set-style", patch: { opacity: value } })} />
|
||||
<LabeledSlider label={`表面粗糙度 ${state.style.roughness.toFixed(2)}`} value={state.style.roughness} min={0.05} max={1} step={0.05} onChange={(value) => onCommand({ name: "set-style", patch: { roughness: value } })} />
|
||||
<SwitchRow label="显示连接节点" checked={state.style.nodes} onChange={(checked) => onCommand({ name: "set-style", patch: { nodes: checked } })} />
|
||||
<SelectField
|
||||
label="方向箭头"
|
||||
value={state.style.direction}
|
||||
onChange={(value) => onCommand({ name: "set-style", patch: { direction: value as SceneRuntimeState["style"]["direction"] } })}
|
||||
options={[{ value: "none", label: "隐藏" }, { value: "results", label: "按后端结果" }, { value: "topology", label: "按编号方向" }]}
|
||||
/>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="结果色带">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<ColorInput label="低值" value={state.style.lowColor} onChange={(value) => onCommand({ name: "set-style", patch: { lowColor: value } })} />
|
||||
<ColorInput label="高值" value={state.style.highColor} onChange={(value) => onCommand({ name: "set-style", patch: { highColor: value } })} />
|
||||
<ColorInput label="无数据" value={state.style.missingColor} onChange={(value) => onCommand({ name: "set-style", patch: { missingColor: value } })} />
|
||||
</div>
|
||||
<div aria-hidden="true" className="h-2 rounded-full ring-1 ring-white/60" style={{ background: `linear-gradient(90deg, ${state.style.lowColor}, ${state.style.highColor})` }} />
|
||||
<SwitchRow label="按当前结果自动设定范围" checked={state.style.autoRange} onChange={(checked) => onCommand({ name: "set-style", patch: { autoRange: checked } })} />
|
||||
{!state.style.autoRange && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<NumberInput label="下限" value={state.style.min} onCommit={(value) => onCommand({ name: "set-style", patch: { min: value } })} />
|
||||
<NumberInput label="上限" value={state.style.max} onCommit={(value) => onCommand({ name: "set-style", patch: { max: value } })} />
|
||||
</div>
|
||||
)}
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="光照与画质">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SelectField label="场景光照" value={state.appearance.preset} onChange={(value) => onCommand({ name: "set-appearance", patch: { preset: value as SceneRuntimeState["appearance"]["preset"] } })} options={[{ value: "day", label: "清晰日光" }, { value: "studio", label: "设备展厅" }, { value: "evening", label: "傍晚暖光" }]} />
|
||||
<SelectField label="画质" value={state.appearance.quality} onChange={(value) => onCommand({ name: "set-appearance", patch: { quality: value as SceneRuntimeState["appearance"]["quality"] } })} options={[{ value: "standard", label: "标准" }, { value: "high", label: "高质量" }]} />
|
||||
</div>
|
||||
<LabeledSlider label={`亮度 ${state.appearance.exposure.toFixed(2)}`} value={state.appearance.exposure} min={0.55} max={1.6} step={0.05} onChange={(value) => onCommand({ name: "set-appearance", patch: { exposure: value } })} />
|
||||
<SwitchRow label="柔和阴影" checked={state.appearance.shadows} onChange={(checked) => onCommand({ name: "set-appearance", patch: { shadows: checked } })} />
|
||||
<SwitchRow label="空间遮蔽与边缘平滑" checked={state.appearance.effects} onChange={(checked) => onCommand({ name: "set-appearance", patch: { effects: checked } })} />
|
||||
</ControlSection>
|
||||
|
||||
<button type="button" onClick={() => onCommand({ name: "reset-style" })} className="flex min-h-10 w-full items-center justify-center gap-2 rounded-lg border border-slate-300/70 bg-white/35 text-sm font-medium text-slate-700 transition hover:border-blue-400 hover:bg-blue-50/70 hover:text-blue-700 active:scale-[0.99]">
|
||||
<FiRotateCcw /> 恢复默认管网样式
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "properties" && <AssetProperties selection={selection} onCommand={onCommand} />}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolButton({ label, active = false, disabled = false, onClick, children }: { label: string; active?: boolean; disabled?: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"grid h-10 w-10 place-items-center rounded-lg text-[18px] transition duration-150 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 disabled:cursor-not-allowed disabled:opacity-35",
|
||||
active
|
||||
? "bg-blue-600 text-white shadow-md shadow-blue-700/20 ring-1 ring-blue-400/30"
|
||||
: "text-slate-600 hover:bg-blue-50/80 hover:text-blue-700",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<span className="pointer-events-none absolute left-full top-1/2 z-50 ml-2 hidden -translate-y-1/2 whitespace-nowrap rounded-md bg-slate-900/90 px-2 py-1 text-xs text-white opacity-0 shadow-md transition group-hover:opacity-100 md:block">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({ label, disabled = false, onClick, children }: { label: string; disabled?: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button type="button" aria-label={label} title={label} disabled={disabled} onClick={onClick} className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-lg text-slate-500 transition hover:bg-white/65 hover:text-blue-700 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-35">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, icon, children }: { active: boolean; onClick: () => void; icon: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<button type="button" role="tab" aria-selected={active} onClick={onClick} className={clsx("relative flex min-h-11 items-center justify-center gap-1.5 rounded-t-lg text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/40", active ? "text-blue-700 after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:rounded-full after:bg-blue-600" : "text-slate-500 hover:bg-white/35 hover:text-slate-800")}>
|
||||
{icon}{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-2">
|
||||
<h3 className="text-[11px] font-bold uppercase tracking-[0.12em] text-slate-500">{title}</h3>
|
||||
{description && <p className="mt-1 text-xs leading-5 text-slate-500">{description}</p>}
|
||||
</div>
|
||||
<div className="space-y-2.5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-500">{label}</span>
|
||||
<select className={fieldClass} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex min-h-10 cursor-pointer items-center justify-between gap-3 rounded-lg px-1 text-sm text-slate-700">
|
||||
<span>{label}</span>
|
||||
<input type="checkbox" className="peer sr-only" checked={checked} onChange={(event) => onChange(event.target.checked)} />
|
||||
<span aria-hidden="true" className="relative h-6 w-11 shrink-0 rounded-full bg-slate-300/80 transition peer-checked:bg-blue-600 peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500/40 peer-focus-visible:ring-offset-2 after:absolute after:left-1 after:top-1 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform peer-checked:after:translate-x-5" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function LabeledSlider({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs tabular-nums text-slate-500">{label}</span>
|
||||
<input type="range" aria-label={label} className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-300/75 accent-blue-600" value={value} min={min} max={max} step={step} onChange={(event) => onChange(Number(event.target.value))} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorInput({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="text-center">
|
||||
<input type="color" aria-label={`${label}颜色`} value={value} onChange={(event) => onChange(event.target.value)} className="h-10 w-full cursor-pointer rounded-lg border border-slate-300/70 bg-white/45 p-1" />
|
||||
<span className="mt-1 block text-xs text-slate-500">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({ label, value, onCommit }: { label: string; value: number; onCommit: (value: number) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-500">{label}</span>
|
||||
<input key={`${label}-${value}`} className={fieldClass} type="number" defaultValue={value} step={0.1} onBlur={(event) => { const next = Number(event.target.value); if (Number.isFinite(next)) onCommit(next); }} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetProperties({ selection, onCommand }: { selection: SceneAssetSelection | null; onCommand: (command: SceneCommand) => void }) {
|
||||
if (!selection) {
|
||||
return (
|
||||
<div className="flex min-h-72 flex-col items-center justify-center px-6 text-center">
|
||||
<span className="mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-blue-200/70 bg-blue-50/60 text-2xl text-blue-600"><FiInfo /></span>
|
||||
<h3 className="text-sm font-semibold text-slate-800">尚未选择三维构件</h3>
|
||||
<p className="mt-2 max-w-64 text-xs leading-5 text-slate-500">在模型中点击节点、管段、泵或阀门,这里会显示模型属性和当前后端结果。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<p className="text-base font-semibold text-slate-900">{selection.title}</p>
|
||||
<p className="mt-0.5 text-xs tabular-nums text-slate-500">模型编号 {selection.elementId}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" onClick={() => onCommand({ name: "locate-selection" })} className="flex min-h-10 items-center justify-center gap-2 rounded-lg bg-blue-600 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-[0.98]"><FiCrosshair />定位构件</button>
|
||||
<button type="button" onClick={() => onCommand({ name: "clear-selection" })} className="min-h-10 rounded-lg border border-slate-300/70 bg-white/35 text-sm font-medium text-slate-700 transition hover:bg-white/65 active:scale-[0.98]">清除选择</button>
|
||||
</div>
|
||||
{selection.sections.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-[0.12em] text-slate-500">{section.title}</h3>
|
||||
<dl>
|
||||
{section.fields.map((field, index) => (
|
||||
<div key={`${section.title}-${field.label}`} className={clsx("grid min-h-9 grid-cols-[minmax(86px,.42fr)_minmax(0,1fr)] items-start gap-4 py-2", index > 0 && "border-t border-slate-200/55")}>
|
||||
<dt className="text-xs text-slate-500">{field.label}</dt>
|
||||
<dd className="text-right text-sm tabular-nums text-slate-800 [overflow-wrap:anywhere]">{field.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
{selection.neighbors.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-[0.12em] text-slate-500">相邻管段</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selection.neighbors.map((neighbor) => (
|
||||
<button key={neighbor.id} type="button" onClick={() => onCommand({ name: "select-asset", assetId: neighbor.id })} className="min-h-9 rounded-lg border border-slate-300/70 bg-white/35 px-3 text-sm text-slate-700 transition hover:border-blue-400 hover:bg-blue-50/70 hover:text-blue-700">{neighbor.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const mockDraggable = jest.fn(({ children }: { children: ReactNode }) => children);
|
||||
|
||||
jest.mock("react-draggable", () => ({
|
||||
__esModule: true,
|
||||
default: (props: { children: ReactNode }) => mockDraggable(props),
|
||||
}));
|
||||
|
||||
import { ThreeDimensionalTimeline } from "./ThreeDimensionalTimeline";
|
||||
|
||||
describe("ThreeDimensionalTimeline", () => {
|
||||
it("renders as an open toolbar and can be collapsed", () => {
|
||||
const onClose = jest.fn();
|
||||
render(
|
||||
<ThreeDimensionalTimeline
|
||||
selectedDate={new Date("2026-09-11T12:00:00+08:00")}
|
||||
currentTime={720}
|
||||
durationMinutes={1440}
|
||||
stepMinutes={15}
|
||||
onClose={onClose}
|
||||
onSelectedDateChange={jest.fn()}
|
||||
onCurrentTimeChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("region", { name: "三维场景时间轴" })).toBeInTheDocument();
|
||||
expect(screen.getByText("结果时间轴")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "收起时间轴" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows the timeline to be dragged vertically away from the bottom", () => {
|
||||
render(
|
||||
<ThreeDimensionalTimeline
|
||||
selectedDate={new Date("2026-09-11T12:00:00+08:00")}
|
||||
currentTime={720}
|
||||
durationMinutes={1440}
|
||||
stepMinutes={15}
|
||||
onClose={jest.fn()}
|
||||
onSelectedDateChange={jest.fn()}
|
||||
onCurrentTimeChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockDraggable).toHaveBeenLastCalledWith(
|
||||
expect.not.objectContaining({ bounds: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the themed calendar and returns the selected day", () => {
|
||||
const onSelectedDateChange = jest.fn();
|
||||
render(
|
||||
<ThreeDimensionalTimeline
|
||||
selectedDate={new Date("2026-09-11T12:00:00+08:00")}
|
||||
currentTime={720}
|
||||
durationMinutes={1440}
|
||||
stepMinutes={15}
|
||||
onClose={jest.fn()}
|
||||
onSelectedDateChange={onSelectedDateChange}
|
||||
onCurrentTimeChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText("数据日期")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /选择日期,当前/ }));
|
||||
expect(screen.getByRole("dialog", { name: "选择数据日期" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "2026年9月10日" }));
|
||||
expect(onSelectedDateChange).toHaveBeenCalledWith(expect.any(Date));
|
||||
expect(screen.queryByRole("dialog", { name: "选择数据日期" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "播放速度,当前 0.4×" }));
|
||||
expect(screen.getByRole("listbox", { name: "选择播放速度" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("option", { name: /1.0×/ }));
|
||||
expect(screen.getByRole("button", { name: "播放速度,当前 1.0×" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the calendar open when returning to today", () => {
|
||||
const onSelectedDateChange = jest.fn();
|
||||
render(
|
||||
<ThreeDimensionalTimeline
|
||||
selectedDate={new Date("2026-08-20T12:00:00+08:00")}
|
||||
currentTime={720}
|
||||
durationMinutes={1440}
|
||||
stepMinutes={15}
|
||||
onClose={jest.fn()}
|
||||
onSelectedDateChange={onSelectedDateChange}
|
||||
onCurrentTimeChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /选择日期,当前/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "今天" }));
|
||||
expect(onSelectedDateChange).toHaveBeenCalledWith(expect.any(Date));
|
||||
expect(screen.getByRole("dialog", { name: "选择数据日期" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports fast year and month navigation", () => {
|
||||
render(
|
||||
<ThreeDimensionalTimeline
|
||||
selectedDate={new Date("2026-09-11T12:00:00+08:00")}
|
||||
currentTime={720}
|
||||
durationMinutes={1440}
|
||||
stepMinutes={15}
|
||||
onClose={jest.fn()}
|
||||
onSelectedDateChange={jest.fn()}
|
||||
onCurrentTimeChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /选择日期,当前/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "选择月份" }));
|
||||
expect(screen.getByRole("grid", { name: "2026 年月份" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "选择年份" }));
|
||||
expect(screen.getByRole("grid", { name: "选择年份" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("gridcell", { name: "2025" }));
|
||||
expect(screen.getByRole("grid", { name: "2025 年月份" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("gridcell", { name: "8 月" }));
|
||||
expect(screen.getByText("2025 年 8 月")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,592 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import Draggable from "react-draggable";
|
||||
import {
|
||||
FiCalendar,
|
||||
FiChevronDown,
|
||||
FiChevronLeft,
|
||||
FiChevronRight,
|
||||
FiPause,
|
||||
FiPlay,
|
||||
FiRotateCcw,
|
||||
FiSkipBack,
|
||||
FiSkipForward,
|
||||
FiX,
|
||||
FiZap,
|
||||
} from "react-icons/fi";
|
||||
|
||||
import {
|
||||
formatTimelineTime,
|
||||
getRoundedCurrentTimelineMinutes,
|
||||
normalizeTimelineMinutes,
|
||||
} from "@components/olmap/core/Controls/timelineTime";
|
||||
|
||||
const DEFAULT_PLAY_INTERVAL_MS = 2_500;
|
||||
const WEEK_LABELS = ["一", "二", "三", "四", "五", "六", "日"];
|
||||
const glassClass =
|
||||
"bg-[rgba(234,244,250,0.62)] [backdrop-filter:blur(26px)_saturate(145%)_contrast(96%)] [-webkit-backdrop-filter:blur(26px)_saturate(145%)_contrast(96%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.90),inset_0_-1px_0_rgba(112,145,168,0.16),0_18px_50px_rgba(15,43,69,0.20)] ring-1 ring-white/60";
|
||||
const calendarGlassClass =
|
||||
"bg-[rgba(238,246,251,0.88)] [backdrop-filter:blur(30px)_saturate(140%)_contrast(95%)] [-webkit-backdrop-filter:blur(30px)_saturate(140%)_contrast(95%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.96),inset_0_-1px_0_rgba(112,145,168,0.18),0_22px_60px_rgba(15,43,69,0.24)] ring-1 ring-white/75";
|
||||
|
||||
export type ThreeDimensionalTimelineProps = {
|
||||
selectedDate: Date;
|
||||
currentTime: number;
|
||||
durationMinutes: number;
|
||||
stepMinutes: number;
|
||||
disabled?: boolean;
|
||||
sidePanelOpen?: boolean;
|
||||
onClose: () => void;
|
||||
onSelectedDateChange: (date: Date) => void;
|
||||
onCurrentTimeChange: (minutes: number) => void;
|
||||
};
|
||||
|
||||
const startOfDay = (date: Date) =>
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
|
||||
const isSameDay = (left: Date, right: Date) =>
|
||||
left.getFullYear() === right.getFullYear() &&
|
||||
left.getMonth() === right.getMonth() &&
|
||||
left.getDate() === right.getDate();
|
||||
|
||||
const addDays = (date: Date, amount: number) =>
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount, 12);
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year} / ${month} / ${day}`;
|
||||
};
|
||||
|
||||
export function ThreeDimensionalTimeline({
|
||||
selectedDate,
|
||||
currentTime,
|
||||
durationMinutes,
|
||||
stepMinutes,
|
||||
disabled = false,
|
||||
sidePanelOpen = false,
|
||||
onClose,
|
||||
onSelectedDateChange,
|
||||
onCurrentTimeChange,
|
||||
}: ThreeDimensionalTimelineProps) {
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playIntervalMs, setPlayIntervalMs] = useState(DEFAULT_PLAY_INTERVAL_MS);
|
||||
const [previewTime, setPreviewTime] = useState<number | null>(null);
|
||||
const safeCurrentTime = normalizeTimelineMinutes(
|
||||
previewTime ?? currentTime,
|
||||
0,
|
||||
durationMinutes,
|
||||
);
|
||||
|
||||
const advance = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
const next = safeCurrentTime + stepMinutes * direction;
|
||||
onCurrentTimeChange(
|
||||
next > durationMinutes ? 0 : next < 0 ? durationMinutes : next,
|
||||
);
|
||||
},
|
||||
[durationMinutes, onCurrentTimeChange, safeCurrentTime, stepMinutes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing || disabled) return;
|
||||
const intervalId = window.setInterval(() => advance(1), playIntervalMs);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [advance, disabled, playIntervalMs, playing]);
|
||||
|
||||
const marks = useMemo(
|
||||
() =>
|
||||
[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const value = Math.round((durationMinutes * ratio) / stepMinutes) * stepMinutes;
|
||||
return formatTimelineTime(Math.min(durationMinutes, value), 0, durationMinutes);
|
||||
}),
|
||||
[durationMinutes, stepMinutes],
|
||||
);
|
||||
|
||||
const resetToCurrentTime = () => {
|
||||
const now = new Date();
|
||||
setPlaying(false);
|
||||
onSelectedDateChange(now);
|
||||
onCurrentTimeChange(
|
||||
getRoundedCurrentTimelineMinutes(now, stepMinutes, durationMinutes),
|
||||
);
|
||||
};
|
||||
|
||||
const commitPreview = (value?: string) => {
|
||||
const next = Number(value ?? previewTime ?? safeCurrentTime);
|
||||
if (!Number.isFinite(next)) return;
|
||||
setPreviewTime(null);
|
||||
onCurrentTimeChange(next);
|
||||
};
|
||||
|
||||
const progress = durationMinutes > 0
|
||||
? Math.min(100, Math.max(0, (safeCurrentTime / durationMinutes) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"pointer-events-none absolute inset-x-2 bottom-2 z-20 flex justify-center transition-[right] duration-200 md:left-4 md:bottom-4",
|
||||
sidePanelOpen ? "md:right-[416px]" : "md:right-4",
|
||||
)}
|
||||
>
|
||||
<Draggable
|
||||
nodeRef={timelineRef}
|
||||
handle=".timeline-drag-handle"
|
||||
cancel="button, input, select, [role='dialog']"
|
||||
>
|
||||
<section
|
||||
ref={timelineRef}
|
||||
aria-label="三维场景时间轴"
|
||||
className={clsx(
|
||||
glassClass,
|
||||
"pointer-events-auto relative w-full max-w-[950px] rounded-2xl opacity-95 transition-opacity duration-200 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<div className="timeline-drag-handle relative flex h-7 cursor-move touch-none items-center justify-center rounded-t-2xl border-b border-white/40 bg-white/10">
|
||||
<span aria-hidden="true" className="h-1 w-10 rounded-full bg-slate-400/60 transition-colors hover:bg-slate-500/70" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="收起时间轴"
|
||||
title="收起时间轴"
|
||||
onClick={onClose}
|
||||
className="absolute right-1 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-lg text-slate-500 transition-[transform,color,background-color] hover:bg-white/60 hover:text-slate-800 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3 pt-3 md:px-4 md:pb-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-slate-800">结果时间轴</span>
|
||||
<span className="rounded-md bg-blue-50/60 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 ring-1 ring-blue-200/55">{stepMinutes} 分钟步长</span>
|
||||
<time className="ml-auto hidden text-xs tabular-nums text-slate-500 sm:block">
|
||||
{formatDate(selectedDate)} {formatTimelineTime(safeCurrentTime, 0, durationMinutes)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<div className="grid gap-2.5 sm:grid-cols-[minmax(260px,0.85fr)_minmax(350px,1.15fr)]">
|
||||
<div className="rounded-xl bg-white/25 px-3 py-2 ring-1 ring-white/40">
|
||||
<span className="mb-1.5 block text-[11px] font-medium text-slate-500">数据日期</span>
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<SquareButton label="后退一天" disabled={disabled} onClick={() => onSelectedDateChange(addDays(selectedDate, -1))}>
|
||||
<FiChevronLeft />
|
||||
</SquareButton>
|
||||
<CalendarPicker
|
||||
selectedDate={selectedDate}
|
||||
disabled={disabled}
|
||||
onChange={onSelectedDateChange}
|
||||
/>
|
||||
<SquareButton
|
||||
label="前进一天"
|
||||
disabled={disabled || startOfDay(selectedDate).getTime() >= startOfDay(new Date()).getTime()}
|
||||
onClick={() => onSelectedDateChange(addDays(selectedDate, 1))}
|
||||
>
|
||||
<FiChevronRight />
|
||||
</SquareButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-white/25 px-3 py-2 ring-1 ring-white/40">
|
||||
<span className="mb-1.5 block text-[11px] font-medium text-slate-500">播放控制</span>
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<PlaybackSpeedPicker
|
||||
value={playIntervalMs}
|
||||
disabled={disabled}
|
||||
onChange={setPlayIntervalMs}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<TimelineButton label={`后退 ${stepMinutes} 分钟`} disabled={disabled} onClick={() => advance(-1)}><FiSkipBack /></TimelineButton>
|
||||
<TimelineButton label={playing ? "暂停播放" : "播放时间轴"} disabled={disabled} active={playing} onClick={() => setPlaying((value) => !value)}>{playing ? <FiPause /> : <FiPlay className="translate-x-px" />}</TimelineButton>
|
||||
<TimelineButton label={`前进 ${stepMinutes} 分钟`} disabled={disabled} onClick={() => advance(1)}><FiSkipForward /></TimelineButton>
|
||||
</div>
|
||||
<TimelineButton label="回到当前时刻" disabled={disabled} onClick={resetToCurrentTime}><FiRotateCcw /></TimelineButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 rounded-xl bg-white/20 px-3 pb-2 pt-2.5 ring-1 ring-white/35">
|
||||
<div className="mb-1.5 flex items-baseline gap-2">
|
||||
<span className="text-[11px] text-slate-500">查询时刻</span>
|
||||
<strong className="text-sm font-semibold tabular-nums text-slate-800">{formatTimelineTime(safeCurrentTime, 0, durationMinutes)}</strong>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="range"
|
||||
aria-label="三维场景查询时刻"
|
||||
min={0}
|
||||
max={durationMinutes}
|
||||
step={stepMinutes}
|
||||
value={safeCurrentTime}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setPreviewTime(Number(event.target.value))}
|
||||
onPointerUp={(event) => commitPreview(event.currentTarget.value)}
|
||||
onKeyUp={(event) => commitPreview(event.currentTarget.value)}
|
||||
onBlur={(event) => previewTime !== null && commitPreview(event.currentTarget.value)}
|
||||
className="block h-1.5 w-full cursor-pointer appearance-none rounded-full disabled:cursor-not-allowed disabled:opacity-40 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-blue-600 [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-[3px] [&::-webkit-slider-thumb]:ring-white/80"
|
||||
style={{ background: `linear-gradient(90deg, #2563eb 0%, #2563eb ${progress}%, rgba(148,163,184,.42) ${progress}%, rgba(148,163,184,.42) 100%)` }}
|
||||
/>
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-between px-0.5">
|
||||
{[0, 1, 2, 3, 4].map((mark) => <span key={mark} className="h-1 w-1 rounded-full bg-white/90 shadow-sm" />)}
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true" className="mt-1.5 flex justify-between text-[10px] tabular-nums text-slate-500">
|
||||
{marks.map((mark, index) => <span key={`${mark}-${index}`} className={clsx(index > 0 && index < marks.length - 1 && "hidden sm:inline")}>{mark}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Draggable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CalendarView = "days" | "months" | "years";
|
||||
|
||||
function CalendarPicker({ selectedDate, disabled, onChange }: { selectedDate: Date; disabled: boolean; onChange: (date: Date) => void }) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const calendarRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [visibleMonth, setVisibleMonth] = useState(() => new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
const [focusedDate, setFocusedDate] = useState(() => startOfDay(selectedDate));
|
||||
const [view, setView] = useState<CalendarView>("days");
|
||||
const [yearPageStart, setYearPageStart] = useState(() => Math.floor(selectedDate.getFullYear() / 12) * 12);
|
||||
const today = startOfDay(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || view !== "days") return;
|
||||
window.requestAnimationFrame(() => {
|
||||
calendarRef.current
|
||||
?.querySelector<HTMLButtonElement>("[data-calendar-focused='true']")
|
||||
?.focus();
|
||||
});
|
||||
}, [focusedDate, open, view, visibleMonth]);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const firstWeekday = (visibleMonth.getDay() + 6) % 7;
|
||||
return Array.from({ length: 42 }, (_, index) =>
|
||||
new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), index - firstWeekday + 1, 12),
|
||||
);
|
||||
}, [visibleMonth]);
|
||||
|
||||
const selectDate = (date: Date) => {
|
||||
onChange(date);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const selectToday = () => {
|
||||
const now = new Date();
|
||||
onChange(now);
|
||||
setFocusedDate(startOfDay(now));
|
||||
setVisibleMonth(new Date(now.getFullYear(), now.getMonth(), 1));
|
||||
setYearPageStart(Math.floor(now.getFullYear() / 12) * 12);
|
||||
setView("days");
|
||||
};
|
||||
|
||||
const showDate = (date: Date) => {
|
||||
const bounded = startOfDay(date).getTime() > today.getTime() ? today : startOfDay(date);
|
||||
setFocusedDate(bounded);
|
||||
setVisibleMonth(new Date(bounded.getFullYear(), bounded.getMonth(), 1));
|
||||
};
|
||||
|
||||
const handleCalendarKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (view !== "days") return;
|
||||
let next: Date | null = null;
|
||||
if (event.key === "ArrowLeft") next = addDays(focusedDate, -1);
|
||||
if (event.key === "ArrowRight") next = addDays(focusedDate, 1);
|
||||
if (event.key === "ArrowUp") next = addDays(focusedDate, -7);
|
||||
if (event.key === "ArrowDown") next = addDays(focusedDate, 7);
|
||||
if (event.key === "PageUp") next = new Date(focusedDate.getFullYear(), focusedDate.getMonth() - 1, focusedDate.getDate(), 12);
|
||||
if (event.key === "PageDown") next = new Date(focusedDate.getFullYear(), focusedDate.getMonth() + 1, focusedDate.getDate(), 12);
|
||||
const weekday = (focusedDate.getDay() + 6) % 7;
|
||||
if (event.key === "Home") next = addDays(focusedDate, -weekday);
|
||||
if (event.key === "End") next = addDays(focusedDate, 6 - weekday);
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
showDate(next);
|
||||
};
|
||||
|
||||
const atLatestPeriod =
|
||||
view === "days"
|
||||
? visibleMonth.getFullYear() === today.getFullYear() && visibleMonth.getMonth() >= today.getMonth()
|
||||
: view === "months"
|
||||
? visibleMonth.getFullYear() >= today.getFullYear()
|
||||
: yearPageStart + 11 >= today.getFullYear();
|
||||
|
||||
const movePeriod = (direction: -1 | 1) => {
|
||||
if (view === "days") {
|
||||
setVisibleMonth((date) => new Date(date.getFullYear(), date.getMonth() + direction, 1));
|
||||
return;
|
||||
}
|
||||
if (view === "months") {
|
||||
setVisibleMonth((date) => new Date(date.getFullYear() + direction, date.getMonth(), 1));
|
||||
return;
|
||||
}
|
||||
setYearPageStart((year) => year + direction * 12);
|
||||
};
|
||||
|
||||
const title = view === "days"
|
||||
? `${visibleMonth.getFullYear()} 年 ${visibleMonth.getMonth() + 1} 月`
|
||||
: view === "months"
|
||||
? `${visibleMonth.getFullYear()} 年`
|
||||
: `${yearPageStart} 至 ${yearPageStart + 11} 年`;
|
||||
|
||||
const openCalendar = () => {
|
||||
const selected = startOfDay(selectedDate);
|
||||
setVisibleMonth(new Date(selected.getFullYear(), selected.getMonth(), 1));
|
||||
setFocusedDate(selected);
|
||||
setYearPageStart(Math.floor(selected.getFullYear() / 12) * 12);
|
||||
setView("days");
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`选择日期,当前 ${formatDate(selectedDate)}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (open) setOpen(false);
|
||||
else openCalendar();
|
||||
}}
|
||||
className={clsx(
|
||||
"flex h-10 min-w-[154px] items-center gap-2 rounded-xl bg-white/48 px-3 text-sm tabular-nums text-slate-700 ring-1 ring-white/65 transition-[transform,background-color,box-shadow] hover:bg-white/72 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
open && "bg-white/78 ring-2 ring-blue-500/35",
|
||||
)}
|
||||
>
|
||||
<FiCalendar className="text-blue-600" />
|
||||
<span>{formatDate(selectedDate)}</span>
|
||||
<FiChevronDown className={clsx("ml-auto text-xs text-slate-500 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
ref={calendarRef}
|
||||
role="dialog"
|
||||
aria-label="选择数据日期"
|
||||
onKeyDown={handleCalendarKeyDown}
|
||||
className={clsx(
|
||||
calendarGlassClass,
|
||||
"absolute bottom-[calc(100%+10px)] left-[-46px] z-50 w-[min(312px,calc(100vw-32px))] rounded-2xl p-3 sm:left-0",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center">
|
||||
<button type="button" aria-label={view === "years" ? "前十二年" : view === "months" ? "上一年" : "上个月"} onClick={() => movePeriod(-1)} className="grid h-10 w-10 place-items-center rounded-xl text-slate-600 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"><FiChevronLeft /></button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={view === "days" ? "选择月份" : view === "months" ? "选择年份" : "返回日期"}
|
||||
aria-live="polite"
|
||||
onClick={() => setView((current) => current === "days" ? "months" : current === "months" ? "years" : "days")}
|
||||
className="min-h-10 flex-1 rounded-xl px-2 text-center text-sm font-semibold tabular-nums text-slate-800 transition hover:bg-white/55 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
<button type="button" onClick={selectToday} className="min-h-10 rounded-xl px-2.5 text-[11px] font-semibold text-blue-700 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40">今天</button>
|
||||
<button type="button" aria-label={view === "years" ? "后十二年" : view === "months" ? "下一年" : "下个月"} disabled={atLatestPeriod} onClick={() => movePeriod(1)} className="grid h-10 w-10 place-items-center rounded-xl text-slate-600 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-25"><FiChevronRight /></button>
|
||||
</div>
|
||||
{view === "days" && (
|
||||
<>
|
||||
<div className="grid grid-cols-7 text-center">
|
||||
{WEEK_LABELS.map((label, index) => <span key={label} className={clsx("py-1 text-[10px] font-medium", index > 4 ? "text-blue-600" : "text-slate-500")}>{label}</span>)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{days.map((date) => {
|
||||
const selected = isSameDay(date, selectedDate);
|
||||
const current = isSameDay(date, today);
|
||||
const focused = isSameDay(date, focusedDate);
|
||||
const outsideMonth = date.getMonth() !== visibleMonth.getMonth();
|
||||
const future = startOfDay(date).getTime() > today.getTime();
|
||||
return (
|
||||
<button
|
||||
key={date.toISOString()}
|
||||
type="button"
|
||||
aria-label={`${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日${current ? ",今天" : ""}`}
|
||||
aria-pressed={selected}
|
||||
data-calendar-focused={focused}
|
||||
tabIndex={focused ? 0 : -1}
|
||||
disabled={future}
|
||||
onFocus={() => setFocusedDate(startOfDay(date))}
|
||||
onClick={() => selectDate(date)}
|
||||
className={clsx(
|
||||
"relative grid h-9 w-9 place-items-center rounded-xl text-xs tabular-nums transition-[transform,color,background-color,box-shadow] active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/45 disabled:cursor-not-allowed disabled:opacity-20",
|
||||
selected
|
||||
? "bg-blue-600 font-semibold text-white shadow-md shadow-blue-700/20"
|
||||
: "text-slate-700 hover:bg-white/65 hover:text-blue-700",
|
||||
outsideMonth && !selected && "text-slate-400",
|
||||
current && !selected && "font-semibold text-blue-700 after:absolute after:bottom-1 after:h-1 after:w-1 after:rounded-full after:bg-blue-600",
|
||||
)}
|
||||
>
|
||||
{date.getDate()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{view === "months" && (
|
||||
<div role="grid" aria-label={`${visibleMonth.getFullYear()} 年月份`} className="grid grid-cols-3 gap-1 py-1">
|
||||
{Array.from({ length: 12 }, (_, month) => {
|
||||
const future = visibleMonth.getFullYear() > today.getFullYear() || (visibleMonth.getFullYear() === today.getFullYear() && month > today.getMonth());
|
||||
const selected = selectedDate.getFullYear() === visibleMonth.getFullYear() && selectedDate.getMonth() === month;
|
||||
return (
|
||||
<button key={month} type="button" role="gridcell" aria-selected={selected} disabled={future} onClick={() => { setVisibleMonth(new Date(visibleMonth.getFullYear(), month, 1)); setView("days"); }} className={clsx("min-h-11 rounded-xl text-sm transition hover:bg-white/60 hover:text-blue-700 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-20", selected ? "bg-blue-600 font-semibold text-white shadow-sm hover:bg-blue-600 hover:text-white" : "text-slate-700")}>{month + 1} 月</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{view === "years" && (
|
||||
<div role="grid" aria-label="选择年份" className="grid grid-cols-3 gap-1 py-1">
|
||||
{Array.from({ length: 12 }, (_, offset) => yearPageStart + offset).map((year) => {
|
||||
const future = year > today.getFullYear();
|
||||
const selected = year === selectedDate.getFullYear();
|
||||
return (
|
||||
<button key={year} type="button" role="gridcell" aria-selected={selected} disabled={future} onClick={() => { const month = year === today.getFullYear() ? Math.min(visibleMonth.getMonth(), today.getMonth()) : visibleMonth.getMonth(); setVisibleMonth(new Date(year, month, 1)); setView("months"); }} className={clsx("min-h-11 rounded-xl text-sm tabular-nums transition hover:bg-white/60 hover:text-blue-700 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-20", selected ? "bg-blue-600 font-semibold text-white shadow-sm hover:bg-blue-600 hover:text-white" : "text-slate-700")}>{year}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const playbackSpeedOptions = [
|
||||
{ value: 1000, label: "1.0×", description: "每秒一步" },
|
||||
{ value: 2500, label: "0.4×", description: "2.5 秒一步" },
|
||||
{ value: 5000, label: "0.2×", description: "5 秒一步" },
|
||||
];
|
||||
|
||||
function PlaybackSpeedPicker({ value, disabled, onChange }: { value: number; disabled: boolean; onChange: (value: number) => void }) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = playbackSpeedOptions.find((option) => option.value === value) ?? playbackSpeedOptions[1];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`播放速度,当前 ${selected.label}`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className={clsx(
|
||||
"flex h-10 min-w-[86px] items-center gap-2 rounded-xl bg-white/46 px-2.5 text-xs font-medium tabular-nums text-slate-700 ring-1 ring-white/65 transition-[transform,background-color,box-shadow] hover:bg-white/72 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
open && "bg-white/78 ring-2 ring-blue-500/35",
|
||||
)}
|
||||
>
|
||||
<FiZap aria-hidden="true" className="text-sm text-blue-600" />
|
||||
<span>{selected.label}</span>
|
||||
<FiChevronDown aria-hidden="true" className={clsx("ml-auto text-xs text-slate-500 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="选择播放速度"
|
||||
className={clsx(
|
||||
calendarGlassClass,
|
||||
"absolute bottom-[calc(100%+8px)] left-0 z-50 w-36 overflow-hidden rounded-xl p-1.5",
|
||||
)}
|
||||
>
|
||||
{playbackSpeedOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={clsx(
|
||||
"flex min-h-11 w-full items-center rounded-lg px-3 text-left transition-[transform,color,background-color] active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/40",
|
||||
option.value === value
|
||||
? "bg-blue-600 text-white shadow-sm shadow-blue-700/20"
|
||||
: "text-slate-700 hover:bg-white/60 hover:text-blue-700",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-semibold tabular-nums">{option.label}</span>
|
||||
<span className={clsx("ml-auto text-[10px]", option.value === value ? "text-blue-100" : "text-slate-500")}>{option.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SquareButton({ label, disabled, onClick, children }: { label: string; disabled: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button type="button" aria-label={label} title={label} disabled={disabled} onClick={onClick} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-white/42 text-base text-slate-600 ring-1 ring-white/60 transition-[transform,color,background-color] hover:bg-blue-50/75 hover:text-blue-700 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-35">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineButton({ label, disabled, active = false, onClick, children }: { label: string; disabled: boolean; active?: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"grid h-10 w-10 shrink-0 place-items-center rounded-full text-base transition-[transform,color,background-color,box-shadow] duration-150 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-35",
|
||||
active
|
||||
? "bg-blue-600 text-white shadow-md shadow-blue-700/25"
|
||||
: "bg-slate-100/52 text-slate-600 ring-1 ring-white/55 hover:bg-blue-50/80 hover:text-blue-700",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const sceneRoot = join(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"three-dimensional",
|
||||
"zjb",
|
||||
"v29",
|
||||
);
|
||||
|
||||
describe("ZJB three-dimensional runtime assets", () => {
|
||||
it("ships every model referenced by the production manifest", () => {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(sceneRoot, "manifest.json"), "utf8"),
|
||||
) as {
|
||||
packageId: string;
|
||||
renderRevision: number;
|
||||
assets: Array<{ file: string; sha256: string }>;
|
||||
networkModel: { file: string; metadata: string; sha256: string };
|
||||
sourceCatalogFile?: string;
|
||||
bindingsFile?: string;
|
||||
};
|
||||
|
||||
expect(manifest.packageId).toBe("zjb-web-v29");
|
||||
expect(manifest.renderRevision).toBe(29);
|
||||
expect(manifest.assets).toHaveLength(24);
|
||||
manifest.assets.forEach((asset) => {
|
||||
const assetPath = join(sceneRoot, asset.file);
|
||||
expect(existsSync(assetPath)).toBe(true);
|
||||
expect(createHash("sha256").update(readFileSync(assetPath)).digest("hex")).toBe(
|
||||
asset.sha256,
|
||||
);
|
||||
});
|
||||
expect(existsSync(join(sceneRoot, manifest.networkModel.file))).toBe(true);
|
||||
expect(existsSync(join(sceneRoot, manifest.networkModel.metadata))).toBe(true);
|
||||
expect(manifest.sourceCatalogFile).toBeUndefined();
|
||||
expect(manifest.bindingsFile).toBeUndefined();
|
||||
|
||||
const networkModel = readFileSync(join(sceneRoot, manifest.networkModel.file));
|
||||
expect(createHash("sha256").update(networkModel).digest("hex")).toBe(
|
||||
manifest.networkModel.sha256,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes the offline runtime modules and third-party licenses", () => {
|
||||
[
|
||||
"preview.html",
|
||||
"preview.mjs",
|
||||
"appearance.mjs",
|
||||
"asset-inspector.mjs",
|
||||
"camera-navigation.mjs",
|
||||
"network-style.mjs",
|
||||
"integration.mjs",
|
||||
"render-effects.mjs",
|
||||
"vendor/meshopt_decoder.module.js",
|
||||
"vendor/meshoptimizer-LICENSE.md",
|
||||
"vendor/three/LICENSE",
|
||||
"vendor/three/three.module.js",
|
||||
"vendor/three/addons/loaders/GLTFLoader.js",
|
||||
].forEach((relativePath) => {
|
||||
expect(existsSync(join(sceneRoot, relativePath))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the embedded viewer on the versioned same-origin host protocol", () => {
|
||||
const preview = readFileSync(join(sceneRoot, "preview.mjs"), "utf8");
|
||||
const html = readFileSync(join(sceneRoot, "preview.html"), "utf8");
|
||||
const inspector = readFileSync(
|
||||
join(sceneRoot, "asset-inspector.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(preview).toContain("tjwater:zjb-scene");
|
||||
expect(preview).toContain("HOST_VERSION=2");
|
||||
expect(preview).toContain("event.origin===window.location.origin");
|
||||
expect(preview).toContain("event.source===window.parent");
|
||||
expect(preview).toContain("postHost('ready'");
|
||||
expect(preview).toContain("data.type==='results'");
|
||||
expect(preview).toContain("data.type==='clear-results'");
|
||||
expect(preview).toContain("data.type!=='command'");
|
||||
expect(preview).toContain("postHost('selection-changed'");
|
||||
expect(preview).not.toContain("window.zjbNetwork");
|
||||
expect(preview).not.toContain("resultsFile");
|
||||
expect(html).not.toContain("绑定运行结果");
|
||||
expect(html).not.toContain("resultsFile");
|
||||
expect(inspector).not.toContain("document.getElementById");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
buildSceneFrame,
|
||||
fetchPressureDevices,
|
||||
fetchSceneFrame,
|
||||
supportsThreeDimensionalScene,
|
||||
type SceneModelIndex,
|
||||
} from "./sceneData";
|
||||
|
||||
const mockApiFetch = jest.fn();
|
||||
|
||||
jest.mock("@/lib/apiFetch", () => ({
|
||||
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
|
||||
}));
|
||||
|
||||
const model: SceneModelIndex = {
|
||||
modelId: "zjb-water-network-v23",
|
||||
nodeIds: new Set(["J-1", "J-2"]),
|
||||
linkIds: new Set(["P-1", "P-2"]),
|
||||
};
|
||||
|
||||
describe("buildSceneFrame", () => {
|
||||
beforeEach(() => {
|
||||
mockApiFetch.mockReset();
|
||||
});
|
||||
|
||||
it("enables the scene only for the normalized ZJB project code", () => {
|
||||
expect(supportsThreeDimensionalScene(" ZJB ")).toBe(true);
|
||||
expect(supportsThreeDimensionalScene("tjwater_v2")).toBe(false);
|
||||
expect(supportsThreeDimensionalScene(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("combines one complete simulation frame and overrides pressure with cleaned SCADA", () => {
|
||||
const frame = buildSceneFrame({
|
||||
queryTime: new Date("2026-09-11T03:00:00.000Z"),
|
||||
model,
|
||||
nodeRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
node_id: "J-1",
|
||||
pressure: 31.2,
|
||||
},
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
node_id: "J-2",
|
||||
pressure: 29.7,
|
||||
},
|
||||
],
|
||||
linkRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
link_id: "P-1",
|
||||
velocity: 0.82,
|
||||
flow: -18.4,
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
pressureDevices: [
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
||||
],
|
||||
scadaRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:01.000Z",
|
||||
device_id: "S-1",
|
||||
monitored_value: 30.8,
|
||||
cleaned_value: 30.9,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(frame.resultTime).toBe("2026-09-11T03:00:00.000Z");
|
||||
expect(frame.payload?.nodes["J-1"]).toEqual({
|
||||
pressure: 30.9,
|
||||
source: "scada",
|
||||
deviceId: "S-1",
|
||||
simulationPressure: 31.2,
|
||||
});
|
||||
expect(frame.payload?.links["P-1"]).toEqual({
|
||||
velocity: 0.82,
|
||||
flow: -18.4,
|
||||
direction: -1,
|
||||
status: "open",
|
||||
});
|
||||
expect(frame.stats).toMatchObject({
|
||||
simulationNodes: 2,
|
||||
simulationLinks: 1,
|
||||
scadaOverrides: 1,
|
||||
missingNodes: 0,
|
||||
missingLinks: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses monitored SCADA when cleaned data is absent", () => {
|
||||
const frame = buildSceneFrame({
|
||||
queryTime: new Date("2026-09-11T03:00:00.000Z"),
|
||||
model,
|
||||
nodeRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
node_id: "J-1",
|
||||
pressure: 31.2,
|
||||
},
|
||||
],
|
||||
linkRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
link_id: "P-1",
|
||||
velocity: 0.2,
|
||||
flow: 2,
|
||||
status: 0,
|
||||
},
|
||||
],
|
||||
pressureDevices: [
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
||||
],
|
||||
scadaRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
device_id: "S-1",
|
||||
monitored_value: 30.8,
|
||||
cleaned_value: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(frame.payload?.nodes["J-1"]?.pressure).toBe(30.8);
|
||||
expect(frame.payload?.links["P-1"]?.status).toBe("closed");
|
||||
});
|
||||
|
||||
it("keeps the selected time and returns no payload when a complete frame is absent", () => {
|
||||
const frame = buildSceneFrame({
|
||||
queryTime: new Date("2026-09-11T03:00:00.000Z"),
|
||||
model,
|
||||
nodeRows: [
|
||||
{
|
||||
time: "2026-09-11T02:45:00.000Z",
|
||||
node_id: "J-1",
|
||||
pressure: 31.2,
|
||||
},
|
||||
],
|
||||
linkRows: [],
|
||||
pressureDevices: [],
|
||||
scadaRows: [],
|
||||
});
|
||||
|
||||
expect(frame.selectedTime).toBe("2026-09-11T03:00:00.000Z");
|
||||
expect(frame.resultTime).toBeNull();
|
||||
expect(frame.payload).toBeNull();
|
||||
expect(frame.stats.missingNodes).toBe(2);
|
||||
expect(frame.stats.missingLinks).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores IDs outside the scene model instead of rejecting the frame", () => {
|
||||
const frame = buildSceneFrame({
|
||||
queryTime: new Date("2026-09-11T03:00:00.000Z"),
|
||||
model,
|
||||
nodeRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
node_id: "J-UNKNOWN",
|
||||
pressure: 31.2,
|
||||
},
|
||||
],
|
||||
linkRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
link_id: "P-UNKNOWN",
|
||||
velocity: 0.2,
|
||||
flow: 2,
|
||||
status: 2,
|
||||
},
|
||||
],
|
||||
pressureDevices: [],
|
||||
scadaRows: [],
|
||||
});
|
||||
|
||||
expect(frame.payload?.nodes).toEqual({});
|
||||
expect(frame.payload?.links).toEqual({});
|
||||
expect(frame.stats.ignoredElements).toBe(2);
|
||||
});
|
||||
|
||||
it("reads pressure mappings from the paginated SCADA device response", async () => {
|
||||
mockApiFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
items: [
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: " J-1 " },
|
||||
{ device_id: "S-2", device_type: "flow", node_id: "J-2" },
|
||||
{ device_id: "S-3", device_type: "pressure", node_id: null },
|
||||
],
|
||||
total: 3,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
const devices = await fetchPressureDevices(new AbortController().signal);
|
||||
|
||||
expect(devices).toEqual([
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
||||
]);
|
||||
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/scada-devices?limit=1000&offset=0"),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps simulation results when SCADA readings are temporarily unavailable", async () => {
|
||||
mockApiFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
node_id: "J-1",
|
||||
pressure: 31.2,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
link_id: "P-1",
|
||||
velocity: 0.8,
|
||||
flow: 12,
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => "SCADA service unavailable",
|
||||
});
|
||||
|
||||
const frame = await fetchSceneFrame({
|
||||
queryTime: new Date("2026-09-11T03:00:00.000Z"),
|
||||
model,
|
||||
pressureDevices: [
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
||||
],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(frame.payload?.nodes["J-1"]).toEqual({
|
||||
pressure: 31.2,
|
||||
source: "simulation",
|
||||
});
|
||||
expect(frame.stats.scadaOverrides).toBe(0);
|
||||
expect(frame.warnings).toEqual([
|
||||
"SCADA 数据暂不可用,当前仅显示模拟结果。",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { config } from "@config/config";
|
||||
|
||||
export const ZJB_PROJECT_CODE = "zjb";
|
||||
export const ZJB_SCENE_MODEL_ID = "zjb-water-network-v23";
|
||||
export const SCENE_FRAME_TOLERANCE_MS = 2_000;
|
||||
|
||||
export const supportsThreeDimensionalScene = (projectCode?: string | null) =>
|
||||
projectCode?.trim().toLowerCase() === ZJB_PROJECT_CODE;
|
||||
|
||||
export type SceneModelIndex = {
|
||||
modelId: string;
|
||||
nodeIds: ReadonlySet<string>;
|
||||
linkIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export type SceneNodeResult = {
|
||||
pressure: number;
|
||||
source: "simulation" | "scada";
|
||||
deviceId?: string;
|
||||
simulationPressure?: number;
|
||||
};
|
||||
|
||||
export type SceneLinkResult = {
|
||||
velocity?: number;
|
||||
flow?: number;
|
||||
status?: "open" | "closed" | "active";
|
||||
direction?: -1 | 0 | 1;
|
||||
};
|
||||
|
||||
export type SceneResultsPayload = {
|
||||
modelId: string;
|
||||
units: {
|
||||
velocity: "m/s";
|
||||
pressure: "mH2O";
|
||||
flow: "L/s";
|
||||
};
|
||||
timestamp: string;
|
||||
nodes: Record<string, SceneNodeResult>;
|
||||
links: Record<string, SceneLinkResult>;
|
||||
};
|
||||
|
||||
export type SceneFrameStats = {
|
||||
simulationNodes: number;
|
||||
simulationLinks: number;
|
||||
scadaOverrides: number;
|
||||
missingNodes: number;
|
||||
missingLinks: number;
|
||||
ignoredElements: number;
|
||||
};
|
||||
|
||||
export type SceneFrame = {
|
||||
selectedTime: string;
|
||||
resultTime: string | null;
|
||||
payload: SceneResultsPayload | null;
|
||||
stats: SceneFrameStats;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type PressureDevice = {
|
||||
device_id: string;
|
||||
device_type: string;
|
||||
node_id: string;
|
||||
};
|
||||
|
||||
type RawPressureDevice = Omit<PressureDevice, "node_id"> & {
|
||||
node_id?: string | null;
|
||||
};
|
||||
|
||||
type Page<T> = {
|
||||
items: T[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
type RealtimeNodeRow = {
|
||||
time: string;
|
||||
node_id: string;
|
||||
pressure: number | null;
|
||||
};
|
||||
|
||||
type RealtimeLinkRow = {
|
||||
time: string;
|
||||
link_id: string;
|
||||
velocity: number | null;
|
||||
flow: number | null;
|
||||
status: number | null;
|
||||
};
|
||||
|
||||
type ScadaReadingRow = {
|
||||
time: string;
|
||||
device_id: string;
|
||||
monitored_value: number | null;
|
||||
cleaned_value: number | null;
|
||||
};
|
||||
|
||||
const emptyStats = (model: SceneModelIndex): SceneFrameStats => ({
|
||||
simulationNodes: 0,
|
||||
simulationLinks: 0,
|
||||
scadaOverrides: 0,
|
||||
missingNodes: model.nodeIds.size,
|
||||
missingLinks: model.linkIds.size,
|
||||
ignoredElements: 0,
|
||||
});
|
||||
|
||||
const toTimestamp = (value: string) => {
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
};
|
||||
|
||||
const isFiniteNumber = (value: unknown): value is number =>
|
||||
typeof value === "number" && Number.isFinite(value);
|
||||
|
||||
const frameWindow = (queryTime: Date) => ({
|
||||
startTime: new Date(queryTime.getTime() - SCENE_FRAME_TOLERANCE_MS),
|
||||
endTime: new Date(queryTime.getTime() + SCENE_FRAME_TOLERANCE_MS),
|
||||
});
|
||||
|
||||
const rowsAtTime = <T extends { time: string }>(rows: T[], timestamp: number) =>
|
||||
rows.filter((row) => toTimestamp(row.time) === timestamp);
|
||||
|
||||
const resolveCommonFrameTime = (
|
||||
queryTime: Date,
|
||||
nodeRows: RealtimeNodeRow[],
|
||||
linkRows: RealtimeLinkRow[],
|
||||
) => {
|
||||
const target = queryTime.getTime();
|
||||
const nodeTimes = new Set(
|
||||
nodeRows
|
||||
.map((row) => toTimestamp(row.time))
|
||||
.filter((time): time is number => time !== null),
|
||||
);
|
||||
const commonTimes = Array.from(
|
||||
new Set(
|
||||
linkRows
|
||||
.map((row) => toTimestamp(row.time))
|
||||
.filter(
|
||||
(time): time is number =>
|
||||
time !== null &&
|
||||
nodeTimes.has(time) &&
|
||||
Math.abs(time - target) <= SCENE_FRAME_TOLERANCE_MS,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (commonTimes.length === 0) return null;
|
||||
return commonTimes.sort(
|
||||
(left, right) => Math.abs(left - target) - Math.abs(right - target),
|
||||
)[0];
|
||||
};
|
||||
|
||||
const normalizeLinkStatus = (
|
||||
value: number | null,
|
||||
): SceneLinkResult["status"] => {
|
||||
if (!isFiniteNumber(value)) return undefined;
|
||||
if (value <= 0) return "closed";
|
||||
if (value === 1) return "open";
|
||||
return "active";
|
||||
};
|
||||
|
||||
const nearestScadaReadings = (
|
||||
rows: ScadaReadingRow[],
|
||||
resultTimestamp: number,
|
||||
) => {
|
||||
const byDevice = new Map<string, ScadaReadingRow>();
|
||||
rows.forEach((row) => {
|
||||
const timestamp = toTimestamp(row.time);
|
||||
if (
|
||||
timestamp === null ||
|
||||
Math.abs(timestamp - resultTimestamp) > SCENE_FRAME_TOLERANCE_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const existing = byDevice.get(row.device_id);
|
||||
const existingTimestamp = existing ? toTimestamp(existing.time) : null;
|
||||
if (
|
||||
existingTimestamp === null ||
|
||||
Math.abs(timestamp - resultTimestamp) <
|
||||
Math.abs(existingTimestamp - resultTimestamp)
|
||||
) {
|
||||
byDevice.set(row.device_id, row);
|
||||
}
|
||||
});
|
||||
return byDevice;
|
||||
};
|
||||
|
||||
export const buildSceneFrame = ({
|
||||
queryTime,
|
||||
model,
|
||||
nodeRows,
|
||||
linkRows,
|
||||
pressureDevices,
|
||||
scadaRows,
|
||||
}: {
|
||||
queryTime: Date;
|
||||
model: SceneModelIndex;
|
||||
nodeRows: RealtimeNodeRow[];
|
||||
linkRows: RealtimeLinkRow[];
|
||||
pressureDevices: PressureDevice[];
|
||||
scadaRows: ScadaReadingRow[];
|
||||
}): SceneFrame => {
|
||||
const selectedTime = queryTime.toISOString();
|
||||
const frameTime = resolveCommonFrameTime(queryTime, nodeRows, linkRows);
|
||||
if (frameTime === null) {
|
||||
return {
|
||||
selectedTime,
|
||||
resultTime: null,
|
||||
payload: null,
|
||||
stats: emptyStats(model),
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const nodes: Record<string, SceneNodeResult> = {};
|
||||
const links: Record<string, SceneLinkResult> = {};
|
||||
let ignoredElements = 0;
|
||||
|
||||
rowsAtTime(nodeRows, frameTime).forEach((row) => {
|
||||
const id = String(row.node_id);
|
||||
if (!model.nodeIds.has(id)) {
|
||||
ignoredElements += 1;
|
||||
return;
|
||||
}
|
||||
if (isFiniteNumber(row.pressure)) {
|
||||
nodes[id] = { pressure: row.pressure, source: "simulation" };
|
||||
}
|
||||
});
|
||||
|
||||
rowsAtTime(linkRows, frameTime).forEach((row) => {
|
||||
const id = String(row.link_id);
|
||||
if (!model.linkIds.has(id)) {
|
||||
ignoredElements += 1;
|
||||
return;
|
||||
}
|
||||
const result: SceneLinkResult = {};
|
||||
if (isFiniteNumber(row.velocity)) result.velocity = row.velocity;
|
||||
if (isFiniteNumber(row.flow)) {
|
||||
result.flow = row.flow;
|
||||
result.direction = Math.sign(row.flow) as -1 | 0 | 1;
|
||||
}
|
||||
const status = normalizeLinkStatus(row.status);
|
||||
if (status) result.status = status;
|
||||
links[id] = result;
|
||||
});
|
||||
|
||||
const simulationNodeCount = Object.keys(nodes).length;
|
||||
const readingsByDevice = nearestScadaReadings(scadaRows, frameTime);
|
||||
let scadaOverrides = 0;
|
||||
pressureDevices.forEach((device) => {
|
||||
if (!model.nodeIds.has(device.node_id)) {
|
||||
ignoredElements += 1;
|
||||
return;
|
||||
}
|
||||
const reading = readingsByDevice.get(device.device_id);
|
||||
if (!reading) return;
|
||||
const value = isFiniteNumber(reading.cleaned_value)
|
||||
? reading.cleaned_value
|
||||
: reading.monitored_value;
|
||||
if (!isFiniteNumber(value)) return;
|
||||
const simulationPressure = nodes[device.node_id]?.pressure;
|
||||
nodes[device.node_id] = {
|
||||
pressure: value,
|
||||
source: "scada",
|
||||
deviceId: device.device_id,
|
||||
...(simulationPressure === undefined ? {} : { simulationPressure }),
|
||||
};
|
||||
scadaOverrides += 1;
|
||||
});
|
||||
|
||||
const resultTime = new Date(frameTime).toISOString();
|
||||
return {
|
||||
selectedTime,
|
||||
resultTime,
|
||||
payload: {
|
||||
modelId: model.modelId,
|
||||
units: { velocity: "m/s", pressure: "mH2O", flow: "L/s" },
|
||||
timestamp: resultTime,
|
||||
nodes,
|
||||
links,
|
||||
},
|
||||
stats: {
|
||||
simulationNodes: simulationNodeCount,
|
||||
simulationLinks: Object.keys(links).length,
|
||||
scadaOverrides,
|
||||
missingNodes: Math.max(0, model.nodeIds.size - Object.keys(nodes).length),
|
||||
missingLinks: Math.max(0, model.linkIds.size - Object.keys(links).length),
|
||||
ignoredElements,
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
};
|
||||
|
||||
const readJson = async <T>(url: string, signal: AbortSignal): Promise<T> => {
|
||||
const response = await apiFetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(detail || `请求失败:HTTP ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
export const fetchPressureDevices = async (signal: AbortSignal) => {
|
||||
const page = await readJson<Page<RawPressureDevice>>(
|
||||
`${config.BACKEND_URL}/api/v1/scada-devices?limit=1000&offset=0`,
|
||||
signal,
|
||||
);
|
||||
return page.items
|
||||
.filter(
|
||||
(device): device is PressureDevice =>
|
||||
device.device_type?.trim().toLowerCase() === "pressure" &&
|
||||
typeof device.node_id === "string" &&
|
||||
device.node_id.trim().length > 0,
|
||||
)
|
||||
.map((device) => ({ ...device, node_id: device.node_id.trim() }));
|
||||
};
|
||||
|
||||
export const fetchSceneFrame = async ({
|
||||
queryTime,
|
||||
model,
|
||||
pressureDevices,
|
||||
signal,
|
||||
}: {
|
||||
queryTime: Date;
|
||||
model: SceneModelIndex;
|
||||
pressureDevices: PressureDevice[];
|
||||
signal: AbortSignal;
|
||||
}) => {
|
||||
const { startTime, endTime } = frameWindow(queryTime);
|
||||
const range = new URLSearchParams({
|
||||
start_time: startTime.toISOString(),
|
||||
end_time: endTime.toISOString(),
|
||||
});
|
||||
const scadaRange = new URLSearchParams(range);
|
||||
scadaRange.set(
|
||||
"device_ids",
|
||||
pressureDevices.map((device) => device.device_id).join(","),
|
||||
);
|
||||
|
||||
const [nodeRows, linkRows] = await Promise.all([
|
||||
readJson<RealtimeNodeRow[]>(
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/nodes?${range}`,
|
||||
signal,
|
||||
),
|
||||
readJson<RealtimeLinkRow[]>(
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/links?${range}`,
|
||||
signal,
|
||||
),
|
||||
]);
|
||||
|
||||
let scadaRows: ScadaReadingRow[] = [];
|
||||
const warnings: string[] = [];
|
||||
if (pressureDevices.length > 0) {
|
||||
try {
|
||||
const rows = await readJson<ScadaReadingRow[]>(
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/scada-readings?${scadaRange}`,
|
||||
signal,
|
||||
);
|
||||
scadaRows = Array.isArray(rows) ? rows : [];
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error;
|
||||
warnings.push("SCADA 数据暂不可用,当前仅显示模拟结果。");
|
||||
}
|
||||
}
|
||||
|
||||
const frame = buildSceneFrame({
|
||||
queryTime,
|
||||
model,
|
||||
nodeRows: Array.isArray(nodeRows) ? nodeRows : [],
|
||||
linkRows: Array.isArray(linkRows) ? linkRows : [],
|
||||
pressureDevices,
|
||||
scadaRows,
|
||||
});
|
||||
return { ...frame, warnings };
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
isSceneRuntimeMessage,
|
||||
SCENE_CHANNEL,
|
||||
SCENE_PROTOCOL_VERSION,
|
||||
} from "./sceneProtocol";
|
||||
|
||||
describe("scene protocol", () => {
|
||||
it("accepts the versioned same-origin message shape", () => {
|
||||
expect(
|
||||
isSceneRuntimeMessage({
|
||||
channel: SCENE_CHANNEL,
|
||||
version: SCENE_PROTOCOL_VERSION,
|
||||
type: "results-cleared",
|
||||
projectCode: "zjb",
|
||||
modelId: "zjb-water-network-v23",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects stale and incomplete messages", () => {
|
||||
expect(
|
||||
isSceneRuntimeMessage({
|
||||
channel: SCENE_CHANNEL,
|
||||
version: 1,
|
||||
type: "ready",
|
||||
projectCode: "zjb",
|
||||
modelId: "zjb-water-network-v23",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isSceneRuntimeMessage({ type: "ready" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { SceneResultsPayload } from "./sceneData";
|
||||
|
||||
export const SCENE_CHANNEL = "tjwater:zjb-scene";
|
||||
export const SCENE_PROTOCOL_VERSION = 2;
|
||||
|
||||
export type SceneMode =
|
||||
| "network"
|
||||
| "hydraulic"
|
||||
| "map"
|
||||
| "detail"
|
||||
| "pump"
|
||||
| "meters";
|
||||
|
||||
export type SceneDisplayMode = "global" | "coordinated";
|
||||
export type SceneMetricMode = "uniform" | "velocity" | "pressure" | "direction";
|
||||
export type SceneDirectionMode = "none" | "topology" | "results";
|
||||
export type SceneLightingPreset = "day" | "studio" | "evening";
|
||||
export type SceneQuality = "standard" | "high";
|
||||
|
||||
export type SceneNetworkStyle = {
|
||||
scale: number;
|
||||
mode: SceneMetricMode;
|
||||
color: string;
|
||||
missingColor: string;
|
||||
lowColor: string;
|
||||
highColor: string;
|
||||
opacity: number;
|
||||
roughness: number;
|
||||
metalness: number;
|
||||
nodes: boolean;
|
||||
direction: SceneDirectionMode;
|
||||
arrowColor: string;
|
||||
autoRange: boolean;
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
export type SceneAppearance = {
|
||||
preset: SceneLightingPreset;
|
||||
exposure: number;
|
||||
shadows: boolean;
|
||||
effects: boolean;
|
||||
quality: SceneQuality;
|
||||
};
|
||||
|
||||
export type SceneCameraView = {
|
||||
id: string;
|
||||
label: string;
|
||||
mode: SceneMode;
|
||||
note?: string;
|
||||
saved: boolean;
|
||||
};
|
||||
|
||||
export type SceneCameraState = {
|
||||
active: string | null;
|
||||
note: string;
|
||||
views: SceneCameraView[];
|
||||
};
|
||||
|
||||
export type SceneStyleSummary = {
|
||||
mode?: SceneMetricMode;
|
||||
min?: number;
|
||||
max?: number;
|
||||
dataLinks?: number;
|
||||
totalLinks?: number;
|
||||
arrows?: number;
|
||||
hasResults?: boolean;
|
||||
resultTime?: string | null;
|
||||
scale?: number;
|
||||
};
|
||||
|
||||
export type SceneRuntimeState = {
|
||||
mode: SceneMode;
|
||||
status: string;
|
||||
contextVisible: boolean;
|
||||
roofVisible: boolean;
|
||||
displayMode: SceneDisplayMode;
|
||||
style: SceneNetworkStyle;
|
||||
styleSummary: SceneStyleSummary;
|
||||
appearance: SceneAppearance;
|
||||
camera: SceneCameraState;
|
||||
};
|
||||
|
||||
export type SceneAssetField = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SceneAssetSection = {
|
||||
title: string;
|
||||
fields: SceneAssetField[];
|
||||
};
|
||||
|
||||
export type SceneAssetNeighbor = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type SceneAssetSelection = {
|
||||
assetId: string;
|
||||
elementId: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
sections: SceneAssetSection[];
|
||||
neighbors: SceneAssetNeighbor[];
|
||||
};
|
||||
|
||||
export type SceneCommand =
|
||||
| { name: "set-mode"; mode: SceneMode }
|
||||
| { name: "visit-camera"; viewId: string }
|
||||
| { name: "save-camera"; label: string }
|
||||
| { name: "remove-camera"; viewId: string }
|
||||
| { name: "set-style"; patch: Partial<SceneNetworkStyle> }
|
||||
| { name: "reset-style" }
|
||||
| { name: "set-display-mode"; mode: SceneDisplayMode }
|
||||
| { name: "set-appearance"; patch: Partial<SceneAppearance> }
|
||||
| { name: "toggle-context" }
|
||||
| { name: "toggle-roof" }
|
||||
| { name: "fit-view" }
|
||||
| { name: "select-asset"; assetId: string }
|
||||
| { name: "locate-selection" }
|
||||
| { name: "clear-selection" };
|
||||
|
||||
type HostMessageBase = {
|
||||
channel: typeof SCENE_CHANNEL;
|
||||
version: typeof SCENE_PROTOCOL_VERSION;
|
||||
projectCode: string;
|
||||
modelId: string;
|
||||
};
|
||||
|
||||
export type SceneHostMessage = HostMessageBase &
|
||||
(
|
||||
| { type: "results"; payload: SceneResultsPayload }
|
||||
| { type: "clear-results" }
|
||||
| { type: "command"; command: SceneCommand }
|
||||
);
|
||||
|
||||
export type SceneRuntimeMessage = HostMessageBase &
|
||||
(
|
||||
| {
|
||||
type: "ready";
|
||||
nodeIds: string[];
|
||||
linkIds: string[];
|
||||
state: SceneRuntimeState;
|
||||
}
|
||||
| { type: "scene-state"; state: SceneRuntimeState }
|
||||
| { type: "selection-changed"; selection: SceneAssetSelection | null }
|
||||
| { type: "results-applied"; summary: SceneStyleSummary }
|
||||
| { type: "results-cleared" }
|
||||
| { type: "error"; message: string }
|
||||
);
|
||||
|
||||
export const isSceneRuntimeMessage = (
|
||||
input: unknown,
|
||||
): input is SceneRuntimeMessage => {
|
||||
if (!input || typeof input !== "object") return false;
|
||||
const message = input as Partial<SceneRuntimeMessage>;
|
||||
return (
|
||||
message.channel === SCENE_CHANNEL &&
|
||||
message.version === SCENE_PROTOCOL_VERSION &&
|
||||
typeof message.projectCode === "string" &&
|
||||
typeof message.modelId === "string" &&
|
||||
typeof message.type === "string"
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user