Guard async solve updates with run tokens and surface FDM/FEA card length mismatch in Results so users understand when overlays are intentionally reduced. Made-with: Cursor
662 lines
24 KiB
TypeScript
662 lines
24 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import type { Options, AlignedData } from "uplot";
|
|
import type { SolveResponse, SolverOutput } from "../../types";
|
|
import type { CaseState } from "../../state/caseModel";
|
|
import type { EngineeringChecks } from "../../state/engineeringChecks";
|
|
import { buildTrajectorySegments } from "../../state/trajectoryMetrics";
|
|
import { Fieldset } from "../common/Fieldset";
|
|
import { UPlotChart } from "../common/UPlotChart";
|
|
import { Wellbore3DView } from "../common/Wellbore3DView";
|
|
import type { OverlayMode } from "../common/Wellbore3DView";
|
|
|
|
type Props = {
|
|
result: SolveResponse | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
lastRunAt: string | null;
|
|
elapsedSeconds: number | null;
|
|
caseState: CaseState;
|
|
checks: EngineeringChecks;
|
|
onNavigateTab?: (tabId: string) => void;
|
|
};
|
|
|
|
type CardSeries = {
|
|
position: number[];
|
|
polished: number[];
|
|
downhole: number[];
|
|
};
|
|
|
|
function toSeries(solver: SolverOutput | undefined): CardSeries | null {
|
|
if (!solver || !solver.card?.length) return null;
|
|
return {
|
|
position: solver.card.map((p) => p.position),
|
|
polished: solver.card.map((p) => p.polishedLoad),
|
|
downhole: solver.card.map((p) => p.downholeLoad)
|
|
};
|
|
}
|
|
|
|
function formatKn(value: number | undefined): string {
|
|
if (value === undefined || !Number.isFinite(value)) return "—";
|
|
return (value / 1000).toFixed(2);
|
|
}
|
|
|
|
function computePumpPlacement(caseState: CaseState) {
|
|
const segments = buildTrajectorySegments(caseState.survey);
|
|
const taperLen = caseState.taper.reduce((sum, row) => sum + Math.max(0, row.length), 0);
|
|
if (!segments.length) {
|
|
return {
|
|
nearestStationIndex: -1,
|
|
nearestStationMd: null as number | null,
|
|
nearestStationDistance: null as number | null,
|
|
surveyEndMd: null as number | null,
|
|
surveyToPumpDelta: null as number | null,
|
|
rodToPumpDelta: taperLen - caseState.pumpDepth
|
|
};
|
|
}
|
|
const stationMds = [segments[0].a.md, ...segments.map((s) => s.b.md)];
|
|
let nearestIdx = 0;
|
|
let nearestDist = Math.abs(stationMds[0] - caseState.pumpDepth);
|
|
for (let i = 1; i < stationMds.length; i += 1) {
|
|
const d = Math.abs(stationMds[i] - caseState.pumpDepth);
|
|
if (d < nearestDist) {
|
|
nearestDist = d;
|
|
nearestIdx = i;
|
|
}
|
|
}
|
|
const surveyEndMd = stationMds[stationMds.length - 1];
|
|
return {
|
|
nearestStationIndex: nearestIdx,
|
|
nearestStationMd: stationMds[nearestIdx],
|
|
nearestStationDistance: nearestDist,
|
|
surveyEndMd,
|
|
surveyToPumpDelta: surveyEndMd - caseState.pumpDepth,
|
|
rodToPumpDelta: taperLen - caseState.pumpDepth
|
|
};
|
|
}
|
|
|
|
export function ResultsTab({
|
|
result,
|
|
loading,
|
|
error,
|
|
lastRunAt,
|
|
elapsedSeconds,
|
|
caseState,
|
|
checks,
|
|
onNavigateTab
|
|
}: Props) {
|
|
const primary = result?.solver;
|
|
const fea = result?.solvers?.fea ?? null;
|
|
const fdm = result?.solvers?.fdm ?? primary ?? null;
|
|
const [overlayMode, setOverlayMode] = useState<OverlayMode>("depth");
|
|
const [selectedSegment, setSelectedSegment] = useState<number | null>(null);
|
|
const [traceabilityQuery, setTraceabilityQuery] = useState("");
|
|
const dlsUnitLabel = caseState.unitsSelection === 1 ? "deg/100 m" : "deg/100 ft";
|
|
const trajectorySegments = useMemo(() => buildTrajectorySegments(caseState.survey), [caseState.survey]);
|
|
const sideLoadProfile = primary?.profiles?.sideLoadProfile ?? null;
|
|
const pumpDiag = useMemo(() => computePumpPlacement(caseState), [caseState]);
|
|
const filteredTraceability = useMemo(() => {
|
|
const fields = result?.fieldTraceability?.fields ?? [];
|
|
const q = traceabilityQuery.trim().toLowerCase();
|
|
if (!q) return fields;
|
|
return fields.filter((row) => {
|
|
return (
|
|
row.xmlKey.toLowerCase().includes(q) ||
|
|
row.category.toLowerCase().includes(q) ||
|
|
row.notes.toLowerCase().includes(q)
|
|
);
|
|
});
|
|
}, [result?.fieldTraceability?.fields, traceabilityQuery]);
|
|
|
|
const dynacardData = useMemo<AlignedData | null>(() => {
|
|
const fdmSeries = toSeries(fdm ?? undefined);
|
|
const feaSeries = toSeries(fea ?? undefined);
|
|
if (!fdmSeries) return null;
|
|
if (feaSeries && feaSeries.position.length === fdmSeries.position.length) {
|
|
return [
|
|
fdmSeries.position,
|
|
fdmSeries.polished,
|
|
fdmSeries.downhole,
|
|
feaSeries.polished,
|
|
feaSeries.downhole
|
|
] as AlignedData;
|
|
}
|
|
return [fdmSeries.position, fdmSeries.polished, fdmSeries.downhole] as AlignedData;
|
|
}, [fdm, fea]);
|
|
const hasCardLengthMismatch = useMemo(() => {
|
|
const fdmSeries = toSeries(fdm ?? undefined);
|
|
const feaSeries = toSeries(fea ?? undefined);
|
|
if (!fdmSeries || !feaSeries) return false;
|
|
return fdmSeries.position.length !== feaSeries.position.length;
|
|
}, [fdm, fea]);
|
|
|
|
const dynacardOptions = useMemo<Options>(() => {
|
|
const seriesCount = fea ? 5 : 3;
|
|
const seriesSpec: Options["series"] = [
|
|
{ label: "Position (m)" },
|
|
{ label: "Polished (FDM)", stroke: "#f59e0b", width: 2 },
|
|
{ label: "Downhole (FDM)", stroke: "#22d3ee", width: 2 }
|
|
];
|
|
if (seriesCount === 5) {
|
|
seriesSpec.push({ label: "Polished (FEA)", stroke: "#f43f5e", width: 1.5, dash: [6, 3] });
|
|
seriesSpec.push({ label: "Downhole (FEA)", stroke: "#34d399", width: 1.5, dash: [6, 3] });
|
|
}
|
|
return {
|
|
width: 800,
|
|
height: 320,
|
|
scales: { x: { time: false } },
|
|
axes: [
|
|
{ label: "Polished-rod position (m)", stroke: "#cbd5f5" },
|
|
{ label: "Load (N)", stroke: "#cbd5f5" }
|
|
],
|
|
series: seriesSpec,
|
|
legend: { show: true }
|
|
} satisfies Options;
|
|
}, [fea]);
|
|
|
|
return (
|
|
<>
|
|
{loading && (
|
|
<div className="callout callout-info">
|
|
<span className="spinner" /> Running simulation…
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<div className="callout callout-error">
|
|
<strong>Error:</strong> {error}
|
|
</div>
|
|
)}
|
|
|
|
{!result && !loading && !error && (
|
|
<div className="callout">
|
|
No results yet. Edit inputs and press <strong>Run Solver</strong>.
|
|
</div>
|
|
)}
|
|
{!!checks.issues.length && (
|
|
<Fieldset legend="Input Integrity Checks">
|
|
<ul className="warning-list">
|
|
{checks.issues.map((issue) => (
|
|
<li key={issue.code}>
|
|
<strong>{issue.severity.toUpperCase()}:</strong> {issue.message}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Fieldset>
|
|
)}
|
|
<Fieldset legend="3D Wellbore / Rod String / Pump">
|
|
<p className="panel-note">
|
|
Tubing trajectory is colored by measured depth (hue). Rod string is overlaid with a
|
|
depth gradient, and pump location is marked in red.
|
|
</p>
|
|
<div className="button-row">
|
|
<button
|
|
type="button"
|
|
className={`btn ${overlayMode === "depth" ? "btn-primary" : ""}`}
|
|
onClick={() => setOverlayMode("depth")}
|
|
aria-pressed={overlayMode === "depth"}
|
|
>
|
|
Overlay: Depth
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`btn ${overlayMode === "sideLoad" ? "btn-primary" : ""}`}
|
|
onClick={() => setOverlayMode("sideLoad")}
|
|
disabled={!sideLoadProfile?.length}
|
|
title={!sideLoadProfile?.length ? "Run with profile output to enable side-load overlay" : ""}
|
|
aria-pressed={overlayMode === "sideLoad"}
|
|
>
|
|
Overlay: Side-load risk
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
onClick={() => setSelectedSegment(null)}
|
|
disabled={selectedSegment === null}
|
|
title="Clear selected trajectory segment highlight"
|
|
>
|
|
Clear segment highlight
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
onClick={() => exportSvg("wellbore-3d-svg", `${caseState.wellName || "well"}_wellbore.svg`)}
|
|
title="Export the current 3D viewer as SVG"
|
|
>
|
|
Export 3D SVG
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
onClick={() =>
|
|
exportSvgToPng("wellbore-3d-svg", `${caseState.wellName || "well"}_wellbore.png`)
|
|
}
|
|
title="Export the current 3D viewer as PNG"
|
|
>
|
|
Export 3D PNG
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
onClick={() =>
|
|
exportSummaryJson(
|
|
`${caseState.wellName || "well"}_summary.json`,
|
|
result,
|
|
checks,
|
|
pumpDiag
|
|
)
|
|
}
|
|
title="Export current checks, diagnostics, and run metadata"
|
|
>
|
|
Export summary JSON
|
|
</button>
|
|
</div>
|
|
<Wellbore3DView
|
|
caseState={caseState}
|
|
overlayMode={overlayMode}
|
|
sideLoadProfile={sideLoadProfile}
|
|
highlightedSegmentIndex={selectedSegment}
|
|
onSegmentSelect={setSelectedSegment}
|
|
svgId="wellbore-3d-svg"
|
|
/>
|
|
</Fieldset>
|
|
<Fieldset legend="Trajectory Analytics">
|
|
<p className="panel-note" style={{ marginBottom: 8 }}>
|
|
Click a row or 3D segment to cross-highlight. DLS is shown numerically only ({dlsUnitLabel}).
|
|
</p>
|
|
<div className="table-scroll" style={{ maxHeight: 250 }}>
|
|
<table className="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>MD start</th>
|
|
<th>MD end</th>
|
|
<th>ΔMD</th>
|
|
<th>DLS ({dlsUnitLabel})</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{trajectorySegments.map((segment) => (
|
|
<tr
|
|
key={segment.index}
|
|
className={selectedSegment === segment.index ? "row-selected" : ""}
|
|
onClick={() => setSelectedSegment(segment.index)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
setSelectedSegment(segment.index);
|
|
}
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Select trajectory segment ${segment.index + 1}`}
|
|
style={{ cursor: "pointer" }}
|
|
>
|
|
<td>{segment.index + 1}</td>
|
|
<td>{segment.a.md.toFixed(1)}</td>
|
|
<td>{segment.b.md.toFixed(1)}</td>
|
|
<td>{segment.dMd.toFixed(1)}</td>
|
|
<td>{segment.dls.toFixed(2)}</td>
|
|
</tr>
|
|
))}
|
|
{!trajectorySegments.length && (
|
|
<tr>
|
|
<td colSpan={5} className="empty-row">No trajectory segments to display.</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Fieldset>
|
|
<Fieldset legend="Pump Placement Diagnostics">
|
|
<div className="kpi-grid">
|
|
<Kpi
|
|
label="Nearest station index"
|
|
value={pumpDiag.nearestStationIndex >= 0 ? String(pumpDiag.nearestStationIndex + 1) : "—"}
|
|
/>
|
|
<Kpi
|
|
label="Nearest station MD"
|
|
value={pumpDiag.nearestStationMd !== null ? pumpDiag.nearestStationMd.toFixed(1) : "—"}
|
|
/>
|
|
<Kpi
|
|
label="Pump-to-nearest station ΔMD"
|
|
value={
|
|
pumpDiag.nearestStationDistance !== null
|
|
? `${pumpDiag.nearestStationDistance.toFixed(1)}`
|
|
: "—"
|
|
}
|
|
/>
|
|
<Kpi
|
|
label="Survey end - pump ΔMD"
|
|
value={pumpDiag.surveyToPumpDelta !== null ? pumpDiag.surveyToPumpDelta.toFixed(1) : "—"}
|
|
/>
|
|
<Kpi label="Rod total - pump Δ" value={pumpDiag.rodToPumpDelta.toFixed(1)} />
|
|
<Kpi
|
|
label="Tubing anchor - pump Δ"
|
|
value={(caseState.tubingAnchorLocation - caseState.pumpDepth).toFixed(1)}
|
|
/>
|
|
</div>
|
|
<div className="button-row">
|
|
<button type="button" className="btn" onClick={() => onNavigateTab?.("tab-trajectory")}>
|
|
Go to Trajectory tab
|
|
</button>
|
|
<button type="button" className="btn" onClick={() => onNavigateTab?.("tab-rod")}>
|
|
Go to Rod String tab
|
|
</button>
|
|
<button type="button" className="btn" onClick={() => onNavigateTab?.("tab-well")}>
|
|
Go to Well tab
|
|
</button>
|
|
</div>
|
|
</Fieldset>
|
|
|
|
{result && (
|
|
<>
|
|
<Fieldset legend="Summary — Key Performance Indicators">
|
|
<div className="kpi-grid">
|
|
<Kpi label="Active solver" value={(result.runMetadata.solverModel || "fdm").toUpperCase()} />
|
|
<Kpi label="Workflow" value={result.runMetadata.workflow ?? "predictive"} />
|
|
<Kpi label="Schema" value={`v${result.schemaVersion ?? 2}`} />
|
|
<Kpi label="Last run" value={lastRunAt ?? "—"} />
|
|
<Kpi label="Elapsed" value={elapsedSeconds !== null ? `${elapsedSeconds.toFixed(1)} s` : "—"} />
|
|
<Kpi label="Well" value={result.parsed.model.wellName} />
|
|
<Kpi label="Surface peak (kN)" value={formatKn(primary?.maxPolishedLoad)} />
|
|
<Kpi label="Surface min (kN)" value={formatKn(primary?.minPolishedLoad)} />
|
|
<Kpi label="Downhole peak (kN)" value={formatKn(primary?.maxDownholeLoad)} />
|
|
<Kpi label="Downhole min (kN)" value={formatKn(primary?.minDownholeLoad)} />
|
|
<Kpi label="Point count" value={String(primary?.pointCount ?? "—")} />
|
|
<Kpi
|
|
label="Gas interference"
|
|
value={primary?.gasInterference ? "Yes" : "No"}
|
|
/>
|
|
</div>
|
|
</Fieldset>
|
|
|
|
<Fieldset legend="Dynamometer Card">
|
|
{hasCardLengthMismatch && (
|
|
<div className="callout">
|
|
FDM and FEA card lengths differ; chart overlay is limited to FDM series for this run.
|
|
</div>
|
|
)}
|
|
{dynacardData ? (
|
|
<UPlotChart data={dynacardData} options={dynacardOptions} height={340} />
|
|
) : (
|
|
<p className="panel-note">No card data in response.</p>
|
|
)}
|
|
{primary?.fourierBaseline && primary.fourierBaseline.card?.length ? (
|
|
<div style={{ marginTop: 16 }}>
|
|
<p className="panel-note">
|
|
Fourier baseline ({primary.fourierBaseline.harmonics} harmonics): RMS polished{" "}
|
|
{primary.fourierBaseline.residualRmsPolished?.toFixed(1)} N · RMS downhole{" "}
|
|
{primary.fourierBaseline.residualRmsDownhole?.toFixed(1)} N
|
|
</p>
|
|
<UPlotChart
|
|
height={260}
|
|
data={(() => {
|
|
const n = Math.min(primary.card.length, primary.fourierBaseline.card.length);
|
|
const pos = primary.card.slice(0, n).map((p) => p.position);
|
|
const pol = primary.card.slice(0, n).map((p) => p.polishedLoad);
|
|
const four = primary.fourierBaseline.card.slice(0, n).map((p) => p.polishedLoad);
|
|
return [pos, pol, four];
|
|
})()}
|
|
options={{
|
|
width: 800,
|
|
height: 260,
|
|
scales: { x: { time: false } },
|
|
axes: [
|
|
{ label: "Position (m)", stroke: "#cbd5f5" },
|
|
{ label: "Load (N)", stroke: "#cbd5f5" }
|
|
],
|
|
series: [
|
|
{ label: "Position" },
|
|
{ label: "Polished (FDM)", stroke: "#f59e0b", width: 2 },
|
|
{ label: "Polished (Fourier)", stroke: "#a78bfa", width: 1.5, dash: [8, 4] }
|
|
],
|
|
legend: { show: true }
|
|
}}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</Fieldset>
|
|
|
|
{primary?.profiles && (
|
|
<Fieldset legend="Profiles (trajectory / side-load / friction)">
|
|
<div className="kpi-grid">
|
|
<Kpi label="Profile nodes" value={String(primary.profiles.nodeCount)} />
|
|
<Kpi
|
|
label="Max |side load| (N)"
|
|
value={
|
|
primary.profiles.sideLoadProfile.length > 0
|
|
? Math.max(
|
|
0,
|
|
...primary.profiles.sideLoadProfile.map((v) => Math.abs(v))
|
|
).toFixed(0)
|
|
: "—"
|
|
}
|
|
/>
|
|
<Kpi
|
|
label="Max |friction| (N)"
|
|
value={
|
|
primary.profiles.frictionProfile.length > 0
|
|
? Math.max(
|
|
0,
|
|
...primary.profiles.frictionProfile.map((v) => Math.abs(v))
|
|
).toFixed(0)
|
|
: "—"
|
|
}
|
|
/>
|
|
</div>
|
|
</Fieldset>
|
|
)}
|
|
|
|
{primary?.diagnostics &&
|
|
primary.diagnostics.chamberPressurePa.length > 0 &&
|
|
primary.diagnostics.gasFraction.length > 0 && (
|
|
<Fieldset legend="Diagnostics (valve / chamber / gas)">
|
|
<div className="kpi-grid">
|
|
<Kpi
|
|
label="Valve samples"
|
|
value={`${primary.diagnostics.valveStates.length} points`}
|
|
/>
|
|
<Kpi
|
|
label="Chamber P range (Pa)"
|
|
value={`${Math.min(...primary.diagnostics.chamberPressurePa).toFixed(0)} … ${Math.max(...primary.diagnostics.chamberPressurePa).toFixed(0)}`}
|
|
/>
|
|
<Kpi
|
|
label="Gas fraction range"
|
|
value={`${Math.min(...primary.diagnostics.gasFraction).toFixed(3)} … ${Math.max(...primary.diagnostics.gasFraction).toFixed(3)}`}
|
|
/>
|
|
</div>
|
|
</Fieldset>
|
|
)}
|
|
|
|
{result.fieldTraceability?.fields?.length ? (
|
|
<Fieldset legend="MVP field traceability (static map)">
|
|
<p className="panel-note">
|
|
Categories mirror <code>docs/engineering/field-traceability.md</code> (
|
|
physics, metadata, parseCalibration, payloadInactive, parsedUnused).
|
|
</p>
|
|
<div className="button-row" style={{ gap: 8, alignItems: "center" }}>
|
|
<input
|
|
className="panel-input"
|
|
value={traceabilityQuery}
|
|
placeholder="Filter by XML key/category/notes"
|
|
aria-label="Filter traceability rows"
|
|
onChange={(e) => setTraceabilityQuery(e.target.value)}
|
|
/>
|
|
<span className="panel-note">
|
|
Showing {filteredTraceability.length} / {result.fieldTraceability.fields.length}
|
|
</span>
|
|
</div>
|
|
<div className="table-scroll" style={{ maxHeight: 280 }}>
|
|
<table className="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>XML field</th>
|
|
<th>Category</th>
|
|
<th>In file</th>
|
|
<th>Notes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredTraceability.map((row) => (
|
|
<tr key={row.xmlKey} title={row.notes || undefined}>
|
|
<td className="mono">{row.xmlKey}</td>
|
|
<td>{row.category}</td>
|
|
<td>{row.presentInXml ? "yes" : "—"}</td>
|
|
<td>{row.notes}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Fieldset>
|
|
) : null}
|
|
|
|
{result.comparison && (
|
|
<Fieldset legend="FDM vs FEA Comparison">
|
|
<div className="kpi-grid">
|
|
<Kpi
|
|
label="ΔPolished max (N)"
|
|
value={result.comparison.polishedMaxDelta.toFixed(2)}
|
|
/>
|
|
<Kpi
|
|
label="ΔPolished min (N)"
|
|
value={result.comparison.polishedMinDelta.toFixed(2)}
|
|
/>
|
|
<Kpi
|
|
label="ΔDownhole max (N)"
|
|
value={result.comparison.downholeMaxDelta.toFixed(2)}
|
|
/>
|
|
<Kpi
|
|
label="ΔDownhole min (N)"
|
|
value={result.comparison.downholeMinDelta.toFixed(2)}
|
|
/>
|
|
<Kpi
|
|
label="Residual RMS (N)"
|
|
value={result.comparison.residualSummary?.rms.toFixed(2) ?? "—"}
|
|
/>
|
|
<Kpi
|
|
label="Residual points"
|
|
value={String(result.comparison.residualSummary?.points ?? "—")}
|
|
/>
|
|
</div>
|
|
</Fieldset>
|
|
)}
|
|
|
|
{(result.parseWarnings?.length || primary?.warnings?.length) && (
|
|
<Fieldset legend="Warnings">
|
|
<ul className="warning-list">
|
|
{(result.parseWarnings ?? []).map((w, i) => (
|
|
<li key={`pw-${i}`}>{w}</li>
|
|
))}
|
|
{(primary?.warnings ?? []).map((w, i) => (
|
|
<li key={`sw-${i}`}>{w}</li>
|
|
))}
|
|
</ul>
|
|
</Fieldset>
|
|
)}
|
|
|
|
{result.parsed.unsupportedFields?.length ? (
|
|
<Fieldset legend={`Unsupported XML fields (${result.parsed.unsupportedFields.length})`}>
|
|
<p className="panel-note">
|
|
Preserved on export; not consumed by the solver.
|
|
</p>
|
|
<pre className="mono-block">
|
|
{result.parsed.unsupportedFields.join("\n")}
|
|
</pre>
|
|
</Fieldset>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function exportSvg(svgId: string, filename: string) {
|
|
const svg = document.getElementById(svgId);
|
|
if (!svg) return;
|
|
const serializer = new XMLSerializer();
|
|
const source = serializer.serializeToString(svg);
|
|
const blob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function exportSvgToPng(svgId: string, filename: string) {
|
|
const svg = document.getElementById(svgId) as SVGSVGElement | null;
|
|
if (!svg) return;
|
|
const serializer = new XMLSerializer();
|
|
const source = serializer.serializeToString(svg);
|
|
const svgBlob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
|
|
const url = URL.createObjectURL(svgBlob);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const vb = svg.viewBox.baseVal;
|
|
const width = vb.width || svg.clientWidth || 800;
|
|
const height = vb.height || svg.clientHeight || 400;
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) {
|
|
URL.revokeObjectURL(url);
|
|
return;
|
|
}
|
|
ctx.drawImage(img, 0, 0);
|
|
canvas.toBlob((blob) => {
|
|
if (!blob) return;
|
|
const pngUrl = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = pngUrl;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(pngUrl);
|
|
}, "image/png");
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
img.src = url;
|
|
}
|
|
|
|
function exportSummaryJson(
|
|
filename: string,
|
|
result: SolveResponse | null,
|
|
checks: EngineeringChecks,
|
|
pumpDiag: ReturnType<typeof computePumpPlacement>
|
|
) {
|
|
const payload = {
|
|
exportedAt: new Date().toISOString(),
|
|
checks,
|
|
pumpPlacement: pumpDiag,
|
|
runMetadata: result?.runMetadata ?? null,
|
|
comparison: result?.comparison ?? null,
|
|
warnings: {
|
|
parse: result?.parseWarnings ?? [],
|
|
solver: result?.solver?.warnings ?? []
|
|
}
|
|
};
|
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function Kpi({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="kpi-cell">
|
|
<div className="kpi-label">{label}</div>
|
|
<div className="kpi-val">{value}</div>
|
|
</div>
|
|
);
|
|
}
|