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:
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user