完善 XML 通用仿真与结果查看
This commit is contained in:
1 parent
104d41d294
commit
f1256a121d
36 files changed
+6666
-196
No files matched your search
+729
-94
File diff suppressed because it is too large.
Load diff
@@ -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+$/, "");
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $PSScriptRoot
|
||||
$nodeDir = Get-ChildItem -Path (Join-Path $repoRoot ".tools") -Directory -Filter "node-*-win-x64" |
|
||||
Sort-Object Name -Descending |
|
||||
Select-Object -First 1
|
||||
@@ -10,4 +11,4 @@ if (-not $nodeDir) {
|
||||
}
|
||||
|
||||
$env:Path = "$($nodeDir.FullName);$env:Path"
|
||||
& (Join-Path $nodeDir.FullName "npm.cmd") run dev
|
||||
& (Join-Path $nodeDir.FullName "npm.cmd") run dev -- --strictPort
|
||||
Reference in new issue
Block a user