bookmark - UI P1 Polishing
This commit is contained in:
156
docs/STATUS.md
156
docs/STATUS.md
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
**Date:** 2026-04-10
|
**Date:** 2026-04-10
|
||||||
**Version:** 0.1.0
|
**Version:** 0.1.0
|
||||||
**Tests:** 136 passing, 0 warnings
|
**Tests:** 171 passing
|
||||||
**Source:** ~6,900 lines Python | ~1,600 lines tests | ~190 lines HTML templates
|
**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
|
**Tooling:** uv (Python 3.12.12), hatchling build backend
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
```bash
|
```bash
|
||||||
cd /Users/noise/Code/impakt
|
cd /Users/noise/Code/impakt
|
||||||
uv sync --dev # install all dependencies
|
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 info tests/mme_data/3239 # show test metadata
|
||||||
uv run impakt serve tests/mme_data/3239 # launch web UI on :8050
|
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/
|
impakt/
|
||||||
pyproject.toml # PEP 621 + uv dependency-groups
|
pyproject.toml # PEP 621 + uv dependency-groups
|
||||||
uv.lock # lockfile
|
uv.lock # lockfile
|
||||||
|
.gitignore
|
||||||
README.md # Architecture docs with 16 Mermaid diagrams
|
README.md # Architecture docs with 16 Mermaid diagrams
|
||||||
BRAINSTORM.md # 80+ feature ideas
|
BRAINSTORM.md # 80+ feature ideas
|
||||||
docs/
|
docs/
|
||||||
@@ -40,7 +41,7 @@ impakt/
|
|||||||
src/impakt/
|
src/impakt/
|
||||||
__init__.py # exports Session, Template
|
__init__.py # exports Session, Template
|
||||||
channel/ # Data model layer
|
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
|
model.py # Channel, ChannelGroup, TestData, TestMetadata
|
||||||
group.py # Auto-grouping utilities
|
group.py # Auto-grouping utilities
|
||||||
lookup.py # ISO naming lookup tables (150+ entries)
|
lookup.py # ISO naming lookup tables (150+ entries)
|
||||||
@@ -67,7 +68,7 @@ impakt/
|
|||||||
euro_ncap.py # Euro NCAP (color/points/stars, versioned)
|
euro_ncap.py # Euro NCAP (color/points/stars, versioned)
|
||||||
us_ncap.py # US NCAP (injury probability/stars)
|
us_ncap.py # US NCAP (injury probability/stars)
|
||||||
iihs.py # IIHS (G/A/M/P)
|
iihs.py # IIHS (G/A/M/P)
|
||||||
plot/ # Visualization
|
plot/ # Visualization engine
|
||||||
engine.py # PlotEngine (Plotly), cursor_values()
|
engine.py # PlotEngine (Plotly), cursor_values()
|
||||||
spec.py # PlotSpec, ChannelRef, Corridor, CursorValues
|
spec.py # PlotSpec, ChannelRef, Corridor, CursorValues
|
||||||
cursor.py # Dual X-cursor logic
|
cursor.py # Dual X-cursor logic
|
||||||
@@ -83,9 +84,29 @@ impakt/
|
|||||||
injury_summary.html
|
injury_summary.html
|
||||||
protocol_report.html
|
protocol_report.html
|
||||||
web/ # Dash web application
|
web/ # Dash web application
|
||||||
app.py # create_app(), serve()
|
app.py # App factory: create_app(), serve()
|
||||||
layout.py # build_layout() -- UI structure
|
state.py # AppState: server-side multi-test state manager
|
||||||
callbacks.py # register_callbacks() -- interactivity
|
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
|
plugin/ # Plugin system
|
||||||
registry.py # PluginRegistry, discovery (entrypoints + dir)
|
registry.py # PluginRegistry, discovery (entrypoints + dir)
|
||||||
script/ # Scripting API + CLI
|
script/ # Scripting API + CLI
|
||||||
@@ -100,6 +121,7 @@ impakt/
|
|||||||
test_io/ # MMEReader
|
test_io/ # MMEReader
|
||||||
test_protocol/ # Euro NCAP scoring
|
test_protocol/ # Euro NCAP scoring
|
||||||
test_transform/ # CFC filter, alignment
|
test_transform/ # CFC filter, alignment
|
||||||
|
test_web/ # AppState, app creation, channel grid, criteria auto-compute
|
||||||
fixtures/
|
fixtures/
|
||||||
generate_mme.py # Synthetic MME generator (26 channels, half-sine)
|
generate_mme.py # Synthetic MME generator (26 channels, half-sine)
|
||||||
sample_mme/ # Generated synthetic test data
|
sample_mme/ # Generated synthetic test data
|
||||||
@@ -116,21 +138,21 @@ impakt/
|
|||||||
|
|
||||||
## What Works
|
## What Works
|
||||||
|
|
||||||
### Fully implemented and tested:
|
### All modules fully implemented and tested:
|
||||||
|
|
||||||
| Module | Status | Notes |
|
| 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 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, hierarchical tree builder. |
|
| **Channel model** | Complete | Immutable channels, auto-grouping X/Y/Z, resultant computation. |
|
||||||
| **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. |
|
| **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 (filtfilt). All 4 CFC classes. |
|
| **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). |
|
| **Alignment transforms** | Complete | X-align (manual/threshold/trigger), Y-align (baseline window). |
|
||||||
| **Resultant** | Complete | From ChannelGroup or arbitrary channels. |
|
| **Resultant** | Complete | From ChannelGroup or arbitrary channels. |
|
||||||
| **Math expressions** | Complete | Safe eval with numpy functions. |
|
| **Math expressions** | Complete | Safe eval with numpy functions. |
|
||||||
| **HIC** | Complete | HIC15/HIC36, cumulative integration, optimal window search. |
|
| **HIC** | Complete | HIC15/HIC36, cumulative integration, optimal window search. |
|
||||||
| **3ms clip** | Complete | Cumulative exceedance method. |
|
| **3ms clip** | Complete | Cumulative exceedance method. |
|
||||||
| **Nij** | Complete | 4 modes (NTE/NTF/NCE/NCF), per-dummy intercepts (H3 50M/5F/95M, child dummies). |
|
| **Nij** | Complete | 4 modes (NTE/NTF/NCE/NCF), per-dummy intercepts. |
|
||||||
| **Chest deflection** | Complete | Peak sternal displacement. |
|
| **Chest deflection** | Complete | Peak sternal displacement with unit/sanity validation. |
|
||||||
| **Viscous criterion** | Complete | V(t)*C(t) with chest depth per dummy type. |
|
| **Viscous criterion** | Complete | V(t)*C(t) with chest depth per dummy type. |
|
||||||
| **Femur load** | Complete | Left/right, unit conversion. |
|
| **Femur load** | Complete | Left/right, unit conversion. |
|
||||||
| **Tibia index** | Complete | M/Mc + F/Fc with intercepts. |
|
| **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. |
|
| **IIHS** | Complete | G/A/M/P per body region, worst-case overall. |
|
||||||
| **Plot engine** | Complete | Plotly rendering, corridors, cursor values, export. |
|
| **Plot engine** | Complete | Plotly rendering, corridors, cursor values, export. |
|
||||||
| **Template model** | Complete | YAML serialize/deserialize, library manager, session persistence. |
|
| **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. |
|
| **Plugin registry** | Complete | Entry point + directory + API discovery. |
|
||||||
| **CLI** | Complete | `impakt serve/info/channels/evaluate`. |
|
| **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.
|
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.
|
2. **`.impakt/` subfolder** -- session state stored alongside test data.
|
||||||
3. **Template/session split** -- templates are global recipes; sessions are per-test instances with overrides.
|
3. **Template/session split** -- templates are global recipes; sessions are per-test instances.
|
||||||
4. **Two MME format variants** -- real ISO 13499 and simplified INI. Reader auto-detects.
|
4. **AppState is server-side** -- numpy arrays stay in Python memory; Dash stores hold only lightweight keys.
|
||||||
5. **16-char channel codes** -- positions 11-12 can be dummy type (H3/P3/PC) or measurement type; parser auto-detects.
|
5. **Channel keys use `test_id::channel_name`** -- enables multi-test overlay.
|
||||||
6. **uv for package management** -- dev deps in `[dependency-groups]`, lockfile committed.
|
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. **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
|
||||||
1. **File/test management** -- open test from UI (path input), open additional tests for overlay, recent tests list, test metadata panel
|
3. **Corridor management** -- load from CSV, draw on plot, corridor library
|
||||||
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
|
4. **Channel inspector** -- tabular data view, statistics (min/max/RMS/peak time)
|
||||||
3. **Multi-pane plot layout** -- configurable grid (1x1 through 3x2), drag channels to panes, independent zoom with optional sync, per-pane axis labels
|
5. **Math expression builder** -- formula input with autocomplete, live preview
|
||||||
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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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 |
|
| Dataset | Lab | Type | Channels | Good for testing |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `fixtures/sample_mme/` | Synthetic | Frontal barrier | 26 | Unit tests, known values |
|
| `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/3239/` | NHTSA/Calspan | Frontal barrier (VW Passat) | 133 | Full pipeline, real data |
|
||||||
| `mme_data/AK3T02FO/` | BASt | Frontal 40% offset | 97 | Multi-occupant (driver, rear, child) |
|
| `mme_data/AK3T02FO/` | BASt | Frontal 40% offset | 97 | Multi-occupant |
|
||||||
| `mme_data/AK3T02SI/` | BASt | Side impact | 97 | Side impact protocols |
|
| `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/VW1FGS15/` | Volkswagen | Pedestrian headform | 10 | Impactor codes (D0) |
|
||||||
| `mme_data/98_7707/` | UTAC | Vehicle-to-vehicle | 0 | Metadata-only, graceful empty handling |
|
| `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
|
## 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").
|
1. **VehicleInfo.year parsed as 0** for real MME data (.mme format embeds year in vehicle name string).
|
||||||
2. **Speed displayed as raw float** (e.g., 55.900001530350906 km/h) -- should round.
|
2. **Speed displayed as raw float** (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`).
|
3. **DataTable deprecation warning** -- Dash recommends migrating to dash-ag-grid. Functional for now.
|
||||||
4. **web/assets/** directory exists but is empty -- no custom CSS yet.
|
4. **Cursor poll interval (80ms)** -- adds slight latency to cursor grid updates. Could use WebSocket for lower latency in future.
|
||||||
5. **No .gitignore** -- should exclude .venv/, __pycache__/, .pytest_cache/, .ruff_cache/, .impakt/ session dirs.
|
5. **Chest deflection auto-detect** skips DS channels with peak > 150mm (avoids steering column displacement). May miss some legitimate high-deflection data.
|
||||||
|
|||||||
230
src/impakt/web/assets/cursor_tracker.js
Normal file
230
src/impakt/web/assets/cursor_tracker.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
"""Cursor value callbacks.
|
"""Cursor value callbacks.
|
||||||
|
|
||||||
Handles:
|
Updates the cursor grid DataTable as the user moves the mouse over the plot.
|
||||||
- Cursor update button -> recompute interpolated values
|
|
||||||
- Cursor values table rendering
|
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
|
from __future__ import annotations
|
||||||
@@ -10,51 +14,64 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import dash
|
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 dash.exceptions import PreventUpdate
|
||||||
|
|
||||||
from impakt.web.callbacks.plot_callbacks import _resolve_channels
|
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
|
from impakt.web.state import AppState
|
||||||
|
|
||||||
|
|
||||||
def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
||||||
"""Register cursor-related callbacks."""
|
"""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(
|
@app.callback(
|
||||||
Output("cursor-values-table", "children"),
|
Output("cursor-grid", "data"),
|
||||||
[Input("cursor-update-btn", "n_clicks")],
|
[
|
||||||
|
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("cfc-select", "value"),
|
||||||
State("y-align-check", "value"),
|
State("y-align-check", "value"),
|
||||||
State("x-align-method", "value"),
|
State("x-align-method", "value"),
|
||||||
State("x-align-value", "value"),
|
State("x-align-value", "value"),
|
||||||
State("show-resultant", "value"),
|
State("show-resultant", "value"),
|
||||||
State("cursor-x1", "value"),
|
|
||||||
State("cursor-x2", "value"),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def update_cursor_table(
|
def update_cursor_grid(
|
||||||
n_clicks: int | None,
|
hover_x: float | None,
|
||||||
|
cursor_x1: float | None,
|
||||||
|
cursor_x2: float | None,
|
||||||
selected_keys: list[str] | None,
|
selected_keys: list[str] | None,
|
||||||
cfc_value: str,
|
cfc_value: str,
|
||||||
y_align: bool,
|
y_align: bool,
|
||||||
x_align_method: str,
|
x_align_method: str,
|
||||||
x_align_value: float | None,
|
x_align_value: float | None,
|
||||||
show_resultant: bool,
|
show_resultant: bool,
|
||||||
cursor_x1: float | None,
|
) -> list[dict[str, str]]:
|
||||||
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")
|
|
||||||
|
|
||||||
if not selected_keys:
|
if not selected_keys:
|
||||||
return html.Div("No channels selected", className="text-muted small")
|
return []
|
||||||
|
|
||||||
|
# Resolve channels with current transforms
|
||||||
channels = _resolve_channels(
|
channels = _resolve_channels(
|
||||||
selected_keys,
|
selected_keys,
|
||||||
app_state,
|
app_state,
|
||||||
@@ -65,4 +82,10 @@ def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
show_resultant,
|
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)
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Handles:
|
Handles:
|
||||||
- Updating plot figures when channels/transforms change
|
- Updating plot figures when channels/transforms change
|
||||||
- Cursor line rendering
|
- Cursor line rendering (X1/X2 vertical lines)
|
||||||
- Layout switching
|
- Hover data is NOT shown as a Plotly tooltip — instead the cursor grid
|
||||||
|
picks it up via the hoverData callback
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -113,7 +114,7 @@ def _build_figure(
|
|||||||
template="plotly_white",
|
template="plotly_white",
|
||||||
annotations=[
|
annotations=[
|
||||||
{
|
{
|
||||||
"text": "Select channels from the tree to plot",
|
"text": "Select channels from the grid to plot",
|
||||||
"xref": "paper",
|
"xref": "paper",
|
||||||
"yref": "paper",
|
"yref": "paper",
|
||||||
"x": 0.5,
|
"x": 0.5,
|
||||||
@@ -128,7 +129,8 @@ def _build_figure(
|
|||||||
)
|
)
|
||||||
return fig
|
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):
|
for i, (label, ch) in enumerate(channels):
|
||||||
color = DEFAULT_COLORS[i % len(DEFAULT_COLORS)]
|
color = DEFAULT_COLORS[i % len(DEFAULT_COLORS)]
|
||||||
fig.add_trace(
|
fig.add_trace(
|
||||||
@@ -138,41 +140,74 @@ def _build_figure(
|
|||||||
mode="lines",
|
mode="lines",
|
||||||
name=label,
|
name=label,
|
||||||
line=dict(color=color, width=1.5),
|
line=dict(color=color, width=1.5),
|
||||||
hovertemplate=f"{label}<br>t=%{{x:.6f}}s<br>%{{y:.4f}} {ch.unit}<extra></extra>",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add cursor lines
|
# Add X1/X2 cursor lines
|
||||||
if cursor_x1 is not None and cursor_x2 is not None:
|
if cursor_x1 is not None:
|
||||||
for x_val, lbl in [(cursor_x1, "x1"), (cursor_x2, "x2")]:
|
fig.add_vline(
|
||||||
fig.add_vline(
|
x=cursor_x1,
|
||||||
x=x_val,
|
line_dash="dash",
|
||||||
line_dash="dash",
|
line_color="rgba(220,53,69,0.6)",
|
||||||
line_color="rgba(100,100,100,0.5)",
|
line_width=1,
|
||||||
line_width=1,
|
annotation_text=f"X1={cursor_x1:.4f}s",
|
||||||
annotation_text=f"{lbl}={x_val:.4f}s",
|
annotation_font_size=9,
|
||||||
annotation_font_size=10,
|
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 ""
|
y_label = channels[0][1].unit if channels else ""
|
||||||
cfc_label = f" (CFC {cfc_value})" if cfc_value != "none" else ""
|
|
||||||
|
|
||||||
fig.update_layout(
|
fig.update_layout(
|
||||||
xaxis_title="Time (s)",
|
xaxis_title="Time (s)",
|
||||||
yaxis_title=y_label,
|
yaxis_title=y_label,
|
||||||
template="plotly_white",
|
template="plotly_white",
|
||||||
hovermode="x unified",
|
hovermode=False,
|
||||||
showlegend=True,
|
showlegend=True,
|
||||||
legend=dict(
|
legend=dict(
|
||||||
orientation="h",
|
orientation="h",
|
||||||
yanchor="bottom",
|
yanchor="bottom",
|
||||||
y=-0.2,
|
y=-0.18,
|
||||||
xanchor="center",
|
xanchor="center",
|
||||||
x=0.5,
|
x=0.5,
|
||||||
font=dict(size=10),
|
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
|
return fig
|
||||||
@@ -189,11 +224,10 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
Input("show-resultant", "value"),
|
Input("show-resultant", "value"),
|
||||||
Input("y-align-check", "value"),
|
Input("y-align-check", "value"),
|
||||||
Input("x-align-method", "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"),
|
State("x-align-value", "value"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -203,7 +237,6 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
show_resultant: bool,
|
show_resultant: bool,
|
||||||
y_align: bool,
|
y_align: bool,
|
||||||
x_align_method: str,
|
x_align_method: str,
|
||||||
n_clicks: int | None,
|
|
||||||
cursor_x1: float | None,
|
cursor_x1: float | None,
|
||||||
cursor_x2: float | None,
|
cursor_x2: float | None,
|
||||||
x_align_value: float | None,
|
x_align_value: float | None,
|
||||||
|
|||||||
@@ -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
|
Displays a DataTable below the plot showing interpolated Y values for all
|
||||||
values at both cursor positions for all plotted channels.
|
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
|
from __future__ import annotations
|
||||||
@@ -9,82 +16,150 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
from dash import html
|
from dash import dash_table, dcc, html
|
||||||
|
|
||||||
from impakt.channel.model import Channel
|
from impakt.channel.model import Channel
|
||||||
|
|
||||||
|
|
||||||
def build_cursor_panel() -> dbc.Card:
|
def build_cursor_panel() -> dbc.Card:
|
||||||
"""Build the cursor controls and values display card."""
|
"""Build the cursor values grid panel."""
|
||||||
return dbc.Card(
|
return dbc.Card(
|
||||||
[
|
[
|
||||||
dbc.CardHeader(
|
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",
|
className="py-2",
|
||||||
),
|
),
|
||||||
dbc.CardBody(
|
dbc.CardBody(
|
||||||
[
|
[
|
||||||
|
# X1/X2 lock controls
|
||||||
dbc.Row(
|
dbc.Row(
|
||||||
[
|
[
|
||||||
dbc.Col(
|
dbc.Col(
|
||||||
[
|
[
|
||||||
dbc.InputGroup(
|
dbc.InputGroup(
|
||||||
[
|
[
|
||||||
dbc.InputGroupText("x1", style={"fontSize": "12px"}),
|
dbc.InputGroupText(
|
||||||
|
"X1",
|
||||||
|
style={"fontSize": "11px", "padding": "2px 6px"},
|
||||||
|
),
|
||||||
dbc.Input(
|
dbc.Input(
|
||||||
id="cursor-x1",
|
id="cursor-x1",
|
||||||
type="number",
|
type="number",
|
||||||
step=0.001,
|
step=0.0001,
|
||||||
value=0.0,
|
value=0.0,
|
||||||
size="sm",
|
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",
|
size="sm",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
width=5,
|
width=6,
|
||||||
),
|
),
|
||||||
dbc.Col(
|
dbc.Col(
|
||||||
[
|
[
|
||||||
dbc.InputGroup(
|
dbc.InputGroup(
|
||||||
[
|
[
|
||||||
dbc.InputGroupText("x2", style={"fontSize": "12px"}),
|
dbc.InputGroupText(
|
||||||
|
"X2",
|
||||||
|
style={"fontSize": "11px", "padding": "2px 6px"},
|
||||||
|
),
|
||||||
dbc.Input(
|
dbc.Input(
|
||||||
id="cursor-x2",
|
id="cursor-x2",
|
||||||
type="number",
|
type="number",
|
||||||
step=0.001,
|
step=0.0001,
|
||||||
value=0.1,
|
value=0.1,
|
||||||
size="sm",
|
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",
|
size="sm",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
width=5,
|
width=6,
|
||||||
),
|
|
||||||
dbc.Col(
|
|
||||||
[
|
|
||||||
dbc.Button(
|
|
||||||
"Update",
|
|
||||||
id="cursor-update-btn",
|
|
||||||
color="primary",
|
|
||||||
size="sm",
|
|
||||||
className="w-100",
|
|
||||||
style={"fontSize": "12px"},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
width=2,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
className="mb-2",
|
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",
|
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]],
|
channels: list[tuple[str, Channel]],
|
||||||
|
hover_x: float | None,
|
||||||
x1: float,
|
x1: float,
|
||||||
x2: float,
|
x2: float,
|
||||||
) -> html.Table | html.Div:
|
) -> list[dict[str, str]]:
|
||||||
"""Build the cursor values table from resolved channels and positions.
|
"""Build the cursor grid rows from resolved channels.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
channels: List of (label, channel) tuples.
|
channels: List of (label, channel) tuples.
|
||||||
x1: First cursor position.
|
hover_x: Current mouse hover X position (None if not hovering).
|
||||||
x2: Second cursor position.
|
x1: Locked X1 position.
|
||||||
|
x2: Locked X2 position.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
An HTML table or a placeholder div.
|
List of row dicts for the DataTable.
|
||||||
"""
|
"""
|
||||||
if not channels:
|
rows: list[dict[str, str]] = []
|
||||||
return html.Div("No channels plotted", className="text-muted small")
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for label, ch in channels:
|
for label, ch in channels:
|
||||||
v1 = ch.value_at(x1)
|
cursor_val = f"{ch.value_at(hover_x):.4f}" if hover_x is not None else ""
|
||||||
v2 = ch.value_at(x2)
|
|
||||||
delta = v2 - v1
|
|
||||||
|
|
||||||
rows.append(
|
rows.append(
|
||||||
html.Tr(
|
{
|
||||||
[
|
"channel": label,
|
||||||
html.Td(
|
"unit": ch.unit,
|
||||||
label,
|
"cursor": cursor_val,
|
||||||
style={
|
"x1": f"{ch.value_at(x1):.4f}",
|
||||||
"fontSize": "11px",
|
"x2": f"{ch.value_at(x2):.4f}",
|
||||||
"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"}),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
return rows
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ def _build_single_pane(pane_idx: int, total_panes: int) -> dbc.Card:
|
|||||||
"modeBarButtonsToAdd": ["drawline", "eraseshape"],
|
"modeBarButtonsToAdd": ["drawline", "eraseshape"],
|
||||||
},
|
},
|
||||||
style={"height": height},
|
style={"height": height},
|
||||||
|
clear_on_unhover=False,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
className="p-2",
|
className="p-2",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Top-level Dash layout builder.
|
"""Top-level Dash layout builder.
|
||||||
|
|
||||||
Assembles all component modules into the complete page layout.
|
Assembles all component modules into the complete page layout.
|
||||||
Uses a flex layout with a draggable splitter between the left
|
Two tabs:
|
||||||
sidebar and the main content area.
|
- **Data**: Channel grid (left, resizable) + Plot area + Cursor values grid
|
||||||
|
- **Analysis**: Injury criteria + Protocol scoring + Export
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -10,7 +11,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import dash_bootstrap_components as dbc
|
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.channel_grid import build_channel_grid
|
||||||
from impakt.web.components.criteria import build_criteria_panel
|
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
|
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:
|
def build_layout(app_state: AppState, template_names: list[str] | None = None) -> html.Div:
|
||||||
"""Build the complete page layout from components."""
|
"""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),
|
build_test_info_panel(app_state),
|
||||||
className="px-3",
|
className="px-3",
|
||||||
),
|
),
|
||||||
# --- Main content: flex row with splitter ---
|
# --- Tabs ---
|
||||||
html.Div(
|
dbc.Tabs(
|
||||||
[
|
[
|
||||||
# === Left panel: Channels + Transforms ===
|
dbc.Tab(
|
||||||
html.Div(
|
_build_data_tab(app_state),
|
||||||
[
|
label="Data",
|
||||||
build_channel_grid(app_state),
|
tab_id="tab-data",
|
||||||
html.Div(style={"height": "8px"}),
|
tab_style={"fontSize": "13px"},
|
||||||
build_transform_panel(),
|
active_label_style={"fontWeight": "bold"},
|
||||||
],
|
|
||||||
id="left-panel",
|
|
||||||
style={
|
|
||||||
"width": "320px",
|
|
||||||
"minWidth": "200px",
|
|
||||||
"maxWidth": "600px",
|
|
||||||
"overflowY": "auto",
|
|
||||||
"flexShrink": "0",
|
|
||||||
"padding": "0 8px",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
# === Splitter handle ===
|
dbc.Tab(
|
||||||
html.Div(
|
_build_analysis_tab(app_state),
|
||||||
id="splitter-handle",
|
label="Analysis",
|
||||||
style={
|
tab_id="tab-analysis",
|
||||||
"width": "6px",
|
tab_style={"fontSize": "13px"},
|
||||||
"cursor": "col-resize",
|
active_label_style={"fontWeight": "bold"},
|
||||||
"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",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
id="main-content",
|
id="main-tabs",
|
||||||
style={
|
active_tab="tab-data",
|
||||||
"display": "flex",
|
className="px-3 pt-1",
|
||||||
"flexDirection": "row",
|
|
||||||
"height": "calc(100vh - 130px)",
|
|
||||||
"overflow": "hidden",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
# --- Hidden stores ---
|
# --- Hidden stores ---
|
||||||
dcc.Store(id="selected-channels-store", data=[]),
|
dcc.Store(id="selected-channels-store", data=[]),
|
||||||
|
|||||||
Reference in New Issue
Block a user