前端进度条性能优化、仿真结束后后处理优化;后端C代码生成流程优化:先识别来源,再按照已知未知量需求排序,最后局部求解

This commit is contained in:
ljz committed 2026-09-11 11:27:54 +08:00
1 parent 0dcb465d84
commit 91bd9fb252
77 files changed
+11223 -551

No files matched your search

+375 -251
View File
File diff suppressed because it is too large. Load diff
+10 -24
View File
@@ -1,9 +1,8 @@
import { Background, BackgroundVariant } from "@xyflow/react";
export const CANVAS_GRID_SIZE = 18;
const CANVAS_DOT_GAP = 18;
export type CanvasGridVisibility = {
lines: boolean;
dots: boolean;
};
@@ -14,26 +13,13 @@ type CanvasGridBackgroundProps = {
export function CanvasGridBackground({
visibility,
}: CanvasGridBackgroundProps) {
return (
<>
{visibility.lines ? (
<Background
color="#d6e0ea"
gap={CANVAS_GRID_SIZE * 5}
id="major-grid-lines"
lineWidth={1}
variant={BackgroundVariant.Lines}
/>
) : null}
{visibility.dots ? (
<Background
color="#c4d0dd"
gap={CANVAS_GRID_SIZE}
id="minor-grid-dots"
size={1}
variant={BackgroundVariant.Dots}
/>
) : null}
</>
);
return visibility.dots ? (
<Background
color="#c4d0dd"
gap={CANVAS_DOT_GAP}
id="minor-grid-dots"
size={1}
variant={BackgroundVariant.Dots}
/>
) : null;
}
+102 -116
View File
@@ -53,8 +53,15 @@ import {
import {
continuousMechanicalForceChartValues,
resultStateTransitionSampleIndices,
segmentedChartPath,
} from "./resultEventSeries";
import {
preparedChartSamples,
chartCursorSamples,
chartSampleBounds,
combinedChartBounds,
visibleSampleRange,
sampledChartPath,
} from "./chartData";
type ResultPortDefinition = {
name: string;
@@ -401,6 +408,28 @@ export function SimulationResultsView({
),
[snapshot.result.diagnostics, snapshot.result.series.time],
);
const displaySeriesFor = useMemo(() => {
const cache = new Map<string, { values: number[]; rawValues: number[]; separatedEventSampleCount: number }>();
const components = new Map(snapshot.project.nodes.map((node) => [node.id, node]));
return (variable: ResultVariableMetadata, unit: ResultUnitOption) => {
const key = `${variable.key}:${unit.value}`;
const previous = cache.get(key);
if (previous) return previous;
const raw = snapshot.result.series[variable.key] ?? [];
const display = continuousMechanicalForceChartValues(variable, components.get(variable.componentId), raw, stateTransitionSampleIndices);
const identityUnit = unit.fromBase(0) === 0 && unit.fromBase(1) === 1;
const rawValues = identityUnit ? raw : raw.map(unit.fromBase);
const entry = {
values: display.values === raw ? rawValues : identityUnit ? display.values : display.values.map(unit.fromBase),
rawValues,
separatedEventSampleCount: display.separatedEventSampleCount,
};
// Bound unit-switch history; immutable arrays held by open charts remain valid.
if (cache.size >= 64) cache.delete(cache.keys().next().value!);
cache.set(key, entry);
return entry;
};
}, [snapshot, stateTransitionSampleIndices]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(
snapshot.project.nodes[0]?.id ?? null,
);
@@ -1632,15 +1661,8 @@ export function SimulationResultsView({
chartWindow.unit,
);
const chartTime = snapshot.result.series.time ?? [];
const primaryChartValues = continuousMechanicalForceChartValues(
variable,
component,
snapshot.result.series[chartWindow.variableKey] ?? [],
stateTransitionSampleIndices,
);
const displayValues = primaryChartValues.values.map(
selectedUnit.fromBase,
);
const primaryChartValues = displaySeriesFor(variable, selectedUnit);
const displayValues = primaryChartValues.values;
const combinedVariables = windowVariables.filter(
(item) => isMixed || item.unit === variable.unit,
);
@@ -1657,13 +1679,7 @@ export function SimulationResultsView({
chartWindow.variableUnits[item.key] ?? item.unit,
)
: selectedUnit;
const itemChartValues =
continuousMechanicalForceChartValues(
item,
itemComponent,
snapshot.result.series[item.key] ?? [],
stateTransitionSampleIndices,
);
const itemChartValues = displaySeriesFor(item, itemUnit);
return {
key: item.key,
label: resultVariableTitle(
@@ -1672,7 +1688,8 @@ export function SimulationResultsView({
),
color: colorForVariable(item.key),
unit: itemUnit.label,
values: itemChartValues.values.map(itemUnit.fromBase),
values: itemChartValues.values,
cursorValues: itemChartValues.rawValues,
separatedEventSampleCount:
itemChartValues.separatedEventSampleCount,
};
@@ -2098,6 +2115,7 @@ export function SimulationResultsView({
title={chartTitle}
unit={selectedUnit.label}
values={displayValues}
cursorValues={primaryChartValues.rawValues}
viewport={chartWindow.viewport}
zoomEnabled={chartWindow.zoomEnabled}
/>
@@ -2310,6 +2328,7 @@ type MultiCurveSeries = {
color: string;
unit: string;
values: number[];
cursorValues?: number[];
separatedEventSampleCount: number;
};
@@ -2990,9 +3009,7 @@ function chartDomainForViewport(
return [minimum + span * viewport.start, minimum + span * viewport.end];
}
function paddedChartDomain(values: number[]): [number, number] {
const rawMinimum = Math.min(...values);
const rawMaximum = Math.max(...values);
function paddedChartDomain(rawMinimum: number, rawMaximum: number): [number, number] {
const padding =
rawMinimum === rawMaximum
? Math.max(Math.abs(rawMinimum) * 0.05, 1)
@@ -3039,31 +3056,35 @@ function chartSegmentIntersectsDomain(
}
function chartSamplesHaveVisibleSegment(
samples: Array<{ x: number; y: number }>,
samples: Array<{ x: number; y: number; breakBefore?: boolean }>,
xMinimum: number,
xMaximum: number,
yMinimum: number,
yMaximum: number,
) {
return samples.some((sample, index) => {
const [start, end] = visibleSampleRange(samples, xMinimum, xMaximum);
for (let index = start; index < end; index++) {
const sample = samples[index];
const previous = samples[index - 1];
if (!previous) {
return (
if (!previous || sample.breakBefore) {
if (
sample.x >= xMinimum &&
sample.x <= xMaximum &&
sample.y >= yMinimum &&
sample.y <= yMaximum
);
) return true;
continue;
}
return chartSegmentIntersectsDomain(
if (chartSegmentIntersectsDomain(
previous,
sample,
xMinimum,
xMaximum,
yMinimum,
yMaximum,
);
});
)) return true;
}
return false;
}
function chartSeriesHasVisibleSegments(
@@ -3075,22 +3096,15 @@ function chartSeriesHasVisibleSegments(
const prepared = series
.map((curve) => ({
...curve,
samples: time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(curve.values[index]),
}))
.filter(
(sample) => Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
samples: preparedChartSamples(time, curve.values),
}))
.filter((curve) => curve.samples.length >= 2);
if (prepared.length === 0) {
return false;
}
const allSamples = prepared.flatMap((curve) => curve.samples);
const fullXMinimum = Math.min(...allSamples.map((sample) => sample.x));
const fullXMaximum = Math.max(...allSamples.map((sample) => sample.x));
const bounds = combinedChartBounds(prepared);
const fullXMinimum = bounds.xMin;
const fullXMaximum = bounds.xMax;
const [xMinimum, xMaximum] = chartDomainForViewport(
fullXMinimum,
fullXMaximum,
@@ -3098,9 +3112,8 @@ function chartSeriesHasVisibleSegments(
);
if (stacked) {
return prepared.some((curve) => {
const [fullYMinimum, fullYMaximum] = paddedChartDomain(
curve.samples.map((sample) => sample.y),
);
const curveBounds = chartSampleBounds(curve.samples);
const [fullYMinimum, fullYMaximum] = paddedChartDomain(curveBounds.yMin, curveBounds.yMax);
const [yMinimum, yMaximum] = chartDomainForViewport(
fullYMinimum,
fullYMaximum,
@@ -3116,7 +3129,7 @@ function chartSeriesHasVisibleSegments(
});
}
const [fullYMinimum, fullYMaximum] = paddedChartDomain(
allSamples.map((sample) => sample.y),
bounds.yMin, bounds.yMax,
);
const [yMinimum, yMaximum] = chartDomainForViewport(
fullYMinimum,
@@ -3167,28 +3180,13 @@ function MultiCurvePlot({
series
.map((curve) => ({
...curve,
samples: time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(curve.values[index]),
dataIndex: index,
}))
.filter(
(sample) =>
Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
samples: preparedChartSamples(time, curve.values),
}))
.filter((curve) => curve.samples.length >= 2),
[series, time],
);
const cursorSamples = useMemo<CurveSample[]>(
() =>
time
.map((timeValue, index) => ({
x: Number(timeValue),
y: index,
}))
.filter((sample) => Number.isFinite(sample.x)),
() => chartCursorSamples(time),
[time],
);
@@ -3303,9 +3301,9 @@ function MultiCurvePlot({
);
}
const allSamples = plottedSeries.flatMap((curve) => curve.samples);
const rawYMin = Math.min(...allSamples.map((sample) => sample.y));
const rawYMax = Math.max(...allSamples.map((sample) => sample.y));
const bounds = combinedChartBounds(plottedSeries);
const rawYMin = bounds.yMin;
const rawYMax = bounds.yMax;
const sharedYPadding =
rawYMin === rawYMax
? Math.max(Math.abs(rawYMin) * 0.05, 1)
@@ -3320,8 +3318,8 @@ function MultiCurvePlot({
viewport.y,
);
const sharedYScale = { minimum: yMin, maximum: yMax };
const fullXMin = Math.min(...allSamples.map((sample) => sample.x));
const fullXMax = Math.max(...allSamples.map((sample) => sample.x));
const fullXMin = bounds.xMin;
const fullXMax = bounds.xMax;
const [xMin, xMax] = chartDomainForViewport(fullXMin, fullXMax, viewport.x);
const xRange = Math.max(xMax - xMin, 1e-12);
const xPosition = (value: number) =>
@@ -3353,7 +3351,7 @@ function MultiCurvePlot({
index,
label: curve.label,
unit: curve.unit,
y: Number(curve.values[Math.round(cursorSample.y)]),
y: Number((curve.cursorValues ?? curve.values)[Math.round(cursorSample.y)]),
}))
.filter((reading) => Number.isFinite(reading.y))
: [];
@@ -3641,11 +3639,12 @@ function MultiCurvePlot({
/>
<g clipPath={`url(#${clipPathId})`} data-chart-clipped-series="true">
{plottedSeries.map((curve) => {
const path = segmentedChartPath(
const path = sampledChartPath(
time,
curve.values,
xPosition,
yPosition,
xMin, xMax, plotWidth,
);
return (
<path
@@ -3824,28 +3823,13 @@ function StackedCurvePlot({
series
.map((curve) => ({
...curve,
samples: time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(curve.values[index]),
dataIndex: index,
}))
.filter(
(sample) =>
Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
samples: preparedChartSamples(time, curve.values),
}))
.filter((curve) => curve.samples.length >= 2),
[series, time],
);
const cursorSamples = useMemo<CurveSample[]>(
() =>
time
.map((timeValue, index) => ({
x: Number(timeValue),
y: index,
}))
.filter((sample) => Number.isFinite(sample.x)),
() => chartCursorSamples(time),
[time],
);
@@ -3964,17 +3948,18 @@ function StackedCurvePlot({
);
}
const allSamples = plottedSeries.flatMap((curve) => curve.samples);
const fullXMin = Math.min(...allSamples.map((sample) => sample.x));
const fullXMax = Math.max(...allSamples.map((sample) => sample.x));
const bounds = combinedChartBounds(plottedSeries);
const fullXMin = bounds.xMin;
const fullXMax = bounds.xMax;
const [xMin, xMax] = chartDomainForViewport(fullXMin, fullXMax, viewport.x);
const xRange = Math.max(xMax - xMin, 1e-12);
const xPosition = (value: number) =>
margin.left + ((value - xMin) / xRange) * plotWidth;
const xTicks = chartTicks(xMin, xMax, 5);
const plotBands = plottedSeries.map((curve, index) => {
const rawMinimum = Math.min(...curve.samples.map((sample) => sample.y));
const rawMaximum = Math.max(...curve.samples.map((sample) => sample.y));
const curveBounds = chartSampleBounds(curve.samples);
const rawMinimum = curveBounds.yMin;
const rawMaximum = curveBounds.yMax;
const padding =
rawMinimum === rawMaximum
? Math.max(Math.abs(rawMinimum) * 0.05, 1)
@@ -4023,7 +4008,7 @@ function StackedCurvePlot({
index,
label: curve.label,
unit: curve.unit,
y: Number(curve.values[Math.round(cursorSample.y)]),
y: Number((curve.cursorValues ?? curve.values)[Math.round(cursorSample.y)]),
}))
.filter((reading) => Number.isFinite(reading.y))
: [];
@@ -4200,11 +4185,12 @@ function StackedCurvePlot({
</defs>
{plotBands.map((band) => {
const isLast = band.index === plotBands.length - 1;
const path = segmentedChartPath(
const path = sampledChartPath(
time,
band.curve.values,
xPosition,
band.yPosition,
xMin, xMax, plotWidth,
);
return (
<g className="result-chart-stack-band" key={band.curve.key}>
@@ -4477,6 +4463,7 @@ function StackedCurvePlot({
type CurvePlotProps = {
time: number[];
values: number[];
cursorValues?: number[];
title: string;
unit: string;
color: string;
@@ -4507,6 +4494,7 @@ type CursorLineDragState = {
function CurvePlot({
time,
values,
cursorValues = values,
title,
unit,
color,
@@ -4526,17 +4514,13 @@ function CurvePlot({
const [cursorPanelPosition, setCursorPanelPosition] =
useState<CursorPanelPosition>({ x: 238, y: 9 });
const samples = useMemo<CurveSample[]>(
() =>
time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(values[index]),
}))
.filter(
(sample) => Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
() => preparedChartSamples(time, values),
[time, values],
);
const cursorSamples = useMemo(
() => preparedChartSamples(time, cursorValues),
[time, cursorValues],
);
useEffect(() => {
const body = bodyRef.current;
@@ -4565,15 +4549,15 @@ function CurvePlot({
}, []);
useEffect(() => {
if (!cursorEnabled || samples.length === 0) {
if (!cursorEnabled || cursorSamples.length === 0) {
return;
}
setCursorIndex((current) =>
current !== null && current < samples.length
current !== null && current < cursorSamples.length
? current
: Math.floor(samples.length / 2),
: Math.floor(cursorSamples.length / 2),
);
}, [cursorEnabled, samples.length]);
}, [cursorEnabled, cursorSamples.length]);
useEffect(() => {
const body = bodyRef.current;
@@ -4635,11 +4619,12 @@ function CurvePlot({
);
}
const fullXMin = Math.min(...samples.map((sample) => sample.x));
const fullXMax = Math.max(...samples.map((sample) => sample.x));
const bounds = chartSampleBounds(samples);
const fullXMin = bounds.xMin;
const fullXMax = bounds.xMax;
const [xMin, xMax] = chartDomainForViewport(fullXMin, fullXMax, viewport.x);
const rawYMin = Math.min(...samples.map((sample) => sample.y));
const rawYMax = Math.max(...samples.map((sample) => sample.y));
const rawYMin = bounds.yMin;
const rawYMax = bounds.yMax;
const yPadding =
rawYMin === rawYMax
? Math.max(Math.abs(rawYMin) * 0.05, 1)
@@ -4657,23 +4642,24 @@ function CurvePlot({
margin.top + (1 - (value - yMin) / yRange) * plotHeight;
const xTicks = chartTicks(xMin, xMax, 5);
const yTicks = chartTicks(yMin, yMax, 5);
const path = segmentedChartPath(
const path = sampledChartPath(
time,
values,
xPosition,
yPosition,
xMin, xMax, plotWidth,
);
const requestedCursorIndex =
cursorIndex === null
? Math.floor(samples.length / 2)
: Math.min(cursorIndex, samples.length - 1);
? Math.floor(cursorSamples.length / 2)
: Math.min(cursorIndex, cursorSamples.length - 1);
const activeCursorIndex = clampCursorIndexToVisibleX(
samples,
cursorSamples,
requestedCursorIndex,
xMin,
xMax,
);
const cursorSample = samples[activeCursorIndex];
const cursorSample = cursorSamples[activeCursorIndex];
const updateCursorByX = (clientX: number, svg: SVGSVGElement) => {
const bounds = svg.getBoundingClientRect();
@@ -4686,7 +4672,7 @@ function CurvePlot({
Math.max(margin.left, svgX),
);
const targetX = xMin + ((clampedX - margin.left) / plotWidth) * xRange;
setCursorIndex(nearestSampleIndex(samples, targetX));
setCursorIndex(nearestSampleIndex(cursorSamples, targetX));
};
const updateCursorByY = (clientY: number, svg: SVGSVGElement) => {
@@ -4701,7 +4687,7 @@ function CurvePlot({
);
const targetY = yMax - ((clampedY - margin.top) / plotHeight) * yRange;
setCursorIndex((current) =>
nearestSampleIndexByY(samples, targetY, current ?? activeCursorIndex),
nearestSampleIndexByY(cursorSamples, targetY, current ?? activeCursorIndex),
);
};
+119
View File
@@ -0,0 +1,119 @@
export type AlignmentPoint = { x: number; y: number };
export type AlignmentKind = "center" | "origin" | "port";
export type AlignmentAnchor = AlignmentPoint & {
nodeId: string;
id: string;
kind: AlignmentKind;
};
export type AlignmentGuide = {
axis: "x" | "y";
kind: AlignmentKind;
from: AlignmentPoint;
to: AlignmentPoint;
};
export type AlignmentMatch = {
positionDelta: AlignmentPoint;
guides: AlignmentGuide[];
};
type CoordinateGroup = { coordinate: number; anchors: AlignmentAnchor[] };
export type AlignmentIndex = Record<"x" | "y", Record<AlignmentKind, CoordinateGroup[]>>;
const KINDS: AlignmentKind[] = ["port", "center", "origin"];
function lowerBound<T>(items: T[], value: number, coordinate: (item: T) => number) {
let low = 0;
let high = items.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (coordinate(items[middle]) < value) low = middle + 1;
else high = middle;
}
return low;
}
/** Build once per drag. Equal X/Y values are grouped, then sorted by the other axis. */
export function buildAlignmentIndex(anchors: AlignmentAnchor[]): AlignmentIndex {
const index: AlignmentIndex = {
x: { center: [], origin: [], port: [] },
y: { center: [], origin: [], port: [] },
};
for (const axis of ["x", "y"] as const) {
const other = axis === "x" ? "y" : "x";
for (const kind of KINDS) {
const groups = new Map<number, AlignmentAnchor[]>();
for (const anchor of anchors) {
if (anchor.kind !== kind || !Number.isFinite(anchor.x) || !Number.isFinite(anchor.y)) continue;
const group = groups.get(anchor[axis]);
if (group) group.push(anchor);
else groups.set(anchor[axis], [anchor]);
}
index[axis][kind] = [...groups].map(([coordinate, items]) => ({
coordinate,
anchors: items.sort((a, b) => a[other] - b[other] || a.id.localeCompare(b.id)),
})).sort((a, b) => a.coordinate - b.coordinate);
}
}
return index;
}
/** Same-kind anchors align independently on X/Y; the whole moving block gets one delta. */
export function findAlignment(
moving: AlignmentAnchor[],
index: AlignmentIndex,
zoom: number,
): AlignmentMatch | null {
// 排版吸附采用屏幕容差,与可建立连接的接触吸附半径相互独立。
const scale = Math.max(zoom, 0.1);
const tolerance = Math.min(8 / scale, 24);
const maximumSpan = 900 / scale;
const matches: Array<{ axis: "x" | "y"; source: AlignmentAnchor; target: AlignmentAnchor }> = [];
for (const axis of ["x", "y"] as const) {
const other = axis === "x" ? "y" : "x";
let best: { source: AlignmentAnchor; target: AlignmentAnchor; distance: number; span: number } | null = null;
for (const source of moving) {
const groups = index[axis][source.kind];
for (let i = lowerBound(groups, source[axis] - tolerance, (g) => g.coordinate);
i < groups.length && groups[i].coordinate <= source[axis] + tolerance; i++) {
const group = groups[i];
const insertion = lowerBound(group.anchors, source[other], (a) => a[other]);
// 即使成千元件共用同一条中心线,也只检查另一坐标上最近的两个候选。
for (const j of [insertion - 1, insertion]) {
const target = group.anchors[j];
if (!target || target.nodeId === source.nodeId) continue;
const distance = Math.abs(target[axis] - source[axis]);
const span = Math.abs(target[other] - source[other]);
if (span > maximumSpan) continue;
if (!best || distance < best.distance - 0.01 ||
(Math.abs(distance - best.distance) <= 0.01 &&
(KINDS.indexOf(source.kind) < KINDS.indexOf(best.source.kind) ||
(source.kind === best.source.kind && span < best.span)))) {
best = { source, target, distance, span };
}
}
}
}
if (best) matches.push({ axis, source: best.source, target: best.target });
}
if (matches.length === 0) return null;
const positionDelta = { x: 0, y: 0 };
for (const match of matches) {
positionDelta[match.axis] = match.target[match.axis] - match.source[match.axis];
}
return {
positionDelta,
guides: matches.map(({ axis, source, target }) => ({
axis, kind: source.kind,
from: { x: source.x + positionDelta.x, y: source.y + positionDelta.y },
to: { x: target.x, y: target.y },
})),
};
}
export function sameAlignmentGuides(a: AlignmentGuide[], b: AlignmentGuide[]) {
return a.length === b.length && a.every((guide, i) => {
const other = b[i];
return guide.axis === other.axis && guide.kind === other.kind &&
guide.from.x === other.from.x && guide.from.y === other.from.y &&
guide.to.x === other.to.x && guide.to.y === other.to.y;
});
}
+139
View File
@@ -0,0 +1,139 @@
export type ChartSample = { x: number; y: number; dataIndex: number; breakBefore: boolean };
type Bounds = { xMin: number; xMax: number; yMin: number; yMax: number; sorted: boolean };
const seriesCache = new WeakMap<number[], WeakMap<number[], ChartSample[]>>();
const boundsCache = new WeakMap<Array<{ x: number; y: number }>, Bounds>();
const cursorCache = new WeakMap<number[], ChartSample[]>();
const selectionCache = new WeakMap<ChartSample[], Map<string, ChartSample[]>>();
/** Cached only by immutable source arrays; collected together with their result snapshot. */
export function preparedChartSamples(time: number[], values: number[]) {
let byTime = seriesCache.get(values);
if (!byTime) { byTime = new WeakMap(); seriesCache.set(values, byTime); }
let samples = byTime.get(time);
if (samples) return samples;
samples = [];
let breakBefore = true;
for (let i = 0; i < time.length; i++) {
const x = Number(time[i]), y = Number(values[i]);
if (!Number.isFinite(x)) { breakBefore = true; continue; }
// Preserve the existing isolated-force-event interpolation policy.
if (!Number.isFinite(y)) continue;
samples.push({ x, y, dataIndex: i, breakBefore });
breakBefore = false;
}
byTime.set(time, samples);
return samples;
}
export function chartCursorSamples(time: number[]) {
let samples = cursorCache.get(time);
if (!samples) {
samples = [];
for (let i = 0; i < time.length; i++) {
if (Number.isFinite(time[i])) samples.push({ x: time[i], y: i, dataIndex: i, breakBefore: false });
}
cursorCache.set(time, samples);
}
return samples;
}
export function chartSampleBounds(samples: Array<{ x: number; y: number }>): Bounds {
const cached = boundsCache.get(samples);
if (cached) return cached;
const bounds = { xMin: Infinity, xMax: -Infinity, yMin: Infinity, yMax: -Infinity, sorted: true };
let previousX = -Infinity;
for (const sample of samples) {
bounds.xMin = Math.min(bounds.xMin, sample.x);
bounds.xMax = Math.max(bounds.xMax, sample.x);
bounds.yMin = Math.min(bounds.yMin, sample.y);
bounds.yMax = Math.max(bounds.yMax, sample.y);
if (sample.x < previousX) bounds.sorted = false;
previousX = sample.x;
}
boundsCache.set(samples, bounds);
return bounds;
}
export function combinedChartBounds(curves: Array<{ samples: Array<{ x: number; y: number }> }>) {
const result = { xMin: Infinity, xMax: -Infinity, yMin: Infinity, yMax: -Infinity };
for (const curve of curves) {
const bounds = chartSampleBounds(curve.samples);
result.xMin = Math.min(result.xMin, bounds.xMin);
result.xMax = Math.max(result.xMax, bounds.xMax);
result.yMin = Math.min(result.yMin, bounds.yMin);
result.yMax = Math.max(result.yMax, bounds.yMax);
}
return result;
}
function lowerBound(samples: Array<{ x: number }>, x: number, upper = false) {
let low = 0, high = samples.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (samples[middle].x < x || (upper && samples[middle].x === x)) low = middle + 1;
else high = middle;
}
return low;
}
export function visibleSampleRange(samples: Array<{ x: number; y: number }>, xMin: number, xMax: number) {
if (!chartSampleBounds(samples).sorted) return [0, samples.length] as const;
// Include both outside neighbours, preserving segments crossing viewport boundaries.
return [Math.max(0, lowerBound(samples, xMin) - 1), Math.min(samples.length, lowerBound(samples, xMax, true) + 1)] as const;
}
/** Keep first/min/max/last per pixel column in original order; no averaging of peaks. */
export function chartDisplaySamples(samples: ChartSample[], xMin: number, xMax: number, pixelWidth: number) {
const width = Math.max(1, Math.ceil(pixelWidth));
const key = `${xMin}:${xMax}:${width}`;
let cache = selectionCache.get(samples);
if (!cache) { cache = new Map(); selectionCache.set(samples, cache); }
const cached = cache.get(key);
if (cached) return cached;
const [start, end] = visibleSampleRange(samples, xMin, xMax);
const result: ChartSample[] = [];
if (end - start <= width * 4 || !chartSampleBounds(samples).sorted) {
for (let i = start; i < end; i++) result.push(samples[i]);
} else {
let bucket = -Infinity;
let first = -1, last = -1, minimum = -1, maximum = -1;
const flush = () => {
if (first < 0) return;
const indices = [first, minimum, maximum, last].sort((a, b) => a - b);
let previous = -1;
for (const index of indices) {
if (index !== previous) result.push(samples[index]);
previous = index;
}
};
for (let i = start; i < end; i++) {
const sample = samples[i];
const nextBucket = Math.max(-1, Math.min(width, Math.floor((sample.x - xMin) / Math.max(xMax - xMin, 1e-12) * width)));
if (nextBucket !== bucket || sample.breakBefore || first < 0) {
flush();
first = last = minimum = maximum = i;
bucket = nextBucket;
} else {
last = i;
if (sample.y < samples[minimum].y) minimum = i;
if (sample.y > samples[maximum].y) maximum = i;
}
}
flush();
}
// A few active windows/viewports may share a curve without retaining unbounded history.
if (cache.size >= 4) cache.delete(cache.keys().next().value!);
cache.set(key, result);
return result;
}
export function sampledChartPath(
time: number[], values: number[],
xPosition: (value: number) => number, yPosition: (value: number) => number,
xMin: number, xMax: number, pixelWidth: number,
) {
const samples = chartDisplaySamples(preparedChartSamples(time, values), xMin, xMax, pixelWidth);
return samples.map((sample, index) =>
`${index === 0 || sample.breakBefore ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition(sample.y).toFixed(2)}`,
).join(" ");
}
+56 -1
View File
@@ -74,7 +74,11 @@ export function simplifyOrthogonalPoints(points: EdgeRoutePoint[]) {
const collinearY =
Math.abs(previous.y - current.y) <= ROUTE_EPSILON &&
Math.abs(current.y - next.y) <= ROUTE_EPSILON;
if (!collinearX && !collinearY) {
const continuesForward =
(collinearX && (current.y - previous.y) * (next.y - current.y) > 0) ||
(collinearY && (current.x - previous.x) * (next.x - current.x) > 0);
// 共线但折返的点仍是有效拐点,删除它会抹掉已确认的那一段线路。
if (!continuesForward) {
simplified.push(current);
}
}
@@ -161,6 +165,57 @@ export function buildOrthogonalRoutePoints(
return simplifyOrthogonalPoints(points).slice(1, -1);
}
function connectionDraftAxis(
fixedPoints: EdgeRoutePoint[],
sourcePosition: Position,
): EdgePointAxis {
const previous = fixedPoints.at(-2);
const anchor = fixedPoints.at(-1);
// 每次确认的是一个转角,下一段与刚确认的线段垂直。
return previous && anchor
? edgeSegmentAxis(previous, anchor) === "horizontal" ? "vertical" : "horizontal"
: edgeAxisForPosition(sourcePosition);
}
/** One click fixes one segment and its corner, not the whole rubber-band tail. */
export function confirmOrthogonalCorner(
fixedPoints: EdgeRoutePoint[],
pointer: EdgeRoutePoint,
sourcePosition: Position,
) {
const anchor = fixedPoints.at(-1);
if (!anchor || !edgePointIsFinite(pointer)) return fixedPoints;
const corner = connectionDraftAxis(fixedPoints, sourcePosition) === "horizontal"
? { x: pointer.x, y: anchor.y }
: { x: anchor.x, y: pointer.y };
// 原地点击不生成零长度线段,也不改变下一段方向。
if (edgePointsMatch(anchor, corner)) return fixedPoints;
return [...fixedPoints, corner];
}
/** Extend only the live L-shaped tail; previously confirmed corners stay fixed. */
export function extendOrthogonalRoute(
fixedPoints: EdgeRoutePoint[],
target: EdgeRoutePoint,
sourcePosition: Position,
targetPosition?: Position,
) {
const points = fixedPoints.map((point) => ({ ...point }));
const anchor = points.at(-1);
if (!anchor || edgePointsMatch(anchor, target)) {
return points;
}
const nextAxis = connectionDraftAxis(points, sourcePosition);
// 只有靠近真实目标端口时才按其朝向收尾;空白处不猜测端口方向或插入中点。
if (targetPosition !== undefined) {
appendEndpointAwareLeg(points, target, nextAxis, edgeAxisForPosition(targetPosition));
} else {
appendOrthogonalLeg(points, target, nextAxis);
}
// 不对 fixedPoints 重新路由或跨边界简化,否则已确认的前缀会随鼠标变化。
return points;
}
/** Returns the rendered polyline, including both live port endpoints. */
export function orthogonalEdgePoints({
routePoints,
+31
View File
@@ -0,0 +1,31 @@
/** Decode NDJSON without rescanning or flattening an ever-growing result line. */
export function createNdjsonLineDecoder(onLine: (line: string) => void) {
const decoder = new TextDecoder();
let fragments: string[] = [];
const consumeText = (text: string) => {
let start = 0;
for (let end = text.indexOf("\n"); end >= 0; end = text.indexOf("\n", start)) {
if (end > start) fragments.push(text.slice(start, end));
const line = fragments.length === 1 ? fragments[0] : fragments.join("");
fragments = [];
onLine(line);
start = end + 1;
}
if (start < text.length) fragments.push(text.slice(start));
};
return {
write(chunk: Uint8Array) {
consumeText(decoder.decode(chunk, { stream: true }));
},
finish() {
consumeText(decoder.decode());
if (fragments.length > 0) {
const line = fragments.length === 1 ? fragments[0] : fragments.join("");
fragments = [];
onLine(line);
}
},
};
}
+147
View File
@@ -0,0 +1,147 @@
// Supply direction is independent of gas flow direction and canvas orientation.
export type PortComputation = {
mode: "equation" | "fixed";
inputs: string[];
outputs: string[];
referencePort?: string;
};
type ComputationPort = {
name: string;
kind: string;
domain: string;
nominalRole: string;
computation?: PortComputation;
};
const labels: Record<string, string> = {
p: "压力", T: "温度", m_flow: "质量流率", H_flow: "能量流率",
};
const quantities = (names: string[]) => names.map((name) => labels[name] ?? name).join("、");
export function normalizePortComputation(value: unknown): PortComputation | undefined {
if (value === undefined) return undefined;
if (!value || typeof value !== "object") throw new Error("端口供需合同格式无效");
const item = value as Partial<PortComputation>;
const validNames = (names: unknown): names is string[] => Array.isArray(names) &&
names.every((name) => typeof name === "string" && Object.hasOwn(labels, name));
if ((item.mode !== "equation" && item.mode !== "fixed") ||
!validNames(item.inputs) || !validNames(item.outputs) ||
new Set([...item.inputs, ...item.outputs]).size !== item.inputs.length + item.outputs.length ||
(item.referencePort !== undefined && (typeof item.referencePort !== "string" ||
!item.referencePort || !["p", "T"].every((v) => item.outputs!.includes(v))))) {
throw new Error("端口供需合同包含无效变量或参考口");
}
return { mode: item.mode, inputs: [...item.inputs], outputs: [...item.outputs],
...(item.referencePort ? { referencePort: item.referencePort } : {}) };
}
export function portTypeIssue(first: ComputationPort, second: ComputationPort) {
if (first.kind !== second.kind || first.domain !== second.domain) {
return "端口类型或物理域不兼容";
}
if (first.kind === "signal" && !(
new Set([first.nominalRole, second.nominalRole]).size === 2 &&
[first.nominalRole, second.nominalRole].every((role) => role === "input" || role === "output")
)) {
return "信号连接需要一个输出口和一个输入口";
}
return null;
}
export function portSupplyIssue(
first: ComputationPort, second: ComputationPort,
firstLabel = first.name, secondLabel = second.name,
) {
if (first.kind !== second.kind || first.domain !== second.domain) return null;
const a = first.computation, b = second.computation;
if (a?.mode !== "fixed" && b?.mode !== "fixed") return null;
for (const [consumer, supplier, consumerLabel, supplierLabel] of [
[a, b, firstLabel, secondLabel], [b, a, secondLabel, firstLabel],
] as const) {
if (!consumer) continue;
const missing = consumer.inputs.filter((name) => !supplier?.outputs.includes(name));
if (missing.length) {
return `${consumerLabel} 需要对端提供${quantities(missing)},但 ${supplierLabel} 未提供;请检查参考口与支路口的连接。气体流向反转不会改变这一供需关系。`;
}
}
return null;
}
export function portConnectionIssue(
first: ComputationPort, second: ComputationPort,
firstLabel = first.name, secondLabel = second.name,
) {
return portTypeIssue(first, second) ?? portSupplyIssue(first, second, firstLabel, secondLabel);
}
export function portComputationDescription(port: ComputationPort) {
const lines = [`${port.name} (${port.domain})`];
const contract = port.computation;
if (contract) {
if (contract.inputs.length) lines.push(`需要:${quantities(contract.inputs)}`);
if (contract.outputs.length) lines.push(`提供:${quantities(contract.outputs)}`);
if (contract.referencePort) lines.push(`温度、压力来自 ${contract.referencePort}`);
if (contract.mode === "fixed" && contract.inputs.includes("T")) lines.push("温度、压力参考口");
if (!contract.inputs.length && !contract.outputs.length) lines.push("变量由连接方程共同确定");
}
return lines.join("\n");
}
type SupplyNode = { id: string; data: { ports: ComputationPort[] } };
type SupplyEdge = { source: string; sourceHandle?: string | null; target: string; targetHandle?: string | null };
export function referenceSupplyIssues(nodes: SupplyNode[], edges: SupplyEdge[]) {
const key = (node: string, port: string) => JSON.stringify([node, port]);
const ports = new Map(nodes.flatMap((node) => node.data.ports.map((port) =>
[key(node.id, port.name), { node: node.id, port }] as const)));
const adjacency = new Map<string, string>();
for (const edge of edges) {
const a = key(edge.source, edge.sourceHandle ?? "");
const b = key(edge.target, edge.targetHandle ?? "");
if (ports.get(a)?.port.kind === "physical" && ports.get(b)?.port.kind === "physical") {
adjacency.set(a, b); adjacency.set(b, a);
}
}
const issues: Array<{ nodeId: string; message: string }> = [];
const resolved = new Map(["p", "T"].map((v) => [v, new Set<string>()]));
for (const [endpoint, entry] of ports) {
const contract = entry.port.computation;
if (contract?.mode !== "fixed" || !["p", "T"].every((v) => contract.inputs.includes(v)) || !adjacency.has(endpoint)) continue;
for (const variable of ["p", "T"]) {
let current = endpoint;
const visited = new Set<string>();
const chain: string[] = [];
const start = `${entry.node}.${entry.port.name}`;
while (true) {
if (resolved.get(variable)!.has(current)) {
visited.forEach((item) => resolved.get(variable)!.add(item));
break;
}
const input = ports.get(current);
if (!input) break;
const label = `${input.node}.${input.port.name}`;
if (visited.has(current)) {
issues.push({ nodeId: entry.node, message: `${start} 的${labels[variable]}参考形成循环,没有实际提供者:${chain.join(" → ")} → ${label}。` });
break;
}
visited.add(current); chain.push(label);
const supplierKey = adjacency.get(current);
if (!supplierKey) {
issues.push({ nodeId: entry.node, message: `${start} 的${labels[variable]}参考链在 ${label} 中断:该参考输入尚未连接。` });
break;
}
const supplier = ports.get(supplierKey);
const supply = supplier?.port.computation;
if (!supply?.outputs.includes(variable) || !supplier) break;
if (!supply.referencePort) {
visited.forEach((item) => resolved.get(variable)!.add(item));
break;
}
chain.push(`${supplier.node}.${supplier.port.name}`);
current = key(supplier.node, supply.referencePort);
}
}
}
return issues;
}
+165
View File
@@ -0,0 +1,165 @@
import type { SimulationResultsSnapshot } from "./SimulationResultsView";
export const RESULT_SNAPSHOT_KEY = "system-simulation-flow:latest-result";
const DATABASE = "system-simulation-results";
const CHUNK_SIZE = 32768;
const BATCH_VALUES = 131072;
type Header = {
snapshot: SimulationResultsSnapshot;
lengths: Record<string, number>;
};
type Chunk = { key: IDBValidKey; values: Float64Array };
let database: Promise<IDBDatabase> | undefined;
let saveSequence = 0;
let pendingSaves = 0;
let ownedCacheId: string | undefined;
export function resultSavePending() { return pendingSaves > 0; }
function openDatabase() {
if (!database) database = new Promise<IDBDatabase>((resolve, reject) => {
let blocked = false;
const request = indexedDB.open(DATABASE, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore("headers");
request.result.createObjectStore("chunks");
};
request.onerror = () => reject(request.error);
request.onblocked = () => {
blocked = true;
reject(new Error("结果数据库被其他页面占用,请关闭旧页面后重试"));
};
request.onsuccess = () => {
const db = request.result;
if (blocked) { db.close(); return; }
db.onversionchange = () => { db.close(); database = undefined; };
resolve(db);
};
}).catch((error) => { database = undefined; throw error; });
return database;
}
function writeBatch(db: IDBDatabase, chunks: Chunk[], header?: [string, Header]) {
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction(["headers", "chunks"], "readwrite");
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error ?? new Error("结果保存事务已中止"));
transaction.onerror = () => {}; // onabort reports the transaction failure once.
try {
for (const chunk of chunks) transaction.objectStore("chunks").put(chunk.values, chunk.key);
if (header) transaction.objectStore("headers").put(header[1], header[0]);
} catch (error) {
transaction.abort();
reject(error);
}
});
}
function deleteCache(db: IDBDatabase, cacheId: string) {
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction(["headers", "chunks"], "readwrite");
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error);
transaction.objectStore("headers").delete(cacheId);
transaction.objectStore("chunks").delete(IDBKeyRange.bound([cacheId], [cacheId, []]));
});
}
/** Publish the small session pointer only after every data chunk is durably committed. */
export async function storeResultSnapshot(snapshot: SimulationResultsSnapshot) {
const sequence = ++saveSequence;
const cacheId = typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${sequence}-${Math.random().toString(36).slice(2)}`;
pendingSaves++;
let db: IDBDatabase | undefined;
let committed = false;
try {
// Yield before serialization so the ready result and controls can paint first.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
if (sequence !== saveSequence) return false;
db = await openDatabase();
const lengths: Record<string, number> = Object.create(null);
let batch: Chunk[] = [];
let batchSize = 0;
for (const [name, values] of Object.entries(snapshot.result.series)) {
lengths[name] = values.length;
for (let offset = 0; offset < values.length; offset += CHUNK_SIZE) {
if (sequence !== saveSequence) return false;
const length = Math.min(CHUNK_SIZE, values.length - offset);
const block = new Float64Array(length);
for (let i = 0; i < length; i++) block[i] = values[offset + i];
batch.push({ key: [cacheId, name, offset], values: block });
batchSize += length;
if (batchSize >= BATCH_VALUES) {
await writeBatch(db, batch);
batch = [];
batchSize = 0;
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
}
}
if (sequence !== saveSequence) return false;
await writeBatch(db, batch, [cacheId, {
snapshot: { ...snapshot, result: { ...snapshot.result, series: Object.create(null) } }, lengths,
}]);
if (sequence !== saveSequence) return false;
sessionStorage.setItem(RESULT_SNAPSHOT_KEY, JSON.stringify({ storage: "indexeddb", version: 1, cacheId }));
committed = true;
// Only retire this page's own previous save, never another tab's loaded snapshot.
const previous = ownedCacheId;
ownedCacheId = cacheId;
if (previous) void deleteCache(db, previous).catch(() => {});
return true;
} finally {
pendingSaves--;
if (db && !committed) await deleteCache(db, cacheId).catch(() => {});
}
}
export async function loadStoredResultSnapshot(): Promise<unknown | null> {
const raw = sessionStorage.getItem(RESULT_SNAPSHOT_KEY);
if (!raw) return null;
const value = JSON.parse(raw);
// Read legacy sessionStorage data without rewriting or duplicating it at startup.
if (value?.storage !== "indexeddb") return value;
if (value.version !== 1 || typeof value.cacheId !== "string") return null;
const db = await openDatabase();
return new Promise<SimulationResultsSnapshot | null>((resolve, reject) => {
const transaction = db.transaction(["headers", "chunks"], "readonly");
let result: SimulationResultsSnapshot | null = null;
let failure: Error | null = null;
transaction.oncomplete = () => failure ? reject(failure) : resolve(result);
transaction.onabort = () => reject(transaction.error);
const request = transaction.objectStore("headers").get(value.cacheId);
request.onsuccess = () => {
const header = request.result as Header | undefined;
if (!header) { failure = new Error("结果缓存已丢失,请重新载入结果文件"); return; }
result = header.snapshot;
for (const [name, length] of Object.entries(header.lengths)) {
result.result.series[name] = new Array<number>(length);
}
const loaded: Record<string, number> = Object.create(null);
const cursor = transaction.objectStore("chunks").openCursor(IDBKeyRange.bound([value.cacheId], [value.cacheId, []]));
cursor.onsuccess = () => {
const entry = cursor.result;
if (!entry) {
for (const [name, length] of Object.entries(header.lengths)) {
if ((loaded[name] ?? 0) !== length) failure = new Error("结果缓存不完整,请重新载入结果文件");
}
return;
}
const [, name, offset] = entry.key as [string, string, number];
const block = entry.value as Float64Array;
const values = result!.result.series[name];
if (!values || offset < 0 || offset + block.length > values.length) {
failure = new Error("结果缓存分块无效");
} else {
for (let i = 0; i < block.length; i++) values[offset + i] = block[i];
loaded[name] = (loaded[name] ?? 0) + block.length;
}
entry.continue();
};
};
});
}
+96
View File
@@ -0,0 +1,96 @@
export type SimulationConsoleLevel = "info" | "success" | "warning" | "error";
export type SimulationConsoleEntry = {
id: number;
timestamp: string;
level: SimulationConsoleLevel;
message: string;
format: "message" | "xml";
};
export type SimulationProgressState = {
state: "idle" | "running" | "success" | "warning" | "error";
percent: number;
message: string;
startTime: number;
simulatedTime: number;
totalTime: number;
};
export const SIMULATION_PROGRESS_REFRESH_MS = 250;
type ProgressUpdate =
| SimulationProgressState
| ((current: SimulationProgressState) => SimulationProgressState);
function sameProgress(a: SimulationProgressState, b: SimulationProgressState) {
return (
a.state === b.state &&
a.percent === b.percent &&
a.message === b.message &&
a.startTime === b.startTime &&
a.simulatedTime === b.simulatedTime &&
a.totalTime === b.totalTime
);
}
/** 仅控制台订阅,进度/日志变化不触发建模工作区渲染。 */
export function createSimulationConsoleStore(initialProgress: SimulationProgressState) {
let snapshot = { entries: [] as SimulationConsoleEntry[], progress: initialProgress };
let latestProgress = initialProgress;
let timer: ReturnType<typeof setTimeout> | undefined;
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((listener) => listener());
const cancelPendingProgress = () => {
if (timer !== undefined) clearTimeout(timer);
timer = undefined;
};
const publishProgress = () => {
timer = undefined;
if (sameProgress(snapshot.progress, latestProgress)) return;
snapshot = { ...snapshot, progress: latestProgress };
notify();
};
const setProgress = (update: ProgressUpdate) => {
cancelPendingProgress();
latestProgress =
typeof update === "function" ? update(latestProgress) : update;
publishProgress();
};
return {
getSnapshot: () => snapshot,
getProgress: () => latestProgress,
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
setProgress,
queueProgress(update: ProgressUpdate, immediate = false) {
const next = typeof update === "function" ? update(latestProgress) : update;
if (immediate || next.state !== "running") {
setProgress(next);
return;
}
latestProgress = next;
if (timer === undefined && !sameProgress(snapshot.progress, latestProgress)) {
timer = setTimeout(publishProgress, SIMULATION_PROGRESS_REFRESH_MS);
}
},
// 日志保留既有 400 条上限;关键动作立即显示,不随进度一起丢弃。
appendEntry(entry: SimulationConsoleEntry) {
snapshot = { ...snapshot, entries: [...snapshot.entries.slice(-399), entry] };
notify();
},
clearEntries() {
if (snapshot.entries.length === 0) return;
snapshot = { ...snapshot, entries: [] };
notify();
},
cancelPendingProgress,
};
}
export type SimulationConsoleStore = ReturnType<typeof createSimulationConsoleStore>;
+38
View File
@@ -3787,3 +3787,41 @@ textarea {
.flow-canvas.connection-planning .react-flow__pane {
cursor: crosshair;
}
.alignment-guides {
position: absolute;
top: 0;
left: 0;
overflow: visible;
pointer-events: none;
/* 整层半透明合成,横竖辅助线及定位点重叠时不会叠成深色。 */
opacity: 0.5;
z-index: 8;
}
.alignment-guides line {
stroke: #80c5ef;
stroke-width: 4;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.alignment-guides circle {
fill: #80c5ef;
stroke: none;
}
/* 连接模式下图形与编辑手柄不拦截落点,点击直接交给画布添加折点。
* 使用模式级覆盖统一关闭各图标自行定义的命中区,仅起点和兼容接口可交互。
*/
.flow-canvas.connection-active .react-flow__node,
.flow-canvas.connection-active .react-flow__node *,
.flow-canvas.connection-active .react-flow__edge,
.flow-canvas.connection-active .react-flow__edge * {
pointer-events: none !important;
}
.flow-canvas.connection-active .react-flow__node .port-handle.port-connection-origin,
.flow-canvas.connection-active .react-flow__node .port-handle.port-connection-compatible {
pointer-events: auto !important;
}