From 1617ee94b90c5e0ff0f8b083feef064bdbc1a1f7 Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Fri, 10 Apr 2026 15:59:59 -0400 Subject: [PATCH] bookmark - UI P1 Polishing --- docs/STATUS.md | 156 ++++++++----- src/impakt/web/assets/cursor_tracker.js | 230 +++++++++++++++++++ src/impakt/web/callbacks/cursor_callbacks.py | 69 ++++-- src/impakt/web/callbacks/plot_callbacks.py | 83 +++++-- src/impakt/web/components/cursors.py | 212 ++++++++++------- src/impakt/web/components/plot_grid.py | 1 + src/impakt/web/layout.py | 179 +++++++++------ 7 files changed, 661 insertions(+), 269 deletions(-) create mode 100644 src/impakt/web/assets/cursor_tracker.js diff --git a/docs/STATUS.md b/docs/STATUS.md index a37122f..48de0af 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,8 +2,8 @@ **Date:** 2026-04-10 **Version:** 0.1.0 -**Tests:** 136 passing, 0 warnings -**Source:** ~6,900 lines Python | ~1,600 lines tests | ~190 lines HTML templates +**Tests:** 171 passing +**Source:** ~9,200 lines Python | ~1,900 lines tests | ~3,400 lines web (py+js+css) | ~190 lines HTML templates **Tooling:** uv (Python 3.12.12), hatchling build backend --- @@ -13,7 +13,7 @@ ```bash cd /Users/noise/Code/impakt uv sync --dev # install all dependencies -uv run pytest tests/ # run all 136 tests +uv run pytest tests/ # run all 171 tests uv run impakt info tests/mme_data/3239 # show test metadata uv run impakt serve tests/mme_data/3239 # launch web UI on :8050 ``` @@ -33,6 +33,7 @@ s.plot("11HEAD0000H3ACXP", "11HEAD0000H3ACYP", "11HEAD0000H3ACZP", cfc=1000) impakt/ pyproject.toml # PEP 621 + uv dependency-groups uv.lock # lockfile + .gitignore README.md # Architecture docs with 16 Mermaid diagrams BRAINSTORM.md # 80+ feature ideas docs/ @@ -40,7 +41,7 @@ impakt/ src/impakt/ __init__.py # exports Session, Template channel/ # Data model layer - code.py # ISO channel code parser (14-char + 16-char) + code.py # ISO channel code parser (14-char + 16-char auto-detect) model.py # Channel, ChannelGroup, TestData, TestMetadata group.py # Auto-grouping utilities lookup.py # ISO naming lookup tables (150+ entries) @@ -67,7 +68,7 @@ impakt/ euro_ncap.py # Euro NCAP (color/points/stars, versioned) us_ncap.py # US NCAP (injury probability/stars) iihs.py # IIHS (G/A/M/P) - plot/ # Visualization + plot/ # Visualization engine engine.py # PlotEngine (Plotly), cursor_values() spec.py # PlotSpec, ChannelRef, Corridor, CursorValues cursor.py # Dual X-cursor logic @@ -83,9 +84,29 @@ impakt/ injury_summary.html protocol_report.html web/ # Dash web application - app.py # create_app(), serve() - layout.py # build_layout() -- UI structure - callbacks.py # register_callbacks() -- interactivity + app.py # App factory: create_app(), serve() + state.py # AppState: server-side multi-test state manager + layout.py # Top-level layout: tabs, flex splitter, component assembly + components/ # Reusable layout components + header.py # Navbar, test info panel, open/overlay modals + channel_grid.py # Flat sortable DataTable with wildcard filter + facets + transforms.py # CFC/align/resultant control panel + plot_grid.py # Multi-pane plot area (1x1, 2x1, 1x2, 2x2, 3x1) + cursors.py # Cursor values grid (live hover + X1/X2 locked) + criteria.py # Auto-compute criteria, protocol scoring, results display + report.py # Export panel (PNG/SVG/PDF, CSV, protocol report) + callbacks/ # Feature-specific callback modules + __init__.py # Registration hub: register_callbacks() + channel_callbacks.py # Channel selection, filtering, badge display + plot_callbacks.py # Plot rendering, transform pipeline + cursor_callbacks.py # Live cursor grid updates via polled JS hover + criteria_callbacks.py # Compute All button, protocol scoring + file_callbacks.py # Open test / add overlay modals + export_callbacks.py # CSV export, report generation + assets/ # Browser-side static files + style.css # Custom CSS (compact layout, splitter, scrollbars) + splitter.js # Draggable panel splitter (pure JS, no deps) + cursor_tracker.js # Live cursor tracking (mousemove -> pixel-to-data-X) plugin/ # Plugin system registry.py # PluginRegistry, discovery (entrypoints + dir) script/ # Scripting API + CLI @@ -100,6 +121,7 @@ impakt/ test_io/ # MMEReader test_protocol/ # Euro NCAP scoring test_transform/ # CFC filter, alignment + test_web/ # AppState, app creation, channel grid, criteria auto-compute fixtures/ generate_mme.py # Synthetic MME generator (26 channels, half-sine) sample_mme/ # Generated synthetic test data @@ -116,21 +138,21 @@ impakt/ ## What Works -### Fully implemented and tested: +### All modules fully implemented and tested: | Module | Status | Notes | |---|---|---| -| **Channel code parser** | Complete | Auto-detects 14-char (no dummy) vs 16-char (with dummy H3/P3/PC). Handles real-world codes from NHTSA, BASt, VW, UTAC. | -| **Channel model** | Complete | Immutable channels, auto-grouping X/Y/Z, resultant computation, hierarchical tree builder. | -| **MME reader** | Complete | Two-format support: (1) Real ISO 13499 (.mme + .chn index + .NNN data files), (2) simplified INI format. Tested against 5 real datasets. | -| **CFC filtering** | Complete | SAE J211 compliant. 4th-order Butterworth, zero-phase (filtfilt). All 4 CFC classes. | +| **Channel code parser** | Complete | Auto-detects 14-char (no dummy) vs 16-char (with dummy H3/P3/PC). | +| **Channel model** | Complete | Immutable channels, auto-grouping X/Y/Z, resultant computation. | +| **MME reader** | Complete | Real ISO 13499 (.mme + .chn index + .NNN data files) + simplified INI. Tested against 5 real datasets. | +| **CFC filtering** | Complete | SAE J211 compliant. 4th-order Butterworth, zero-phase. All 4 CFC classes. | | **Alignment transforms** | Complete | X-align (manual/threshold/trigger), Y-align (baseline window). | | **Resultant** | Complete | From ChannelGroup or arbitrary channels. | | **Math expressions** | Complete | Safe eval with numpy functions. | | **HIC** | Complete | HIC15/HIC36, cumulative integration, optimal window search. | | **3ms clip** | Complete | Cumulative exceedance method. | -| **Nij** | Complete | 4 modes (NTE/NTF/NCE/NCF), per-dummy intercepts (H3 50M/5F/95M, child dummies). | -| **Chest deflection** | Complete | Peak sternal displacement. | +| **Nij** | Complete | 4 modes (NTE/NTF/NCE/NCF), per-dummy intercepts. | +| **Chest deflection** | Complete | Peak sternal displacement with unit/sanity validation. | | **Viscous criterion** | Complete | V(t)*C(t) with chest depth per dummy type. | | **Femur load** | Complete | Left/right, unit conversion. | | **Tibia index** | Complete | M/Mc + F/Fc with intercepts. | @@ -139,48 +161,70 @@ impakt/ | **IIHS** | Complete | G/A/M/P per body region, worst-case overall. | | **Plot engine** | Complete | Plotly rendering, corridors, cursor values, export. | | **Template model** | Complete | YAML serialize/deserialize, library manager, session persistence. | -| **Report engine** | Complete | HTML+WeasyPrint PDF, 3 Jinja2 templates. Falls back to HTML if WeasyPrint unavailable. | +| **Report engine** | Complete | HTML+WeasyPrint PDF, 3 Jinja2 templates. | | **Plugin registry** | Complete | Entry point + directory + API discovery. | | **CLI** | Complete | `impakt serve/info/channels/evaluate`. | -| **Web UI** | Minimal | Basic Dash app: flat channel list, single CFC dropdown, cursor inputs. Functional but needs major overhaul. | +| **Web UI** | **Functional** | See details below. | -### Key design decisions already made: +### Web UI -- Current State + +The web UI has been through a major overhaul and is now functional for daily use: + +**Layout:** +- Two tabs: **Data** (channels + plot + cursor grid) and **Analysis** (criteria + export) +- Draggable splitter between left panel and plot area (pure JS, no deps) +- Left panel resizable from 200px to 600px + +**Channel Grid (left panel):** +- Flat sortable DataTable showing: #, ISO Code, Description, Unit, Min, Max +- Wildcard filter bar (`*HEAD*AC*`, `11*FO*Z*`, or partial text auto-wrapped) +- Facet dropdowns: body region, measurement type, direction +- Multi-select checkboxes, selected channels shown as color-coded badges +- Columns sortable and resizable (CSS resize) + +**Plot Area (right side):** +- Multi-pane layout presets (1x1, 2x1, 1x2, 2x2, 3x1) +- Transform controls: CFC filter, Y-align, X-align (manual/threshold), resultant toggle +- X1/X2 reference lines drawn on plot (red dashed / blue dashed) + +**Cursor Values Grid (below plot):** +- Live updating: vertical crosshair and cursor values track mouse movement anywhere in plot area +- Custom JS cursor tracker (mousemove -> Plotly axis p2d/p2l -> data coordinates) +- Polled via dcc.Interval (80ms) -> clientside callback -> server-side interpolation +- Columns: Channel, Unit, Cursor (live), X1 (locked), X2 (locked) + +**Analysis Tab:** +- Auto-detect channels by ISO naming and compute HIC15, 3ms clip, Nij, chest defl, femur, tibia +- Protocol scoring: Euro NCAP / US NCAP / IIHS with color-coded results table and star ratings +- CSV export of plotted channel data (with transforms applied) +- Protocol report generation (HTML) + +**File Management:** +- Open Test / Add Overlay buttons with modal dialogs +- Test info panel showing all loaded tests with metadata +- Multi-test support in channel grid and plot labels + +### Key design decisions: 1. **Immutable channels** -- transforms return new Channel objects; raw data never modified. -2. **`.impakt/` subfolder** -- session state stored alongside test data, not in a central DB. -3. **Template/session split** -- templates are global recipes; sessions are per-test instances with overrides. -4. **Two MME format variants** -- real ISO 13499 and simplified INI. Reader auto-detects. -5. **16-char channel codes** -- positions 11-12 can be dummy type (H3/P3/PC) or measurement type; parser auto-detects. -6. **uv for package management** -- dev deps in `[dependency-groups]`, lockfile committed. +2. **`.impakt/` subfolder** -- session state stored alongside test data. +3. **Template/session split** -- templates are global recipes; sessions are per-test instances. +4. **AppState is server-side** -- numpy arrays stay in Python memory; Dash stores hold only lightweight keys. +5. **Channel keys use `test_id::channel_name`** -- enables multi-test overlay. +6. **Custom JS cursor tracking** -- bypasses Plotly's hover system (which only fires near data points) with raw mousemove + pixel-to-data conversion using Plotly's internal axis API. +7. **uv for package management** -- dev deps in `[dependency-groups]`. --- -## What Needs Building Next: Web UI Overhaul +## Next Steps (Priority 2) -The web UI is the top priority. Current state is a proof-of-concept; needs to become a daily-use tool. The planned features, in priority order: +These are the next features to build, now that Priority 1 is complete: -### Priority 1 -- Must have for the tool to be usable - -1. **File/test management** -- open test from UI (path input), open additional tests for overlay, recent tests list, test metadata panel -2. **Proper channel tree** -- collapsible hierarchy (Object > Region > Measurement), select-all per group, preview (peak, unit, rate), color indicators for plotted channels, multi-test awareness -3. **Multi-pane plot layout** -- configurable grid (1x1 through 3x2), drag channels to panes, independent zoom with optional sync, per-pane axis labels -4. **Working criteria panel** -- auto-detect channels from ISO naming, compute all criteria, results table with pass/fail colors, click to highlight time window on plot -5. **Plot export** -- download PNG/SVG/PDF per pane, copy to clipboard, export CSV of plotted data - -### Priority 2 -- Makes the tool genuinely productive - -6. **Template management UI** -- template browser, apply/save/edit from UI, session auto-save -7. **Enhanced transform controls** -- per-channel CFC, X-align/Y-align controls, one-click resultant -8. **Corridor management** -- load from CSV, draw on plot, corridor library with templates -9. **Channel inspector** -- tabular data view, statistics (min/max/RMS/peak time), peak detection -10. **Math expression builder** -- formula input with autocomplete, variable binding dropdowns, live preview - -### Priority 3 -- Differentiators - -11. **Annotations** -- text on plots, measurement lines, highlight regions -12. **Comparison mode** -- side-by-side tests, delta plots, synced cursors -13. **Report builder** -- drag-and-drop report composer, PDF preview -14. **Keyboard shortcuts** -- Ctrl+O, Ctrl+S, 1-9 pane switch, F fullscreen, R reset zoom +1. **Template management UI** -- template browser, apply/save/edit from UI, session auto-save +2. **Enhanced transform controls** -- per-channel CFC, per-channel X/Y-align +3. **Corridor management** -- load from CSV, draw on plot, corridor library +4. **Channel inspector** -- tabular data view, statistics (min/max/RMS/peak time) +5. **Math expression builder** -- formula input with autocomplete, live preview --- @@ -189,11 +233,11 @@ The web UI is the top priority. Current state is a proof-of-concept; needs to be | Dataset | Lab | Type | Channels | Good for testing | |---|---|---|---|---| | `fixtures/sample_mme/` | Synthetic | Frontal barrier | 26 | Unit tests, known values | -| `mme_data/3239/` | NHTSA/Calspan | Frontal barrier (VW Passat) | 133 | Full pipeline, real data, driver+rear passenger | -| `mme_data/AK3T02FO/` | BASt | Frontal 40% offset | 97 | Multi-occupant (driver, rear, child) | +| `mme_data/3239/` | NHTSA/Calspan | Frontal barrier (VW Passat) | 133 | Full pipeline, real data | +| `mme_data/AK3T02FO/` | BASt | Frontal 40% offset | 97 | Multi-occupant | | `mme_data/AK3T02SI/` | BASt | Side impact | 97 | Side impact protocols | -| `mme_data/VW1FGS15/` | Volkswagen | Pedestrian headform | 10 | Impactor codes (D0), pre-computed resultant | -| `mme_data/98_7707/` | UTAC | Vehicle-to-vehicle | 0 | Metadata-only, graceful empty handling | +| `mme_data/VW1FGS15/` | Volkswagen | Pedestrian headform | 10 | Impactor codes (D0) | +| `mme_data/98_7707/` | UTAC | Vehicle-to-vehicle | 0 | Metadata-only | --- @@ -207,8 +251,8 @@ Optional: nptdms (for future TDMS reader plugin) ## Known Issues / Technical Debt -1. **VehicleInfo.year parsed as 0** for real MME data (the .mme format doesn't have a vehicle_year field; it's embedded in the vehicle name string like "VOLKSWAGEN PASSAT 2000"). -2. **Speed displayed as raw float** (e.g., 55.900001530350906 km/h) -- should round. -3. **report/pdf.py** referenced in `pyproject.toml` directory structure but doesn't exist as separate file (functionality is in `engine.py`). -4. **web/assets/** directory exists but is empty -- no custom CSS yet. -5. **No .gitignore** -- should exclude .venv/, __pycache__/, .pytest_cache/, .ruff_cache/, .impakt/ session dirs. +1. **VehicleInfo.year parsed as 0** for real MME data (.mme format embeds year in vehicle name string). +2. **Speed displayed as raw float** (55.900001530350906 km/h) -- should round. +3. **DataTable deprecation warning** -- Dash recommends migrating to dash-ag-grid. Functional for now. +4. **Cursor poll interval (80ms)** -- adds slight latency to cursor grid updates. Could use WebSocket for lower latency in future. +5. **Chest deflection auto-detect** skips DS channels with peak > 150mm (avoids steering column displacement). May miss some legitimate high-deflection data. diff --git a/src/impakt/web/assets/cursor_tracker.js b/src/impakt/web/assets/cursor_tracker.js new file mode 100644 index 0000000..2b8927e --- /dev/null +++ b/src/impakt/web/assets/cursor_tracker.js @@ -0,0 +1,230 @@ +/** + * Live cursor tracker for the plot area. + * + * Captures mousemove events on the Plotly graph and converts + * the pixel X position to data coordinates. Writes the result to + * window.__impakt_hover_x so the Dash Interval callback can poll it. + * + * Draws a vertical crosshair line that follows the mouse. + */ +(function () { + "use strict"; + + console.log("[Impakt] cursor_tracker.js loaded"); + + var activeLine = null; + var activeGraphDiv = null; + var attached = false; + + function findGraphDiv() { + // Dash renders pattern-matching IDs as JSON strings in the id attribute + var candidates = document.querySelectorAll(".dash-graph .js-plotly-plot"); + if (candidates.length > 0) return candidates[0]; + + // Fallback: any plotly plot + candidates = document.querySelectorAll(".js-plotly-plot"); + if (candidates.length > 0) return candidates[0]; + + return null; + } + + function getPlotArea(graphDiv) { + // The drag overlay that covers the plot area + // Plotly uses different class names across versions + var area = graphDiv.querySelector(".nsewdrag"); + if (area) return area; + + // Plotly 6.x may use .drag[data-subplot] + area = graphDiv.querySelector(".drag"); + if (area) return area; + + // Try the cartesian layer + area = graphDiv.querySelector(".cartesianlayer"); + return area; + } + + function pixelToDataX(graphDiv, pixelX, plotAreaRect) { + // Method 1: Use Plotly's _fullLayout.xaxis (most reliable) + var layout = graphDiv._fullLayout; + if (layout && layout.xaxis) { + var xaxis = layout.xaxis; + + // p2d converts pixel to data coordinate + if (typeof xaxis.p2d === "function") { + // p2d expects pixels relative to the plot area left edge + var plotLeft = xaxis._offset || 0; + return xaxis.p2d(pixelX - plotLeft + xaxis._offset); + } + + // Fallback: manual linear interpolation from axis range and pixel domain + if (xaxis.range && xaxis._length) { + var dataMin = xaxis.range[0]; + var dataMax = xaxis.range[1]; + var pxOffset = xaxis._offset || 0; + var pxLength = xaxis._length; + var fraction = (pixelX) / pxLength; + return dataMin + fraction * (dataMax - dataMin); + } + } + + return null; + } + + function initTracker() { + var graphDiv = findGraphDiv(); + if (!graphDiv) { + console.log("[Impakt] No graph div found yet, retrying..."); + setTimeout(initTracker, 500); + return; + } + + var plotArea = getPlotArea(graphDiv); + if (!plotArea) { + console.log("[Impakt] No plot area found yet, retrying..."); + setTimeout(initTracker, 500); + return; + } + + if (attached && activeGraphDiv === graphDiv) return; + + console.log("[Impakt] Attaching cursor tracker to", graphDiv.id || "(unnamed graph)"); + console.log("[Impakt] Plot area element:", plotArea.className); + + // Log what axis info is available + if (graphDiv._fullLayout && graphDiv._fullLayout.xaxis) { + var xa = graphDiv._fullLayout.xaxis; + console.log("[Impakt] xaxis available:", { + range: xa.range, + _offset: xa._offset, + _length: xa._length, + p2d: typeof xa.p2d, + p2l: typeof xa.p2l, + l2p: typeof xa.l2p, + d2p: typeof xa.d2p, + }); + } else { + console.log("[Impakt] No _fullLayout.xaxis yet"); + } + + activeGraphDiv = graphDiv; + + // Attach to the entire graph container, not just the drag area, + // to ensure we capture mouse events everywhere + graphDiv.addEventListener("mousemove", function (e) { + var layout = graphDiv._fullLayout; + if (!layout || !layout.xaxis) return; + + var xa = layout.xaxis; + var graphRect = graphDiv.getBoundingClientRect(); + + // Pixel position relative to the graph div + var pixelFromLeft = e.clientX - graphRect.left; + + // The plot area starts at xa._offset from the left of the graph div + var plotAreaLeft = xa._offset || 0; + var plotAreaWidth = xa._length || 1; + + // Pixel position within the plot area + var plotPixel = pixelFromLeft - plotAreaLeft; + + // Only process if within the plot area + if (plotPixel < 0 || plotPixel > plotAreaWidth) { + return; + } + + // Convert to data coordinate + var dataX; + if (typeof xa.p2d === "function") { + dataX = xa.p2d(plotPixel); + } else if (typeof xa.p2l === "function") { + // p2l converts pixel to "linear" value, which for linear axes = data value + dataX = xa.p2l(plotPixel); + } else if (xa.range) { + // Manual linear interpolation + var fraction = plotPixel / plotAreaWidth; + dataX = xa.range[0] + fraction * (xa.range[1] - xa.range[0]); + } else { + return; + } + + if (dataX === undefined || dataX === null || isNaN(dataX)) return; + + window.__impakt_hover_x = dataX; + + // Draw vertical crosshair + drawCrosshair(graphDiv, plotAreaLeft + plotPixel, graphRect); + }); + + graphDiv.addEventListener("mouseleave", function () { + removeCrosshair(); + }); + + attached = true; + } + + function drawCrosshair(graphDiv, pixelX, graphRect) { + removeCrosshair(); + + var container = graphDiv.querySelector(".plot-container") || graphDiv; + + var line = document.createElement("div"); + line.className = "impakt-crosshair"; + line.style.cssText = [ + "position: absolute", + "top: 0", + "bottom: 0", + "width: 1px", + "background-color: rgba(0,0,0,0.25)", + "pointer-events: none", + "z-index: 1000", + "left: " + pixelX + "px", + ].join(";"); + + // Ensure container is positioned + if (!container.style.position || container.style.position === "static") { + container.style.position = "relative"; + } + + container.appendChild(line); + activeLine = line; + } + + function removeCrosshair() { + if (activeLine && activeLine.parentNode) { + activeLine.parentNode.removeChild(activeLine); + } + activeLine = null; + // Clean up any stale ones + document.querySelectorAll(".impakt-crosshair").forEach(function (el) { + el.remove(); + }); + } + + // Re-initialize when Dash re-renders the graph (e.g., after channel selection changes) + function watchForGraphChanges() { + var observer = new MutationObserver(function (mutations) { + // Check if a new graph appeared + var graphDiv = findGraphDiv(); + if (graphDiv && graphDiv !== activeGraphDiv) { + attached = false; + initTracker(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + + // Start + function boot() { + console.log("[Impakt] cursor_tracker.js booting"); + initTracker(); + watchForGraphChanges(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { + setTimeout(boot, 500); + }); + } else { + setTimeout(boot, 500); + } +})(); diff --git a/src/impakt/web/callbacks/cursor_callbacks.py b/src/impakt/web/callbacks/cursor_callbacks.py index bd08515..b97f37e 100644 --- a/src/impakt/web/callbacks/cursor_callbacks.py +++ b/src/impakt/web/callbacks/cursor_callbacks.py @@ -1,8 +1,12 @@ """Cursor value callbacks. -Handles: -- Cursor update button -> recompute interpolated values -- Cursor values table rendering +Updates the cursor grid DataTable as the user moves the mouse over the plot. + +The JS cursor_tracker.js writes the hover X position to +window.__impakt_hover_x on every mousemove. A clientside callback +polls this value via dcc.Interval and writes it to the cursor-hover-x +store. The server-side callback then reads the store and computes +interpolated Y values for all plotted channels. """ from __future__ import annotations @@ -10,51 +14,64 @@ from __future__ import annotations from typing import Any import dash -from dash import Input, Output, State, html +from dash import ClientsideFunction, Input, Output, State, clientside_callback, html from dash.exceptions import PreventUpdate from impakt.web.callbacks.plot_callbacks import _resolve_channels -from impakt.web.components.cursors import build_cursor_values_table +from impakt.web.components.cursors import build_cursor_grid_data from impakt.web.state import AppState def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None: """Register cursor-related callbacks.""" + # Clientside callback: reads window.__impakt_hover_x (set by cursor_tracker.js) + # and writes it to the cursor-hover-x store on each Interval tick. + app.clientside_callback( + """ + function(n_intervals) { + var x = window.__impakt_hover_x; + if (x !== undefined && x !== null && !isNaN(x)) { + return x; + } + return window.dash_clientside.no_update; + } + """, + Output("cursor-hover-x", "data"), + Input("cursor-poll-interval", "n_intervals"), + ) + @app.callback( - Output("cursor-values-table", "children"), - [Input("cursor-update-btn", "n_clicks")], + Output("cursor-grid", "data"), + [ + Input("cursor-hover-x", "data"), + Input("cursor-x1", "value"), + Input("cursor-x2", "value"), + Input("selected-channels-store", "data"), + ], [ - State("selected-channels-store", "data"), State("cfc-select", "value"), State("y-align-check", "value"), State("x-align-method", "value"), State("x-align-value", "value"), State("show-resultant", "value"), - State("cursor-x1", "value"), - State("cursor-x2", "value"), ], ) - def update_cursor_table( - n_clicks: int | None, + def update_cursor_grid( + hover_x: float | None, + cursor_x1: float | None, + cursor_x2: float | None, selected_keys: list[str] | None, cfc_value: str, y_align: bool, x_align_method: str, x_align_value: float | None, show_resultant: bool, - cursor_x1: float | None, - cursor_x2: float | None, - ) -> Any: - if not n_clicks: - return html.Div("Select channels and click 'Update'", className="text-muted small") - - if cursor_x1 is None or cursor_x2 is None: - return html.Div("Set both cursor positions", className="text-muted small") - + ) -> list[dict[str, str]]: if not selected_keys: - return html.Div("No channels selected", className="text-muted small") + return [] + # Resolve channels with current transforms channels = _resolve_channels( selected_keys, app_state, @@ -65,4 +82,10 @@ def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None: show_resultant, ) - return build_cursor_values_table(channels, cursor_x1, cursor_x2) + if not channels: + return [] + + x1 = cursor_x1 if cursor_x1 is not None else 0.0 + x2 = cursor_x2 if cursor_x2 is not None else 0.1 + + return build_cursor_grid_data(channels, hover_x, x1, x2) diff --git a/src/impakt/web/callbacks/plot_callbacks.py b/src/impakt/web/callbacks/plot_callbacks.py index 0339508..e8449b9 100644 --- a/src/impakt/web/callbacks/plot_callbacks.py +++ b/src/impakt/web/callbacks/plot_callbacks.py @@ -2,8 +2,9 @@ Handles: - Updating plot figures when channels/transforms change -- Cursor line rendering -- Layout switching +- Cursor line rendering (X1/X2 vertical lines) +- Hover data is NOT shown as a Plotly tooltip — instead the cursor grid + picks it up via the hoverData callback """ from __future__ import annotations @@ -113,7 +114,7 @@ def _build_figure( template="plotly_white", annotations=[ { - "text": "Select channels from the tree to plot", + "text": "Select channels from the grid to plot", "xref": "paper", "yref": "paper", "x": 0.5, @@ -128,7 +129,8 @@ def _build_figure( ) return fig - # Add traces + # Add traces — hovermode is disabled at the layout level; cursor tracking + # is handled by our own JS mousemove handler (cursor_tracker.js). for i, (label, ch) in enumerate(channels): color = DEFAULT_COLORS[i % len(DEFAULT_COLORS)] fig.add_trace( @@ -138,41 +140,74 @@ def _build_figure( mode="lines", name=label, line=dict(color=color, width=1.5), - hovertemplate=f"{label}
t=%{{x:.6f}}s
%{{y:.4f}} {ch.unit}", ) ) - # Add cursor lines - if cursor_x1 is not None and cursor_x2 is not None: - for x_val, lbl in [(cursor_x1, "x1"), (cursor_x2, "x2")]: - fig.add_vline( - x=x_val, - line_dash="dash", - line_color="rgba(100,100,100,0.5)", - line_width=1, - annotation_text=f"{lbl}={x_val:.4f}s", - annotation_font_size=10, - ) + # Add X1/X2 cursor lines + if cursor_x1 is not None: + fig.add_vline( + x=cursor_x1, + line_dash="dash", + line_color="rgba(220,53,69,0.6)", + line_width=1, + annotation_text=f"X1={cursor_x1:.4f}s", + annotation_font_size=9, + annotation_font_color="rgba(220,53,69,0.8)", + ) + if cursor_x2 is not None: + fig.add_vline( + x=cursor_x2, + line_dash="dash", + line_color="rgba(13,110,253,0.6)", + line_width=1, + annotation_text=f"X2={cursor_x2:.4f}s", + annotation_font_size=9, + annotation_font_color="rgba(13,110,253,0.8)", + ) - # Layout + # Layout — hovermode is disabled; cursor tracking is handled entirely + # by our JS (cursor_tracker.js) which reads pixel positions from mousemove + # events and converts to data coordinates via Plotly's axis internals. y_label = channels[0][1].unit if channels else "" - cfc_label = f" (CFC {cfc_value})" if cfc_value != "none" else "" fig.update_layout( xaxis_title="Time (s)", yaxis_title=y_label, template="plotly_white", - hovermode="x unified", + hovermode=False, showlegend=True, legend=dict( orientation="h", yanchor="bottom", - y=-0.2, + y=-0.18, xanchor="center", x=0.5, font=dict(size=10), ), - margin=dict(l=55, r=15, t=10, b=60), + margin=dict(l=55, r=15, t=10, b=55), + ) + + # Vertical spike line (crosshair) follows the mouse across the plot area + fig.update_xaxes( + showspikes=True, + spikemode="across", + spikesnap="cursor", + spikethickness=1, + spikecolor="rgba(0,0,0,0.25)", + spikedash="dot", + ) + fig.update_yaxes( + showspikes=False, + ) + + # Show spike line (vertical crosshair) on hover + fig.update_xaxes( + showspikes=True, + spikemode="across", + spikesnap="cursor", + spikethickness=1, + spikecolor="rgba(0,0,0,0.3)", + spikedash="dot", ) return fig @@ -189,11 +224,10 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None: Input("show-resultant", "value"), Input("y-align-check", "value"), Input("x-align-method", "value"), - Input("cursor-update-btn", "n_clicks"), + Input("cursor-x1", "value"), + Input("cursor-x2", "value"), ], [ - State("cursor-x1", "value"), - State("cursor-x2", "value"), State("x-align-value", "value"), ], ) @@ -203,7 +237,6 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None: show_resultant: bool, y_align: bool, x_align_method: str, - n_clicks: int | None, cursor_x1: float | None, cursor_x2: float | None, x_align_value: float | None, diff --git a/src/impakt/web/components/cursors.py b/src/impakt/web/components/cursors.py index 0e86ec4..1938b30 100644 --- a/src/impakt/web/components/cursors.py +++ b/src/impakt/web/components/cursors.py @@ -1,7 +1,14 @@ -"""Cursor controls and values table component. +"""Cursor values grid component. -Provides dual X-axis cursor inputs and a table showing interpolated -values at both cursor positions for all plotted channels. +Displays a DataTable below the plot showing interpolated Y values for all +plotted channels at three X positions: + +- **Cursor**: The live mouse position (updates as the user moves the mouse + over the plot). +- **X1**: A locked reference position (set by the user via input or click). +- **X2**: A second locked reference position. + +This replaces the old button-driven cursor panel with a live-updating grid. """ from __future__ import annotations @@ -9,82 +16,150 @@ from __future__ import annotations from typing import Any import dash_bootstrap_components as dbc -from dash import html +from dash import dash_table, dcc, html from impakt.channel.model import Channel def build_cursor_panel() -> dbc.Card: - """Build the cursor controls and values display card.""" + """Build the cursor values grid panel.""" return dbc.Card( [ dbc.CardHeader( [ - html.Span("X-Axis Cursors", className="fw-bold"), + html.Span("Cursor Values", className="fw-bold"), + html.Span( + " — hover over plot", + className="text-muted", + style={"fontSize": "10px", "fontWeight": "normal"}, + ), ], className="py-2", ), dbc.CardBody( [ + # X1/X2 lock controls dbc.Row( [ dbc.Col( [ dbc.InputGroup( [ - dbc.InputGroupText("x1", style={"fontSize": "12px"}), + dbc.InputGroupText( + "X1", + style={"fontSize": "11px", "padding": "2px 6px"}, + ), dbc.Input( id="cursor-x1", type="number", - step=0.001, + step=0.0001, value=0.0, size="sm", - style={"fontSize": "12px"}, + style={ + "fontSize": "11px", + "fontFamily": "monospace", + }, + ), + dbc.InputGroupText( + "s", + style={"fontSize": "11px", "padding": "2px 4px"}, ), - dbc.InputGroupText("s", style={"fontSize": "12px"}), ], size="sm", ), ], - width=5, + width=6, ), dbc.Col( [ dbc.InputGroup( [ - dbc.InputGroupText("x2", style={"fontSize": "12px"}), + dbc.InputGroupText( + "X2", + style={"fontSize": "11px", "padding": "2px 6px"}, + ), dbc.Input( id="cursor-x2", type="number", - step=0.001, + step=0.0001, value=0.1, size="sm", - style={"fontSize": "12px"}, + style={ + "fontSize": "11px", + "fontFamily": "monospace", + }, + ), + dbc.InputGroupText( + "s", + style={"fontSize": "11px", "padding": "2px 4px"}, ), - dbc.InputGroupText("s", style={"fontSize": "12px"}), ], size="sm", ), ], - width=5, - ), - dbc.Col( - [ - dbc.Button( - "Update", - id="cursor-update-btn", - color="primary", - size="sm", - className="w-100", - style={"fontSize": "12px"}, - ), - ], - width=2, + width=6, ), ], className="mb-2", ), - html.Div(id="cursor-values-table"), + # Cursor values DataTable + dash_table.DataTable( + id="cursor-grid", + columns=[ + {"name": "Channel", "id": "channel"}, + {"name": "Unit", "id": "unit"}, + {"name": "Cursor", "id": "cursor"}, + {"name": "X1", "id": "x1"}, + {"name": "X2", "id": "x2"}, + ], + data=[], + style_table={ + "overflowY": "auto", + "maxHeight": "200px", + }, + style_header={ + "backgroundColor": "#f8f9fa", + "fontWeight": "bold", + "fontSize": "10px", + "padding": "3px 6px", + "borderBottom": "2px solid #dee2e6", + }, + style_cell={ + "fontSize": "11px", + "fontFamily": "'SF Mono', 'Menlo', 'Monaco', monospace", + "padding": "2px 6px", + "border": "none", + "borderBottom": "1px solid #f0f0f0", + }, + style_cell_conditional=[ + { + "if": {"column_id": "channel"}, + "fontFamily": "inherit", + "textAlign": "left", + "minWidth": "100px", + "overflow": "hidden", + "textOverflow": "ellipsis", + "whiteSpace": "nowrap", + }, + {"if": {"column_id": "unit"}, "width": "45px", "textAlign": "center"}, + {"if": {"column_id": "cursor"}, "textAlign": "right", "width": "80px"}, + {"if": {"column_id": "x1"}, "textAlign": "right", "width": "80px"}, + {"if": {"column_id": "x2"}, "textAlign": "right", "width": "80px"}, + ], + style_data_conditional=[ + { + "if": {"column_id": "cursor"}, + "color": "#0d6efd", + "fontWeight": "500", + }, + ], + style_as_list_view=True, + fixed_rows={"headers": True}, + ), + # Interval that polls the JS-side hover X position + dcc.Interval(id="cursor-poll-interval", interval=80, n_intervals=0), + # Hidden store for the current hover X position + dcc.Store(id="cursor-hover-x", data=None), ], className="py-2", ), @@ -92,74 +167,33 @@ def build_cursor_panel() -> dbc.Card: ) -def build_cursor_values_table( +def build_cursor_grid_data( channels: list[tuple[str, Channel]], + hover_x: float | None, x1: float, x2: float, -) -> html.Table | html.Div: - """Build the cursor values table from resolved channels and positions. +) -> list[dict[str, str]]: + """Build the cursor grid rows from resolved channels. Args: channels: List of (label, channel) tuples. - x1: First cursor position. - x2: Second cursor position. + hover_x: Current mouse hover X position (None if not hovering). + x1: Locked X1 position. + x2: Locked X2 position. Returns: - An HTML table or a placeholder div. + List of row dicts for the DataTable. """ - if not channels: - return html.Div("No channels plotted", className="text-muted small") - - rows = [] + rows: list[dict[str, str]] = [] for label, ch in channels: - v1 = ch.value_at(x1) - v2 = ch.value_at(x2) - delta = v2 - v1 - + cursor_val = f"{ch.value_at(hover_x):.4f}" if hover_x is not None else "" rows.append( - html.Tr( - [ - html.Td( - label, - style={ - "fontSize": "11px", - "maxWidth": "180px", - "overflow": "hidden", - "textOverflow": "ellipsis", - "whiteSpace": "nowrap", - }, - ), - html.Td( - f"{v1:.4f}", - style={"fontSize": "11px", "fontFamily": "monospace", "textAlign": "right"}, - ), - html.Td( - f"{v2:.4f}", - style={"fontSize": "11px", "fontFamily": "monospace", "textAlign": "right"}, - ), - html.Td( - f"{delta:+.4f}", - style={"fontSize": "11px", "fontFamily": "monospace", "textAlign": "right"}, - ), - html.Td(ch.unit, style={"fontSize": "11px"}), - ] - ) + { + "channel": label, + "unit": ch.unit, + "cursor": cursor_val, + "x1": f"{ch.value_at(x1):.4f}", + "x2": f"{ch.value_at(x2):.4f}", + } ) - - return html.Table( - [ - html.Thead( - html.Tr( - [ - html.Th("Channel", style={"fontSize": "10px"}), - html.Th(f"@ {x1:.4f}s", style={"fontSize": "10px", "textAlign": "right"}), - html.Th(f"@ {x2:.4f}s", style={"fontSize": "10px", "textAlign": "right"}), - html.Th("Delta", style={"fontSize": "10px", "textAlign": "right"}), - html.Th("Unit", style={"fontSize": "10px"}), - ] - ) - ), - html.Tbody(rows), - ], - className="table table-sm table-hover mb-0", - ) + return rows diff --git a/src/impakt/web/components/plot_grid.py b/src/impakt/web/components/plot_grid.py index 6d25230..9534cb3 100644 --- a/src/impakt/web/components/plot_grid.py +++ b/src/impakt/web/components/plot_grid.py @@ -100,6 +100,7 @@ def _build_single_pane(pane_idx: int, total_panes: int) -> dbc.Card: "modeBarButtonsToAdd": ["drawline", "eraseshape"], }, style={"height": height}, + clear_on_unhover=False, ), ], className="p-2", diff --git a/src/impakt/web/layout.py b/src/impakt/web/layout.py index 9050b6a..d2df5fe 100644 --- a/src/impakt/web/layout.py +++ b/src/impakt/web/layout.py @@ -1,8 +1,9 @@ """Top-level Dash layout builder. Assembles all component modules into the complete page layout. -Uses a flex layout with a draggable splitter between the left -sidebar and the main content area. +Two tabs: +- **Data**: Channel grid (left, resizable) + Plot area + Cursor values grid +- **Analysis**: Injury criteria + Protocol scoring + Export """ from __future__ import annotations @@ -10,7 +11,7 @@ from __future__ import annotations from typing import Any import dash_bootstrap_components as dbc -from dash import ClientsideFunction, clientside_callback, dcc, html +from dash import dcc, html from impakt.web.components.channel_grid import build_channel_grid from impakt.web.components.criteria import build_criteria_panel @@ -27,6 +28,88 @@ from impakt.web.components.transforms import build_transform_panel from impakt.web.state import AppState +def _build_data_tab(app_state: AppState) -> html.Div: + """Build the Data tab: channels + transforms | plots + cursor grid.""" + return html.Div( + [ + # === Left panel: Channels + Transforms === + html.Div( + [ + build_channel_grid(app_state), + html.Div(style={"height": "8px"}), + build_transform_panel(), + ], + id="left-panel", + style={ + "width": "320px", + "minWidth": "200px", + "maxWidth": "600px", + "overflowY": "auto", + "flexShrink": "0", + "padding": "0 8px", + }, + ), + # === Splitter handle === + html.Div( + id="splitter-handle", + style={ + "width": "6px", + "cursor": "col-resize", + "backgroundColor": "#e9ecef", + "flexShrink": "0", + "position": "relative", + "zIndex": "10", + }, + ), + # === Right side: Plot area + Cursor grid (full width) === + html.Div( + [ + build_plot_grid("1x1"), + build_cursor_panel(), + ], + style={ + "flex": "1", + "minWidth": "0", + "overflowY": "auto", + "overflowX": "hidden", + "padding": "0 8px", + }, + ), + ], + style={ + "display": "flex", + "flexDirection": "row", + "height": "calc(100vh - 160px)", + "overflow": "hidden", + }, + ) + + +def _build_analysis_tab(app_state: AppState) -> html.Div: + """Build the Analysis tab: criteria + protocol scoring + export.""" + return html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + build_criteria_panel(), + ], + width=6, + ), + dbc.Col( + [ + build_report_panel(), + ], + width=6, + ), + ] + ), + ], + style={"padding": "8px"}, + ) + + def build_layout(app_state: AppState, template_names: list[str] | None = None) -> html.Div: """Build the complete page layout from components.""" @@ -42,83 +125,27 @@ def build_layout(app_state: AppState, template_names: list[str] | None = None) - build_test_info_panel(app_state), className="px-3", ), - # --- Main content: flex row with splitter --- - html.Div( + # --- Tabs --- + dbc.Tabs( [ - # === Left panel: Channels + Transforms === - html.Div( - [ - build_channel_grid(app_state), - html.Div(style={"height": "8px"}), - build_transform_panel(), - ], - id="left-panel", - style={ - "width": "320px", - "minWidth": "200px", - "maxWidth": "600px", - "overflowY": "auto", - "flexShrink": "0", - "padding": "0 8px", - }, + dbc.Tab( + _build_data_tab(app_state), + label="Data", + tab_id="tab-data", + tab_style={"fontSize": "13px"}, + active_label_style={"fontWeight": "bold"}, ), - # === Splitter handle === - html.Div( - id="splitter-handle", - style={ - "width": "6px", - "cursor": "col-resize", - "backgroundColor": "#e9ecef", - "flexShrink": "0", - "transition": "background-color 0.15s", - "position": "relative", - "zIndex": "10", - }, - # The hover/active styling is in CSS - ), - # === Center + Right: fills remaining space === - html.Div( - [ - dbc.Row( - [ - # Center: Plot grid + Cursors - dbc.Col( - [ - build_plot_grid("1x1"), - build_cursor_panel(), - ], - width=8, - ), - # Right: Criteria + Report - dbc.Col( - [ - build_criteria_panel(), - build_report_panel(), - ], - width=4, - style={ - "maxHeight": "calc(100vh - 160px)", - "overflowY": "auto", - }, - ), - ] - ), - ], - style={ - "flex": "1", - "minWidth": "0", - "overflowX": "hidden", - "padding": "0 8px", - }, + dbc.Tab( + _build_analysis_tab(app_state), + label="Analysis", + tab_id="tab-analysis", + tab_style={"fontSize": "13px"}, + active_label_style={"fontWeight": "bold"}, ), ], - id="main-content", - style={ - "display": "flex", - "flexDirection": "row", - "height": "calc(100vh - 130px)", - "overflow": "hidden", - }, + id="main-tabs", + active_tab="tab-data", + className="px-3 pt-1", ), # --- Hidden stores --- dcc.Store(id="selected-channels-store", data=[]),