完善 XML 通用仿真与结果查看

This commit is contained in:
ljz committed 2026-07-21 13:42:55 +08:00
1 parent 104d41d294
commit f1256a121d
36 files changed
+6666 -196

No files matched your search

+729 -94
View File
File diff suppressed because it is too large. Load diff
+932
View File
@@ -0,0 +1,932 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type {
DragEvent,
PointerEvent as ReactPointerEvent,
} from "react";
import { X } from "lucide-react";
import {
Background,
ConnectionLineType,
ConnectionMode,
Controls,
MiniMap,
ReactFlow,
type Edge,
type Node,
type NodeTypes,
} from "@xyflow/react";
import {
WorkspaceViewTabs,
type WorkspaceView,
} from "./WorkspaceViewTabs";
type ResultPortDefinition = {
name: string;
kind: string;
domain: string;
nominalRole: string;
positiveFlowDirection?: string;
side: "left" | "right";
};
type ResultNodeData = {
label: string;
componentType: string;
modelType: string;
ports: ResultPortDefinition[];
parameters: Record<string, number | string>;
parameterUnits?: Record<string, string>;
rotation: 0 | 90 | 180 | 270;
mirrored: boolean;
};
type ResultProjectNode = {
id: string;
type: "simulationComponent";
position: {
x: number;
y: number;
};
data: ResultNodeData;
};
type ResultProjectEdge = {
id: string;
source: string;
target: string;
sourceHandle: string | null;
targetHandle: string | null;
};
type ResultSimulationConfig = {
t_start: number;
t_stop: number;
step: number;
max_step: number;
method: string;
};
type ResultPayload = {
success: boolean;
message: string;
final: Record<string, number>;
series: Record<string, number[]>;
diagnostics: {
pressureFlow: {
solveCount: number;
maxScaledResidual: number;
maxEvaluationsPerSolve: number;
};
stream: {
maxIterationsPerSolve: number;
};
stateCount: number;
sampleCount: number;
};
};
export type SimulationResultsSnapshot = {
id: string;
createdAt: string;
project: {
name: string;
nodes: ResultProjectNode[];
edges: ResultProjectEdge[];
simulation: ResultSimulationConfig;
};
result: ResultPayload;
};
type SimulationResultsViewProps = {
snapshot: SimulationResultsSnapshot;
nodeTypes: NodeTypes;
onViewChange: (view: WorkspaceView) => void;
};
type EmptySimulationResultsViewProps = {
onViewChange: (view: WorkspaceView) => void;
};
type ResultVariable = {
key: string;
label: string;
unit: string;
group: "component" | "port";
};
type ChartWindow = {
id: string;
variableKey: string;
x: number;
y: number;
zIndex: number;
};
type WindowDragState = {
windowId: string;
pointerId: number;
offsetX: number;
offsetY: number;
};
const RESULT_VARIABLE_MIME = "application/x-system-simulation-variable";
const RESULT_LAYOUT_KEY_PREFIX = "system-simulation-flow:result-layout:";
const CHART_WINDOW_WIDTH = 420;
const CHART_WINDOW_HEIGHT = 260;
const CHART_COLORS = [
"#1d6fb8",
"#c2413b",
"#2f7d55",
"#b36b16",
"#6b5ca5",
"#087f8c",
"#9a4f76",
];
const variableDefinitions: Record<string, { label: string; unit: string }> = {
p: { label: "压力", unit: "Pa" },
T: { label: "温度", unit: "K" },
m: { label: "质量", unit: "kg" },
U: { label: "内能", unit: "J" },
rho: { label: "密度", unit: "kg/m³" },
u: { label: "比内能", unit: "J/kg" },
h: { label: "比焓", unit: "J/kg" },
m_flow: { label: "质量流量", unit: "kg/s" },
h_outflow: { label: "流出比焓", unit: "J/kg" },
};
export function SimulationResultsView({
snapshot,
nodeTypes,
onViewChange,
}: SimulationResultsViewProps) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(
snapshot.project.nodes[0]?.id ?? null,
);
const [chartWindows, setChartWindows] = useState<ChartWindow[]>(() =>
loadChartWindows(snapshot.id),
);
const workspaceRef = useRef<HTMLDivElement>(null);
const dragStateRef = useRef<WindowDragState | null>(null);
const nextWindowIdRef = useRef(nextChartWindowNumber(chartWindows));
const topZIndexRef = useRef(
Math.max(1, ...chartWindows.map((window) => window.zIndex)),
);
useEffect(() => {
storeChartWindows(snapshot.id, chartWindows);
}, [chartWindows, snapshot.id]);
const selectedNode =
snapshot.project.nodes.find((node) => node.id === selectedNodeId) ?? null;
const readOnlyNodes = useMemo<Node<ResultNodeData, "simulationComponent">[]>(
() =>
snapshot.project.nodes.map((node) => ({
...node,
className: "results-readonly-node",
connectable: false,
draggable: false,
focusable: true,
selectable: true,
selected: node.id === selectedNodeId,
})),
[selectedNodeId, snapshot.project.nodes],
);
const readOnlyEdges = useMemo<Edge[]>(
() =>
snapshot.project.edges.map((edge) => ({
...edge,
focusable: false,
selectable: false,
type: "step",
})),
[snapshot.project.edges],
);
const selectedVariables = useMemo(
() =>
selectedNode
? resultVariablesForNode(
snapshot.result.series,
selectedNode.id,
selectedNode.data.ports,
)
: [],
[selectedNode, snapshot.result.series],
);
const componentVariables = selectedVariables.filter(
(variable) => variable.group === "component",
);
const portVariables = selectedVariables.filter(
(variable) => variable.group === "port",
);
const focusChartWindow = (windowId: string) => {
const zIndex = ++topZIndexRef.current;
setChartWindows((current) =>
current.map((window) =>
window.id === windowId ? { ...window, zIndex } : window,
),
);
};
const addChartWindow = (
variableKey: string,
requestedPosition?: { x: number; y: number },
) => {
const workspace = workspaceRef.current;
if (!workspace) {
return;
}
const existing = chartWindows.find(
(window) => window.variableKey === variableKey,
);
if (existing) {
focusChartWindow(existing.id);
return;
}
const bounds = workspace.getBoundingClientRect();
const index = chartWindows.length;
const fallback = {
x: 18 + (index % 3) * 26,
y: 18 + (index % 4) * 26,
};
const position = requestedPosition ?? fallback;
const zIndex = ++topZIndexRef.current;
setChartWindows((current) => [
...current,
{
id: `chart-${nextWindowIdRef.current++}`,
variableKey,
x: clampWindowCoordinate(
position.x,
bounds.width,
CHART_WINDOW_WIDTH,
),
y: clampWindowCoordinate(
position.y,
bounds.height,
CHART_WINDOW_HEIGHT,
),
zIndex,
},
]);
};
const handleVariableDragStart = (
event: DragEvent<HTMLButtonElement>,
variableKey: string,
) => {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(RESULT_VARIABLE_MIME, variableKey);
};
const handleWorkspaceDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const variableKey = event.dataTransfer.getData(RESULT_VARIABLE_MIME);
const workspace = workspaceRef.current;
if (!variableKey || !workspace) {
return;
}
const bounds = workspace.getBoundingClientRect();
addChartWindow(variableKey, {
x: event.clientX - bounds.left - CHART_WINDOW_WIDTH / 2,
y: event.clientY - bounds.top - 34,
});
};
const startWindowDrag = (
event: ReactPointerEvent<HTMLDivElement>,
chartWindow: ChartWindow,
) => {
const windowElement = event.currentTarget.closest(".result-chart-window");
if (!(windowElement instanceof HTMLElement)) {
return;
}
const bounds = windowElement.getBoundingClientRect();
dragStateRef.current = {
windowId: chartWindow.id,
pointerId: event.pointerId,
offsetX: event.clientX - bounds.left,
offsetY: event.clientY - bounds.top,
};
event.currentTarget.setPointerCapture(event.pointerId);
focusChartWindow(chartWindow.id);
};
const moveChartWindow = (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
const workspace = workspaceRef.current;
if (
!dragState ||
dragState.pointerId !== event.pointerId ||
!workspace
) {
return;
}
const bounds = workspace.getBoundingClientRect();
const x = clampWindowCoordinate(
event.clientX - bounds.left - dragState.offsetX,
bounds.width,
CHART_WINDOW_WIDTH,
);
const y = clampWindowCoordinate(
event.clientY - bounds.top - dragState.offsetY,
bounds.height,
CHART_WINDOW_HEIGHT,
);
setChartWindows((current) =>
current.map((window) =>
window.id === dragState.windowId ? { ...window, x, y } : window,
),
);
};
const stopWindowDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (dragStateRef.current?.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
dragStateRef.current = null;
};
const removeChartWindow = (windowId: string) => {
setChartWindows((current) =>
current.filter((window) => window.id !== windowId),
);
};
return (
<div className="results-shell">
<header className="results-topbar">
<div>
<div className="product-name">System Simulation Flow</div>
<div className="product-meta">仿真结果查看</div>
</div>
<WorkspaceViewTabs
activeView="results"
hasResults
onViewChange={onViewChange}
/>
<div className="results-run-meta">
<strong>{snapshot.project.name}</strong>
<span>
{snapshot.result.diagnostics.sampleCount} 个采样点 ·{" "}
{formatResultTimestamp(snapshot.createdAt)}
</span>
</div>
</header>
<main className="results-workbench">
<section className="results-system-panel">
<div className="results-section-heading">
<strong>系统图</strong>
<span>{snapshot.project.nodes.length} 个组件</span>
</div>
<div className="results-system-canvas">
<ReactFlow
colorMode="light"
connectionLineType={ConnectionLineType.Step}
connectionMode={ConnectionMode.Loose}
deleteKeyCode={null}
edges={readOnlyEdges}
edgesFocusable={false}
edgesReconnectable={false}
elementsSelectable
fitView
nodeTypes={nodeTypes}
nodes={readOnlyNodes}
nodesConnectable={false}
nodesDraggable={false}
nodesFocusable
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
onPaneClick={() => setSelectedNodeId(null)}
panOnDrag
selectionOnDrag={false}
zoomOnDoubleClick={false}
>
<Background gap={18} />
<MiniMap pannable zoomable />
<Controls />
</ReactFlow>
</div>
</section>
<section className="results-chart-panel">
<div className="results-section-heading">
<strong>曲线窗口</strong>
<span>{chartWindows.length} 条曲线</span>
</div>
<div
className={`results-chart-workspace ${
chartWindows.length === 0 ? "empty" : ""
}`}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDrop={handleWorkspaceDrop}
ref={workspaceRef}
>
{chartWindows.map((chartWindow) => {
const variable = describeResultVariable(
chartWindow.variableKey,
selectedNodeIdForVariable(
chartWindow.variableKey,
snapshot.project.nodes,
),
snapshot.project.nodes,
);
const component = snapshot.project.nodes.find((node) =>
chartWindow.variableKey.startsWith(`${node.id}.`),
);
return (
<article
className="result-chart-window"
key={chartWindow.id}
onPointerDown={() => focusChartWindow(chartWindow.id)}
style={{
left: chartWindow.x,
top: chartWindow.y,
zIndex: chartWindow.zIndex,
}}
>
<div
className="result-chart-window-header"
onPointerCancel={stopWindowDrag}
onPointerDown={(event) =>
startWindowDrag(event, chartWindow)
}
onPointerMove={moveChartWindow}
onPointerUp={stopWindowDrag}
>
<div>
<strong>{variable.label}</strong>
<span>{component?.data.label ?? variable.componentId}</span>
</div>
<button
aria-label={`关闭 ${variable.label} 曲线`}
onClick={(event) => {
event.stopPropagation();
removeChartWindow(chartWindow.id);
}}
onPointerDown={(event) => event.stopPropagation()}
type="button"
>
<X aria-hidden="true" size={16} strokeWidth={1.9} />
</button>
</div>
<CurvePlot
color={colorForVariable(chartWindow.variableKey)}
label={variable.label}
time={snapshot.result.series.time ?? []}
unit={variable.unit}
values={
snapshot.result.series[chartWindow.variableKey] ?? []
}
/>
</article>
);
})}
{chartWindows.length === 0 ? (
<div className="results-workspace-empty">暂无曲线</div>
) : null}
</div>
</section>
<aside className="results-variable-panel">
<div className="results-section-heading">
<strong>结果变量</strong>
<span>{selectedVariables.length}</span>
</div>
{selectedNode ? (
<>
<div className="results-selected-component">
<strong>{selectedNode.data.label}</strong>
<span>{selectedNode.data.modelType}</span>
</div>
<VariableGroup
chartWindows={chartWindows}
onClick={addChartWindow}
onDragStart={handleVariableDragStart}
title="组件变量"
variables={componentVariables}
/>
<VariableGroup
chartWindows={chartWindows}
onClick={addChartWindow}
onDragStart={handleVariableDragStart}
title="端口变量"
variables={portVariables}
/>
</>
) : (
<div className="results-variable-empty">未选择组件</div>
)}
</aside>
</main>
</div>
);
}
export function EmptySimulationResultsView({
onViewChange,
}: EmptySimulationResultsViewProps) {
return (
<div className="results-shell">
<header className="results-topbar">
<div>
<div className="product-name">System Simulation Flow</div>
<div className="product-meta">仿真结果查看</div>
</div>
<WorkspaceViewTabs
activeView="results"
hasResults={false}
onViewChange={onViewChange}
/>
<div className="results-run-meta empty">
<span>尚无结果</span>
</div>
</header>
<main className="results-empty-view">
<strong>尚未生成仿真结果</strong>
<button onClick={() => onViewChange("modeling")} type="button">
进入建模
</button>
</main>
</div>
);
}
type VariableGroupProps = {
title: string;
variables: ResultVariable[];
chartWindows: ChartWindow[];
onClick: (variableKey: string) => void;
onDragStart: (
event: DragEvent<HTMLButtonElement>,
variableKey: string,
) => void;
};
function VariableGroup({
title,
variables,
chartWindows,
onClick,
onDragStart,
}: VariableGroupProps) {
if (variables.length === 0) {
return null;
}
return (
<section className="results-variable-group">
<div className="results-variable-group-title">{title}</div>
<div className="results-variable-list">
{variables.map((variable) => {
const visible = chartWindows.some(
(window) => window.variableKey === variable.key,
);
return (
<button
className={visible ? "visible" : ""}
draggable
key={variable.key}
onClick={() => onClick(variable.key)}
onDragStart={(event) => onDragStart(event, variable.key)}
type="button"
>
<span
className="result-variable-swatch"
style={{ backgroundColor: colorForVariable(variable.key) }}
/>
<span>
<strong>{variable.label}</strong>
<small>{variable.unit || "无量纲"}</small>
</span>
</button>
);
})}
</div>
</section>
);
}
type CurvePlotProps = {
time: number[];
values: number[];
label: string;
unit: string;
color: string;
};
function CurvePlot({ time, values, label, unit, color }: CurvePlotProps) {
const samples = time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(values[index]),
}))
.filter((sample) => Number.isFinite(sample.x) && Number.isFinite(sample.y));
if (samples.length < 2) {
return <div className="result-chart-empty">无可绘制数据</div>;
}
const width = 400;
const height = 190;
const margin = { top: 14, right: 16, bottom: 30, left: 58 };
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const xMin = Math.min(...samples.map((sample) => sample.x));
const xMax = Math.max(...samples.map((sample) => sample.x));
const rawYMin = Math.min(...samples.map((sample) => sample.y));
const rawYMax = Math.max(...samples.map((sample) => sample.y));
const yPadding =
rawYMin === rawYMax
? Math.max(Math.abs(rawYMin) * 0.05, 1)
: (rawYMax - rawYMin) * 0.08;
const yMin = rawYMin - yPadding;
const yMax = rawYMax + yPadding;
const xRange = Math.max(xMax - xMin, 1e-12);
const yRange = Math.max(yMax - yMin, 1e-12);
const xPosition = (value: number) =>
margin.left + ((value - xMin) / xRange) * plotWidth;
const yPosition = (value: number) =>
margin.top + (1 - (value - yMin) / yRange) * plotHeight;
const path = samples
.map(
(sample, index) =>
`${index === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition(
sample.y,
).toFixed(2)}`,
)
.join(" ");
return (
<div className="result-chart-body">
<svg
aria-label={`${label} 随时间变化曲线`}
role="img"
viewBox={`0 0 ${width} ${height}`}
>
<title>{label}</title>
{[0, 0.25, 0.5, 0.75, 1].map((fraction) => {
const y = margin.top + fraction * plotHeight;
return (
<line
className="result-chart-grid-line"
key={`y-${fraction}`}
x1={margin.left}
x2={width - margin.right}
y1={y}
y2={y}
/>
);
})}
{[0, 0.25, 0.5, 0.75, 1].map((fraction) => {
const x = margin.left + fraction * plotWidth;
return (
<line
className="result-chart-grid-line"
key={`x-${fraction}`}
x1={x}
x2={x}
y1={margin.top}
y2={height - margin.bottom}
/>
);
})}
<line
className="result-chart-axis"
x1={margin.left}
x2={margin.left}
y1={margin.top}
y2={height - margin.bottom}
/>
<line
className="result-chart-axis"
x1={margin.left}
x2={width - margin.right}
y1={height - margin.bottom}
y2={height - margin.bottom}
/>
<path
d={path}
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
<text
className="result-chart-label"
textAnchor="end"
x={margin.left - 6}
y={margin.top + 4}
>
{formatChartNumber(rawYMax)}
</text>
<text
className="result-chart-label"
textAnchor="end"
x={margin.left - 6}
y={height - margin.bottom}
>
{formatChartNumber(rawYMin)}
</text>
<text
className="result-chart-label"
textAnchor="start"
x={margin.left}
y={height - 10}
>
{formatChartNumber(xMin)}
</text>
<text
className="result-chart-label"
textAnchor="end"
x={width - margin.right}
y={height - 10}
>
{formatChartNumber(xMax)} s
</text>
<text
className="result-chart-unit"
textAnchor="start"
x={margin.left}
y={10}
>
{unit}
</text>
</svg>
</div>
);
}
function resultVariablesForNode(
series: Record<string, number[]>,
nodeId: string,
ports: ResultPortDefinition[],
) {
const portNames = new Set(ports.map((port) => port.name));
return Object.entries(series)
.filter(
([key, values]) =>
key !== "time" &&
key.startsWith(`${nodeId}.`) &&
Array.isArray(values) &&
values.length > 0,
)
.map(([key]) => {
const described = describeResultVariable(key, nodeId);
const firstSegment = key.slice(nodeId.length + 1).split(".")[0];
return {
key,
label: described.label,
unit: described.unit,
group: portNames.has(firstSegment) ? "port" : "component",
} satisfies ResultVariable;
})
.sort((first, second) => {
if (first.group !== second.group) {
return first.group === "component" ? -1 : 1;
}
return first.label.localeCompare(second.label, "zh-CN");
});
}
function describeResultVariable(
key: string,
componentId: string,
nodes?: ResultProjectNode[],
) {
const prefix = `${componentId}.`;
const suffix = key.startsWith(prefix) ? key.slice(prefix.length) : key;
const segments = suffix.split(".");
const variableName = segments.at(-1) ?? suffix;
const portName = segments.length > 1 ? segments.slice(0, -1).join(".") : null;
const definition = variableDefinitions[variableName] ?? {
label: variableName,
unit: "",
};
const component = nodes?.find((node) => node.id === componentId);
return {
componentId,
label: portName
? `${portName} · ${definition.label}`
: definition.label,
unit: definition.unit,
componentLabel: component?.data.label ?? componentId,
};
}
function selectedNodeIdForVariable(
variableKey: string,
nodes: ResultProjectNode[],
) {
return (
nodes.find((node) => variableKey.startsWith(`${node.id}.`))?.id ??
variableKey.split(".")[0]
);
}
function colorForVariable(key: string) {
let hash = 0;
for (const character of key) {
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
}
return CHART_COLORS[hash % CHART_COLORS.length];
}
function clampWindowCoordinate(
value: number,
containerSize: number,
windowSize: number,
) {
return Math.min(
Math.max(12, value),
Math.max(12, containerSize - windowSize - 12),
);
}
function loadChartWindows(snapshotId: string): ChartWindow[] {
try {
const raw = sessionStorage.getItem(`${RESULT_LAYOUT_KEY_PREFIX}${snapshotId}`);
if (!raw) {
return [];
}
const value = JSON.parse(raw) as unknown;
if (!Array.isArray(value)) {
return [];
}
return value.filter(isChartWindow);
} catch {
return [];
}
}
function nextChartWindowNumber(windows: ChartWindow[]) {
return (
Math.max(
0,
...windows.map((window) => {
const match = /^chart-(\d+)$/.exec(window.id);
return match ? Number(match[1]) : 0;
}),
) + 1
);
}
function storeChartWindows(snapshotId: string, windows: ChartWindow[]) {
try {
sessionStorage.setItem(
`${RESULT_LAYOUT_KEY_PREFIX}${snapshotId}`,
JSON.stringify(windows),
);
} catch {
// The current mounted result view still retains its layout in memory.
}
}
function isChartWindow(value: unknown): value is ChartWindow {
if (!value || typeof value !== "object") {
return false;
}
const window = value as Partial<ChartWindow>;
return (
typeof window.id === "string" &&
typeof window.variableKey === "string" &&
typeof window.x === "number" &&
Number.isFinite(window.x) &&
typeof window.y === "number" &&
Number.isFinite(window.y) &&
typeof window.zIndex === "number" &&
Number.isFinite(window.zIndex)
);
}
function formatResultTimestamp(value: string) {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime())
? value
: parsed.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function formatChartNumber(value: number) {
if (!Number.isFinite(value)) {
return "-";
}
if (
Math.abs(value) >= 100000 ||
(Math.abs(value) > 0 && Math.abs(value) < 0.001)
) {
return value.toExponential(2);
}
return value.toFixed(3).replace(/\.?0+$/, "");
}
+37
View File
@@ -0,0 +1,37 @@
export type WorkspaceView = "modeling" | "results";
type WorkspaceViewTabsProps = {
activeView: WorkspaceView;
hasResults: boolean;
onViewChange: (view: WorkspaceView) => void;
};
export function WorkspaceViewTabs({
activeView,
hasResults,
onViewChange,
}: WorkspaceViewTabsProps) {
return (
<div aria-label="工作区视图" className="workspace-view-tabs" role="tablist">
<button
aria-selected={activeView === "modeling"}
className={activeView === "modeling" ? "active" : ""}
onClick={() => onViewChange("modeling")}
role="tab"
type="button"
>
建模
</button>
<button
aria-selected={activeView === "results"}
className={activeView === "results" ? "active" : ""}
onClick={() => onViewChange("results")}
role="tab"
type="button"
>
结果
{hasResults ? <span aria-label="已有仿真结果" className="result-available-dot" /> : null}
</button>
</div>
);
}
+522 -19
View File
@@ -54,6 +54,53 @@ textarea {
font-size: 12px;
}
.workspace-view-tabs {
display: flex;
align-items: stretch;
height: 34px;
border-bottom: 1px solid #bfcbd8;
}
.workspace-view-tabs button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-width: 76px;
height: 34px;
padding: 0 13px;
border: 0;
border-bottom: 2px solid transparent;
color: #526172;
background: transparent;
cursor: pointer;
}
.workspace-view-tabs button:hover {
color: #1f2933;
background: #f1f5f8;
}
.workspace-view-tabs button.active {
border-bottom-color: #1d6fb8;
color: #155a94;
background: #eaf3fa;
font-weight: 700;
}
.workspace-view-tabs button:focus-visible {
outline: 2px solid #1d6fb8;
outline-offset: 1px;
}
.result-available-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #2f7d55;
}
.topbar-actions {
display: flex;
flex-wrap: wrap;
@@ -480,43 +527,80 @@ textarea {
.sim-node {
position: relative;
width: 132px;
height: 84px;
}
.sim-node.orientation-vertical {
width: 84px;
height: 132px;
}
.sim-node-frame {
position: absolute;
top: 50%;
left: 50%;
display: grid;
place-items: center;
min-width: 96px;
min-height: 58px;
padding: 8px 16px;
width: 132px;
height: 84px;
box-sizing: border-box;
border: 2px solid #4b5563;
border-radius: 6px;
background: #ffffff;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
transform-origin: center;
}
.sim-node.selected {
.sim-node.selected .sim-node-frame {
border-color: #1d6fb8;
box-shadow: 0 0 0 3px rgba(29, 111, 184, 0.15);
}
.react-flow__node.validation-error .sim-node {
.react-flow__node.validation-error .sim-node-frame {
border-color: #c63b32;
box-shadow: 0 0 0 3px rgba(198, 59, 50, 0.14);
}
.react-flow__node.validation-warning .sim-node {
.react-flow__node.validation-warning .sim-node-frame {
border-color: #c08a16;
box-shadow: 0 0 0 3px rgba(192, 138, 22, 0.14);
}
.node-symbol-anchor {
position: absolute;
z-index: 1;
top: 50%;
left: 50%;
width: 48px;
height: 48px;
transform: translate(-50%, -50%);
}
.node-symbol {
height: 28px;
display: grid;
width: 48px;
height: 48px;
place-items: center;
transform-origin: center;
}
.node-label {
max-width: 120px;
position: absolute;
z-index: 2;
bottom: 4px;
left: 50%;
max-width: calc(100% - 14px);
padding: 0 2px;
overflow: hidden;
font-size: 12px;
font-weight: 700;
line-height: 16px;
pointer-events: none;
text-overflow: ellipsis;
transform: translateX(-50%);
white-space: nowrap;
background: rgba(255, 255, 255, 0.88);
}
.symbol-cylinder,
@@ -524,6 +608,7 @@ textarea {
.symbol-pipe,
.symbol-orifice,
.symbol-tee {
box-sizing: border-box;
width: 42px;
height: 22px;
border: 2px solid #4b5563;
@@ -544,7 +629,6 @@ textarea {
.symbol-pipe {
height: 12px;
margin-top: 8px;
border-radius: 8px;
}
@@ -557,12 +641,14 @@ textarea {
.symbol-tee {
position: relative;
height: 32px;
border: 0;
}
.symbol-tee::before,
.symbol-tee::after {
position: absolute;
box-sizing: border-box;
content: "";
border: 2px solid #4b5563;
border-radius: 6px;
@@ -570,7 +656,7 @@ textarea {
}
.symbol-tee::before {
top: 8px;
top: 10px;
left: 0;
width: 42px;
height: 12px;
@@ -578,18 +664,25 @@ textarea {
.symbol-tee::after {
top: 0;
left: 14px;
left: 15px;
width: 12px;
height: 28px;
height: 32px;
}
.port-handle {
z-index: 3;
width: 10px;
height: 10px;
border: 2px solid #374151;
background: #ffffff;
}
.flow-canvas .react-flow__edge-path,
.flow-canvas .react-flow__connection-path {
stroke: #64748b;
stroke-width: 1.5;
}
.field {
display: grid;
gap: 6px;
@@ -735,6 +828,18 @@ textarea {
padding: 12px;
}
.result-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.result-title small {
color: #b45309;
font-size: 11px;
font-weight: 600;
}
.result-grid {
display: grid;
grid-template-columns: minmax(80px, 1fr) minmax(0, 1.4fr);
@@ -762,25 +867,419 @@ textarea {
font-size: 12px;
}
.artifact-list {
.component-result {
display: grid;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #e3e8ef;
}
.artifact-list div {
.component-result > strong {
color: #1f2933;
font-size: 12px;
}
.results-shell {
display: grid;
gap: 4px;
grid-template-rows: 62px minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 980px;
background: #eef2f6;
}
.results-topbar {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto minmax(220px, 1fr);
align-items: center;
gap: 18px;
padding: 8px 14px;
border-bottom: 1px solid #cfd8e3;
background: #ffffff;
}
.results-run-meta {
display: grid;
justify-items: end;
gap: 2px;
min-width: 0;
text-align: right;
}
.results-run-meta strong,
.results-run-meta span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.results-run-meta strong {
color: #253342;
font-size: 12px;
}
.results-run-meta span {
color: #64748b;
font-size: 11px;
}
.artifact-list code {
overflow-wrap: anywhere;
color: #1f2933;
font-family: Consolas, "Courier New", monospace;
.results-run-meta.empty {
color: #7b8795;
}
.results-workbench {
display: grid;
grid-template-columns:
minmax(360px, 0.85fr)
minmax(480px, 1.15fr)
290px;
min-width: 0;
min-height: 0;
}
.results-system-panel,
.results-chart-panel,
.results-variable-panel {
display: grid;
grid-template-rows: 43px minmax(0, 1fr);
min-width: 0;
min-height: 0;
background: #ffffff;
}
.results-system-panel {
border-right: 1px solid #cfd8e3;
}
.results-chart-panel {
border-right: 1px solid #cfd8e3;
}
.results-variable-panel {
display: block;
overflow: auto;
}
.results-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 43px;
padding: 0 13px;
border-bottom: 1px solid #dfe5ec;
color: #233142;
background: #f7f9fb;
}
.results-section-heading strong {
font-size: 13px;
}
.results-section-heading span {
color: #64748b;
font-size: 11px;
}
.results-system-canvas {
position: relative;
min-width: 0;
min-height: 0;
background: #f8fafc;
}
.results-system-canvas .react-flow__pane {
cursor: grab;
}
.results-system-canvas .react-flow__pane.dragging {
cursor: grabbing;
}
.results-system-canvas .react-flow__node {
cursor: pointer;
}
.results-system-canvas .react-flow__handle {
cursor: default;
pointer-events: none;
}
.results-system-canvas .react-flow__edge-path {
stroke: #5f6f82;
stroke-width: 1.5;
}
.results-chart-workspace {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background-color: #f5f7fa;
background-image: radial-gradient(#cbd5e1 0.7px, transparent 0.7px);
background-position: 0 0;
background-size: 18px 18px;
}
.results-chart-workspace.empty {
background-color: #f8fafc;
}
.results-workspace-empty {
position: absolute;
top: 50%;
left: 50%;
color: #8a97a6;
font-size: 12px;
transform: translate(-50%, -50%);
}
.result-chart-window {
position: absolute;
display: grid;
grid-template-rows: 44px minmax(0, 1fr);
width: min(420px, calc(100% - 24px));
height: 260px;
overflow: hidden;
border: 1px solid #99a8b8;
border-radius: 6px;
background: #ffffff;
box-shadow: 0 7px 20px rgba(30, 41, 59, 0.18);
}
.result-chart-window-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
padding: 5px 7px 5px 11px;
border-bottom: 1px solid #dbe2ea;
background: #f6f8fa;
cursor: move;
touch-action: none;
user-select: none;
}
.result-chart-window-header > div {
min-width: 0;
}
.result-chart-window-header strong,
.result-chart-window-header span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-chart-window-header strong {
color: #1f2933;
font-size: 12px;
}
.result-chart-window-header span {
margin-top: 1px;
color: #64748b;
font-size: 10px;
}
.result-chart-window-header button {
display: grid;
flex: 0 0 28px;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
color: #526172;
background: transparent;
cursor: pointer;
}
.result-chart-window-header button:hover {
border-color: #c5cfda;
color: #b42318;
background: #ffffff;
}
.result-chart-body {
min-width: 0;
min-height: 0;
padding: 5px 8px 7px 4px;
}
.result-chart-body svg {
display: block;
width: 100%;
height: 100%;
}
.result-chart-grid-line {
stroke: #e1e7ee;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.result-chart-axis {
stroke: #76869a;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.result-chart-label,
.result-chart-unit {
fill: #5b6878;
font-family: inherit;
font-size: 9px;
}
.result-chart-unit {
font-weight: 650;
}
.result-chart-empty {
display: grid;
height: 100%;
place-items: center;
color: #8a97a6;
font-size: 12px;
}
.results-selected-component {
display: grid;
gap: 3px;
padding: 13px;
border-bottom: 1px solid #e3e8ef;
}
.results-selected-component strong {
overflow: hidden;
color: #1f2933;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.results-selected-component span {
color: #64748b;
font-size: 11px;
}
.results-variable-group {
padding: 12px;
border-bottom: 1px solid #e3e8ef;
}
.results-variable-group-title {
margin-bottom: 8px;
color: #526172;
font-size: 11px;
font-weight: 700;
}
.results-variable-list {
display: grid;
gap: 6px;
}
.results-variable-list button {
display: grid;
grid-template-columns: 4px minmax(0, 1fr);
align-items: stretch;
gap: 9px;
min-height: 43px;
padding: 6px 8px 6px 0;
overflow: hidden;
border: 1px solid #d4dde7;
border-radius: 5px;
color: #263442;
text-align: left;
background: #ffffff;
cursor: grab;
}
.results-variable-list button:hover {
border-color: #8ca0b5;
background: #f8fafc;
}
.results-variable-list button.visible {
border-color: #6b9ac4;
background: #edf6fc;
}
.results-variable-list button:active {
cursor: grabbing;
}
.result-variable-swatch {
display: block;
width: 4px;
height: 100%;
}
.results-variable-list button > span:last-child {
align-self: center;
min-width: 0;
}
.results-variable-list strong,
.results-variable-list small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.results-variable-list strong {
font-size: 12px;
}
.results-variable-list small {
margin-top: 2px;
color: #64748b;
font-size: 10px;
}
.results-variable-empty {
padding: 18px 13px;
color: #7b8795;
font-size: 12px;
}
.results-empty-view {
display: grid;
align-content: center;
justify-items: center;
gap: 14px;
min-height: 0;
color: #687586;
background: #f8fafc;
}
.results-empty-view strong {
color: #344252;
font-size: 14px;
}
.results-empty-view button {
height: 32px;
padding: 0 12px;
border: 1px solid #99aabc;
border-radius: 5px;
color: #263442;
background: #ffffff;
cursor: pointer;
}
.results-empty-view button:hover {
border-color: #66798d;
background: #f2f6f9;
}
@media (max-width: 1100px) {
.workbench {
grid-template-columns: 180px minmax(0, 1fr) 280px;
@@ -794,4 +1293,8 @@ textarea {
.project-name-field input {
width: 120px;
}
.results-workbench {
grid-template-columns: 340px minmax(400px, 1fr) 260px;
}
}