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) { @media (max-width: 880px) {
.tab-grid.two, .tab-grid.three { grid-template-columns: 1fr; } .tab-grid.two, .tab-grid.three { grid-template-columns: 1fr; }
.panel-row { grid-template-columns: 140px 1fr; } .panel-row { grid-template-columns: 140px 1fr; }
.action-row-inline { flex-direction: column; align-items: stretch; }
.action-row-inline .btn { width: 100%; }
} }
/* Inputs */ /* Inputs */
@@ -202,6 +204,10 @@ html, body, #root {
align-items: center; align-items: center;
margin: 4px 0 8px 0; 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; } .action-row { display: flex; justify-content: flex-end; gap: 8px; padding-top: 10px; }
/* Tables */ /* Tables */
@@ -241,8 +247,43 @@ html, body, #root {
outline-offset: -1px; outline-offset: -1px;
background: rgba(56, 189, 248, 0.1); 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 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 .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 */
.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-legend { color: var(--text-dim); font-size: 11px; }
.uplot-host .u-wrap { background: var(--panel-3); } .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 */ /* 3D wellbore */
.wellbore-3d-wrap { .wellbore-3d-wrap {
border: 1px solid var(--border); border: 1px solid var(--border);

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ type Props = { store: CaseStore };
export function PumpTab({ store }: Props) { export function PumpTab({ store }: Props) {
const { state, update } = store; const { state, update } = store;
const isMetric = state.unitsSelection === 1;
const pumpOptions = useMemo(() => { const pumpOptions = useMemo(() => {
const mmVals = PUMP_PLUNGER_INCH_OPTIONS.map((inchVal) => pumpDiameterMmFromInches(inchVal)); const mmVals = PUMP_PLUNGER_INCH_OPTIONS.map((inchVal) => pumpDiameterMmFromInches(inchVal));
@@ -37,7 +38,11 @@ export function PumpTab({ store }: Props) {
<Row <Row
label="Plunger diameter" label="Plunger diameter"
htmlFor="plungerDiam" 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 <SelectField
id="plungerDiam" id="plungerDiam"
@@ -47,7 +52,11 @@ export function PumpTab({ store }: Props) {
ariaLabel="Pump plunger diameter" ariaLabel="Pump plunger diameter"
/> />
</Row> </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 <NumberField
id="pumpFric" id="pumpFric"
value={state.pumpFriction} value={state.pumpFriction}
@@ -55,7 +64,11 @@ export function PumpTab({ store }: Props) {
onChange={(v) => update("pumpFriction", v)} onChange={(v) => update("pumpFriction", v)}
/> />
</Row> </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 <NumberField
id="pumpIntake" id="pumpIntake"
value={state.pumpIntakePressure} value={state.pumpIntakePressure}

View File

@@ -89,9 +89,23 @@ export function ResultsTab({
const fdm = result?.solvers?.fdm ?? primary ?? null; const fdm = result?.solvers?.fdm ?? primary ?? null;
const [overlayMode, setOverlayMode] = useState<OverlayMode>("depth"); const [overlayMode, setOverlayMode] = useState<OverlayMode>("depth");
const [selectedSegment, setSelectedSegment] = useState<number | null>(null); 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 trajectorySegments = useMemo(() => buildTrajectorySegments(caseState.survey), [caseState.survey]);
const sideLoadProfile = primary?.profiles?.sideLoadProfile ?? null; const sideLoadProfile = primary?.profiles?.sideLoadProfile ?? null;
const pumpDiag = useMemo(() => computePumpPlacement(caseState), [caseState]); 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 dynacardData = useMemo<AlignedData | null>(() => {
const fdmSeries = toSeries(fdm ?? undefined); const fdmSeries = toSeries(fdm ?? undefined);
@@ -240,8 +254,7 @@ export function ResultsTab({
</Fieldset> </Fieldset>
<Fieldset legend="Trajectory Analytics"> <Fieldset legend="Trajectory Analytics">
<p className="panel-note" style={{ marginBottom: 8 }}> <p className="panel-note" style={{ marginBottom: 8 }}>
Click a row or 3D segment to cross-highlight. DLS is shown numerically only (deg per Click a row or 3D segment to cross-highlight. DLS is shown numerically only ({dlsUnitLabel}).
100 ft MD).
</p> </p>
<div className="table-scroll" style={{ maxHeight: 250 }}> <div className="table-scroll" style={{ maxHeight: 250 }}>
<table className="data-table"> <table className="data-table">
@@ -251,7 +264,7 @@ export function ResultsTab({
<th>MD start</th> <th>MD start</th>
<th>MD end</th> <th>MD end</th>
<th>ΔMD</th> <th>ΔMD</th>
<th>DLS (deg/100)</th> <th>DLS ({dlsUnitLabel})</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -449,6 +462,18 @@ export function ResultsTab({
Categories mirror <code>docs/engineering/field-traceability.md</code> ( Categories mirror <code>docs/engineering/field-traceability.md</code> (
physics, metadata, parseCalibration, payloadInactive, parsedUnused). physics, metadata, parseCalibration, payloadInactive, parsedUnused).
</p> </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 }}> <div className="table-scroll" style={{ maxHeight: 280 }}>
<table className="data-table"> <table className="data-table">
<thead> <thead>
@@ -456,14 +481,16 @@ export function ResultsTab({
<th>XML field</th> <th>XML field</th>
<th>Category</th> <th>Category</th>
<th>In file</th> <th>In file</th>
<th>Notes</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{result.fieldTraceability.fields.map((row) => ( {filteredTraceability.map((row) => (
<tr key={row.xmlKey} title={row.notes || undefined}> <tr key={row.xmlKey} title={row.notes || undefined}>
<td className="mono">{row.xmlKey}</td> <td className="mono">{row.xmlKey}</td>
<td>{row.category}</td> <td>{row.category}</td>
<td>{row.presentInXml ? "yes" : "—"}</td> <td>{row.presentInXml ? "yes" : "—"}</td>
<td>{row.notes}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

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

View File

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