feat: polish rod and trajectory UX interactions

Improve Majic Rod Solver usability with denser table controls, clearer unit-aware guidance, and stronger accessibility affordances for keyboard/screen-reader workflows.

Made-with: Cursor
This commit is contained in:
2026-04-17 08:16:11 -06:00
parent 2a6cee21f8
commit 64d4492c60
7 changed files with 163 additions and 19 deletions

View File

@@ -149,6 +149,8 @@ html, body, #root {
@media (max-width: 880px) {
.tab-grid.two, .tab-grid.three { grid-template-columns: 1fr; }
.panel-row { grid-template-columns: 140px 1fr; }
.action-row-inline { flex-direction: column; align-items: stretch; }
.action-row-inline .btn { width: 100%; }
}
/* Inputs */
@@ -202,6 +204,10 @@ html, body, #root {
align-items: center;
margin: 4px 0 8px 0;
}
.action-row-inline {
justify-content: flex-end;
margin: 0;
}
.action-row { display: flex; justify-content: flex-end; gap: 8px; padding-top: 10px; }
/* Tables */
@@ -241,8 +247,43 @@ html, body, #root {
outline-offset: -1px;
background: rgba(56, 189, 248, 0.1);
}
.data-table tbody tr:focus-within td {
background: rgba(56, 189, 248, 0.08);
}
.data-table tbody td .panel-input { padding: 3px 6px; font-size: 11px; }
.data-table .empty-row { text-align: center; color: var(--text-muted); padding: 18px; }
.data-table-compact thead th { padding: 4px 6px; font-size: 11px; }
.data-table-compact tbody td { padding: 2px 4px; }
.data-table-compact tbody td .panel-input { padding: 2px 4px; font-size: 10px; }
.table-actions-col {
position: sticky;
right: 0;
z-index: 2;
background: var(--panel-3);
box-shadow: -1px 0 0 var(--border);
}
.data-table thead .table-actions-col {
background: var(--panel);
z-index: 3;
}
.data-table tbody tr:focus-within .table-actions-col,
.data-table tbody tr.row-selected .table-actions-col {
background: #162338;
}
.data-table .btn:focus-visible,
.data-table .panel-input:focus-visible,
.data-table select:focus-visible,
.data-table input[type="checkbox"]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.18);
}
.data-table-compact .btn {
min-height: 24px;
}
/* KPI grid */
.kpi-grid {
@@ -338,6 +379,18 @@ html, body, #root {
.uplot-host .u-legend { color: var(--text-dim); font-size: 11px; }
.uplot-host .u-wrap { background: var(--panel-3); }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* 3D wellbore */
.wellbore-3d-wrap {
border: 1px solid var(--border);

View File

@@ -4,6 +4,7 @@ export type CheckboxFieldProps = {
onChange: (checked: boolean) => void;
label?: string;
disabled?: boolean;
ariaLabel?: string;
};
export function CheckboxField(props: CheckboxFieldProps) {
@@ -12,6 +13,7 @@ export function CheckboxField(props: CheckboxFieldProps) {
<input
id={props.id}
type="checkbox"
aria-label={props.ariaLabel}
checked={props.checked}
disabled={props.disabled}
onChange={(e) => props.onChange(e.target.checked)}

View File

@@ -12,6 +12,7 @@ type Props = { store: CaseStore };
export function FluidTab({ store }: Props) {
const { state, update } = store;
const isMetric = state.unitsSelection === 1;
const estimated = useMemo(
() => ({
@@ -65,7 +66,7 @@ export function FluidTab({ store }: Props) {
label="Tubing Gradient"
htmlFor="tubingGrad"
hint={
state.unitsSelection === 1
isMetric
? "Pa/m when SI (UnitsSelection=1); from XML"
: "psi/ft imperial oilfield; converted to Pa/m in the API"
}
@@ -79,16 +80,19 @@ export function FluidTab({ store }: Props) {
</Row>
<div className="button-row" style={{ flexWrap: "wrap", gap: 8 }}>
<button type="button" className="btn" onClick={applyEstimate}>
Apply liquid hydrostatic estimate
Apply estimate to Tubing Gradient
</button>
<span className="panel-note">
Heuristic bulk-liquid ρ(water cut, API, water SG) gradient {" "}
{state.unitsSelection === 1
{isMetric
? `${estimated.paM.toFixed(1)} Pa/m`
: `${estimated.psiPerFt.toFixed(4)} psi/ft`}
. Not wired into the C solve yet (see COMPUTE_PLAN).
</span>
</div>
<p className="panel-note" style={{ marginTop: 4 }}>
Dual reference: {estimated.psiPerFt.toFixed(4)} psi/ft · {estimated.paM.toFixed(1)} Pa/m
</p>
</Fieldset>
</div>
);

View File

@@ -14,6 +14,7 @@ type Props = { store: CaseStore };
export function PumpTab({ store }: Props) {
const { state, update } = store;
const isMetric = state.unitsSelection === 1;
const pumpOptions = useMemo(() => {
const mmVals = PUMP_PLUNGER_INCH_OPTIONS.map((inchVal) => pumpDiameterMmFromInches(inchVal));
@@ -37,7 +38,11 @@ export function PumpTab({ store }: Props) {
<Row
label="Plunger diameter"
htmlFor="plungerDiam"
hint="Stored as mm in the case file when above 2 (same convention as rod OD)."
hint={
isMetric
? "Metric-first display with imperial equivalent in each option."
: "Imperial-first display with metric equivalent in each option."
}
>
<SelectField
id="plungerDiam"
@@ -47,7 +52,11 @@ export function PumpTab({ store }: Props) {
ariaLabel="Pump plunger diameter"
/>
</Row>
<Row label="Pump Friction" htmlFor="pumpFric" hint="lbf (imperial)">
<Row
label="Pump Friction"
htmlFor="pumpFric"
hint={isMetric ? "N (SI case)" : "lbf (imperial oilfield case)"}
>
<NumberField
id="pumpFric"
value={state.pumpFriction}
@@ -55,7 +64,11 @@ export function PumpTab({ store }: Props) {
onChange={(v) => update("pumpFriction", v)}
/>
</Row>
<Row label="Pump Intake Pressure" htmlFor="pumpIntake" hint="psi (imperial)">
<Row
label="Pump Intake Pressure"
htmlFor="pumpIntake"
hint={isMetric ? "Pa (SI case)" : "psi (imperial oilfield case)"}
>
<NumberField
id="pumpIntake"
value={state.pumpIntakePressure}

View File

@@ -89,9 +89,23 @@ export function ResultsTab({
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);
@@ -240,8 +254,7 @@ export function ResultsTab({
</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 (deg per
100 ft MD).
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">
@@ -251,7 +264,7 @@ export function ResultsTab({
<th>MD start</th>
<th>MD end</th>
<th>ΔMD</th>
<th>DLS (deg/100)</th>
<th>DLS ({dlsUnitLabel})</th>
</tr>
</thead>
<tbody>
@@ -449,6 +462,18 @@ export function ResultsTab({
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>
@@ -456,14 +481,16 @@ export function ResultsTab({
<th>XML field</th>
<th>Category</th>
<th>In file</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{result.fieldTraceability.fields.map((row) => (
{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>

View File

@@ -57,6 +57,7 @@ function diameterOptionsFor(rowDiameter: number) {
export function RodStringTab({ store }: Props) {
const { state, addTaperRow, removeTaperRow, updateTaperRow, setTaper, update } = store;
const [taperLengthMode, setTaperLengthMode] = useState<"length" | "count">("length");
const [compactRows, setCompactRows] = useState(false);
const depthUnit = state.unitsSelection === 1 ? "m" : "ft";
@@ -102,6 +103,13 @@ export function RodStringTab({ store }: Props) {
<abbr title="Approximate catalog values for workflow only">heuristic</abbr> verify for
your vendor tables.
</p>
{taperLengthMode === "count" && (
<p className="panel-note">
Joint counts are converted using <code>RodLengthForSteel</code> /{" "}
<code>RodLengthForFiberglass</code> from the case (fallbacks: 25 ft / 37.5 ft in
imperial). Non-integer equivalent counts are allowed.
</p>
)}
<div className="button-row">
<span className="panel-note">Section length:</span>
@@ -119,6 +127,15 @@ export function RodStringTab({ store }: Props) {
>
Joint count
</button>
<button
type="button"
className={`btn ${compactRows ? "btn-primary" : ""}`}
onClick={() => setCompactRows((v) => !v)}
aria-pressed={compactRows}
aria-label={compactRows ? "Disable compact rows in rod table" : "Enable compact rows in rod table"}
>
{compactRows ? "Compact rows: ON" : "Compact rows: OFF"}
</button>
<button type="button" className="btn" onClick={() => addTaperRow()}>
Add Section
</button>
@@ -133,9 +150,12 @@ export function RodStringTab({ store }: Props) {
{totals.length.toFixed(1)} {depthUnit}
</span>
</div>
<p className="sr-only" aria-live="polite">
Rod table compact mode is {compactRows ? "enabled" : "disabled"}.
</p>
<div className="table-scroll">
<table className="data-table">
<table className={`data-table ${compactRows ? "data-table-compact" : ""}`}>
<thead>
<tr>
<th style={{ width: 36 }}>#</th>
@@ -148,8 +168,8 @@ export function RodStringTab({ store }: Props) {
<th>Guide</th>
<th># guides</th>
<th>Guide type</th>
<th>Rod guide wt</th>
<th style={{ width: 56 }}></th>
<th>Guide wt (lb/ft)</th>
<th className="table-actions-col" style={{ width: 56 }}></th>
</tr>
</thead>
<tbody>
@@ -221,6 +241,7 @@ export function RodStringTab({ store }: Props) {
<CheckboxField
id={`taper-${i}-guides`}
label=""
ariaLabel={`Enable guides for taper ${i + 1}`}
checked={row.guidesEnabled}
onChange={(checked) =>
updateTaperRow(i, {
@@ -244,6 +265,7 @@ export function RodStringTab({ store }: Props) {
<SelectField
value={row.guideTypeToken}
options={GUIDE_TYPE_OPTIONS}
disabled={!row.guidesEnabled}
onChange={(v) => updateTaperRow(i, { guideTypeToken: v })}
ariaLabel={`Taper ${i + 1} guide type`}
/>
@@ -252,16 +274,18 @@ export function RodStringTab({ store }: Props) {
<NumberField
value={row.rodGuideWeightLbfPerFt}
step={0.01}
disabled={!row.guidesEnabled}
onChange={(v) => updateTaperRow(i, { rodGuideWeightLbfPerFt: v })}
ariaLabel={`Taper ${i + 1} rod guide weight`}
/>
</td>
<td>
<td className="table-actions-col">
<button
type="button"
className="btn btn-danger"
onClick={() => removeTaperRow(i)}
aria-label={`Remove taper ${i + 1}`}
title={`Remove taper row ${i + 1}`}
>
</button>

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import type { CaseStore } from "../../state/useCaseStore";
import { Fieldset } from "../common/Fieldset";
import { NumberField } from "../common/NumberField";
@@ -7,6 +8,7 @@ type Props = { store: CaseStore };
export function TrajectoryTab({ store }: Props) {
const { state, addSurveyRow, insertSurveyRowBelow, removeSurveyRow, updateSurveyRow, setSurvey } =
store;
const [compactRows, setCompactRows] = useState(false);
function loadVertical() {
const depth = state.pumpDepth || 1727;
@@ -40,6 +42,10 @@ export function TrajectoryTab({ store }: Props) {
Enter survey stations from surface to TD. Minimum curvature method (API
Bulletin D20) is applied in the solver. First row should be 0 MD, 0 Inc, 0 Az.
</p>
<p className="panel-note">
Use <strong>Insert below</strong> to add stations mid-trajectory without rebuilding the
full table.
</p>
<div className="button-row">
<button type="button" className="btn" onClick={() => addSurveyRow()}>
Add Station
@@ -53,20 +59,34 @@ export function TrajectoryTab({ store }: Props) {
<button type="button" className="btn" onClick={loadDeviatedExample}>
Load Deviated Example
</button>
<button
type="button"
className={`btn ${compactRows ? "btn-primary" : ""}`}
onClick={() => setCompactRows((v) => !v)}
aria-pressed={compactRows}
aria-label={
compactRows ? "Disable compact rows in trajectory table" : "Enable compact rows in trajectory table"
}
>
{compactRows ? "Compact rows: ON" : "Compact rows: OFF"}
</button>
<span className="panel-note" style={{ marginLeft: "auto" }}>
{state.survey.length} station{state.survey.length === 1 ? "" : "s"}
</span>
</div>
<p className="sr-only" aria-live="polite">
Trajectory table compact mode is {compactRows ? "enabled" : "disabled"}.
</p>
<div className="table-scroll">
<table className="data-table">
<table className={`data-table ${compactRows ? "data-table-compact" : ""}`}>
<thead>
<tr>
<th style={{ width: 40 }}>#</th>
<th>MD</th>
<th>Inclination (°)</th>
<th>Azimuth (°)</th>
<th style={{ width: 120 }}>Actions</th>
<th className="table-actions-col" style={{ width: 120 }}>Actions</th>
</tr>
</thead>
<tbody>
@@ -100,13 +120,14 @@ export function TrajectoryTab({ store }: Props) {
ariaLabel={`Azi ${i + 1}`}
/>
</td>
<td>
<div className="button-row" style={{ gap: 4 }}>
<td className="table-actions-col">
<div className="button-row action-row-inline" style={{ gap: 4 }}>
<button
type="button"
className="btn"
onClick={() => insertSurveyRowBelow(i)}
title="Insert a station after this row"
aria-label={`Insert station below row ${i + 1}`}
>
Insert below
</button>
@@ -124,7 +145,7 @@ export function TrajectoryTab({ store }: Props) {
))}
{state.survey.length === 0 && (
<tr>
<td colSpan={6} className="empty-row">
<td colSpan={5} className="empty-row">
No survey stations. Add rows or load a preset.
</td>
</tr>