前端进度条性能优化、仿真结束后后处理优化;后端C代码生成流程优化:先识别来源,再按照已知未知量需求排序,最后局部求解
This commit is contained in:
1 parent
0dcb465d84
commit
91bd9fb252
77 files changed
+11223
-551
No files matched your search
+375
-251
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user