Channel Focus
This commit is contained in:
210
docs/QA-2026-04-11_1054.md
Normal file
210
docs/QA-2026-04-11_1054.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# Quality Assessment -- 2026-04-11
|
||||||
|
|
||||||
|
> **Score: 83.7 / 100 — Grade: B+**
|
||||||
|
> Strong jump of +5.4 points from B (78.3) to B+ (83.7) driven by lint reaching perfection (0 violations) and architecture completing its public-API surface (all 11 modules now export `__all__`); type safety improved incrementally while complexity ticked slightly down as two files crossed the 300-line threshold.
|
||||||
|
|
||||||
|
| Dimension | Score |
|
||||||
|
|-----------|-------|
|
||||||
|
| Test Health | 8.0/10 |
|
||||||
|
| Type Safety | 6.8/10 |
|
||||||
|
| Lint Hygiene | 10.0/10 |
|
||||||
|
| Architecture | 10.0/10 |
|
||||||
|
| Documentation | 9.0/10 |
|
||||||
|
| Complexity | 6.5/10 |
|
||||||
|
| Security | 8.5/10 |
|
||||||
|
| Maintainability | 8.5/10 |
|
||||||
|
|
||||||
|
**Version:** 0.1.0
|
||||||
|
**Assessed by:** Claude Sonnet 4.6
|
||||||
|
**Previous assessment:** QA-2026-04-11_0619.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inventory
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Source files | 72 |
|
||||||
|
| Source lines | 10,563 |
|
||||||
|
| Test files | 30 |
|
||||||
|
| Test lines | 2,736 |
|
||||||
|
| Test:source ratio | 0.259 |
|
||||||
|
| Direct dependencies | 10 core + 1 optional + 4 dev |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Raw Metrics
|
||||||
|
|
||||||
|
### Test Suite
|
||||||
|
|
||||||
|
```
|
||||||
|
TOTAL 3746 1142 70%
|
||||||
|
|
||||||
|
20 files skipped due to complete coverage.
|
||||||
|
Required test coverage of 60% reached. Total coverage: 69.51%
|
||||||
|
240 passed, 7 warnings in 16.64s
|
||||||
|
```
|
||||||
|
|
||||||
|
- Tests collected: 240
|
||||||
|
- Tests passed: 240
|
||||||
|
- Tests failed: 0
|
||||||
|
- Test duration: 16.64s
|
||||||
|
- Coverage: 69.51% (pytest-cov now configured; 60% floor enforced)
|
||||||
|
|
||||||
|
### Type Safety (mypy --strict)
|
||||||
|
|
||||||
|
```
|
||||||
|
Found 34 errors in 16 files (checked 72 source files)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Total errors: 34
|
||||||
|
- Files with errors: 16 / 72 (78% clean)
|
||||||
|
- Top error categories:
|
||||||
|
- `[attr-defined]` 9 — attribute access on loosely typed objects
|
||||||
|
- `[no-any-return]` 6 — returning `Any` from typed functions
|
||||||
|
- `[return-value]` 4 — incompatible return type
|
||||||
|
- `[import-untyped]` 3 — third-party stubs missing
|
||||||
|
- `[assignment]` 3 — incompatible assignment
|
||||||
|
- `[var-annotated]` 2 — need type annotation
|
||||||
|
- `[valid-type]` 2 — invalid type expression
|
||||||
|
- `[no-untyped-call]` 2 — call to untyped function
|
||||||
|
- `[unused-ignore]` 1
|
||||||
|
- `[no-untyped-def]` 1
|
||||||
|
- `[comparison-overlap]` 1
|
||||||
|
|
||||||
|
### Lint (ruff)
|
||||||
|
|
||||||
|
```
|
||||||
|
All checks passed!
|
||||||
|
```
|
||||||
|
|
||||||
|
- Total violations: 0
|
||||||
|
- Auto-fixable: N/A
|
||||||
|
- Top violation rules: none
|
||||||
|
|
||||||
|
### Complexity
|
||||||
|
|
||||||
|
- File size: min=1 / median=132 / mean=147 / max=692
|
||||||
|
- Files >300 lines: 8 / 72
|
||||||
|
- High-complexity files (branch density >15):
|
||||||
|
|
||||||
|
```
|
||||||
|
80 src/impakt/io/mme.py (692 lines) -- ISO 13499 parser, justified
|
||||||
|
44 src/impakt/web/components/criteria.py (342 lines) -- UI assembly with protocol logic
|
||||||
|
30 src/impakt/web/callbacks/plot_callbacks.py (324 lines) -- transform pipeline orchestration
|
||||||
|
30 src/impakt/channel/model.py (456 lines) -- core data model, multiple classes
|
||||||
|
27 src/impakt/web/state.py (272 lines) -- app state with multi-test support
|
||||||
|
27 src/impakt/protocol/euro_ncap.py (237 lines) -- sliding-scale scoring tables
|
||||||
|
23 src/impakt/plot/engine.py (303 lines) -- Plotly rendering with corridors
|
||||||
|
21 src/impakt/web/callbacks/channel_callbacks.py (236 lines) -- selection/filter callbacks
|
||||||
|
21 src/impakt/protocol/iihs.py (179 lines) -- G/A/M/P rating logic
|
||||||
|
20 src/impakt/web/components/channel_grid.py (418 lines) -- DataTable assembly
|
||||||
|
19 src/impakt/script/cli.py (137 lines) -- CLI arg parsing
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Docstring coverage: 420 / 457 definitions (91.9%)
|
||||||
|
- Modules with `__all__`: 11 / 11 (all modules)
|
||||||
|
- channel: YES
|
||||||
|
- criteria: YES
|
||||||
|
- io: YES *(new)*
|
||||||
|
- plot: YES
|
||||||
|
- plugin: YES *(new)*
|
||||||
|
- protocol: YES
|
||||||
|
- report: YES *(new)*
|
||||||
|
- script: YES *(new)*
|
||||||
|
- template: YES *(new)*
|
||||||
|
- transform: YES
|
||||||
|
- web: YES
|
||||||
|
- README: 1,266 lines with Mermaid diagrams
|
||||||
|
- Architectural diagrams: yes
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- eval/exec (sandboxed): 1 — `math_expr.py`, restricted builtins `{}` + 16-token blocklist; excluded from grep via `# noqa: S307`
|
||||||
|
- eval/exec (unsandboxed): 0
|
||||||
|
- subprocess: 1 confirmed false positive — `src/impakt/transform/math_expr.py:68: "subprocess",` is a forbidden-token blocklist string, not an invocation
|
||||||
|
- Hardcoded secrets: 0
|
||||||
|
- Bare except: 0
|
||||||
|
|
||||||
|
### Maintainability
|
||||||
|
|
||||||
|
- TODO: 0
|
||||||
|
- FIXME: 0
|
||||||
|
- HACK: 0
|
||||||
|
- Logging calls: 50
|
||||||
|
- try/except blocks: 53
|
||||||
|
- Bare excepts: 0
|
||||||
|
- Internal imports (coupling): 198
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scorecard
|
||||||
|
|
||||||
|
| # | Dimension | Weight | Score | Weighted | Justification |
|
||||||
|
|---|-----------|--------|-------|----------|---------------|
|
||||||
|
| 1 | Test Health | 20% | 8.0/10 | 1.60 | 240/240 pass; test:source ratio 0.259 (within 0.2–0.5 band); integration tests with real datasets present; 69.51% coverage now measured (below 80% ceiling for a 10). |
|
||||||
|
| 2 | Type Safety | 15% | 6.8/10 | 1.02 | mypy strict, 34 errors in 16 files; all 17 `[type-arg]` errors resolved since last run. Linear interpolation between 6 (<50) and 8 (<10): 6 + (50-34)/(50-10) × 2 = 6.8. |
|
||||||
|
| 3 | Lint Hygiene | 10% | 10.0/10 | 1.00 | `ruff check src/` reports 0 violations. Perfect score; all 89 prior violations cleared. |
|
||||||
|
| 4 | Architecture | 15% | 10.0/10 | 1.50 | Clear 4-layer design (io → transform/channel → protocol → web/plot). Plugin system present. All 11 public modules export `__all__`. No layer violations confirmed (io/transform/protocol/channel do not import from web or plot). |
|
||||||
|
| 5 | Documentation | 10% | 9.0/10 | 0.90 | 91.9% docstring coverage exceeds 90% threshold. Comprehensive README with Mermaid diagrams. No generated API reference (Sphinx/mkdocs), so not a full 10. |
|
||||||
|
| 6 | Complexity | 10% | 6.5/10 | 0.65 | Median 132 (<150, excellent). 8 files >300 lines (up from 6). `plot_callbacks.py` (324) and `engine.py` (303) newly crossed threshold. Interpolated between 8 (≤3 files >300) and 6 (≤10): 6 + (10-8)/(10-3) × 2 = 6.57 → 6.5. |
|
||||||
|
| 7 | Security | 10% | 8.5/10 | 0.85 | Single eval sandboxed with `{"__builtins__": {}}` + 16-item token blocklist. Subprocess hit confirmed false positive (blocklist string, not call). No secrets, no bare excepts. Interpolated between 9 (fully sandboxed) and 7 (partially sandboxed). |
|
||||||
|
| 8 | Maintainability | 10% | 8.5/10 | 0.85 | Zero debt markers. Zero bare excepts. 50 logging calls. Modern tooling (uv, hatchling, ruff, mypy). Between 10 (perfect) and 8 (<5 markers). |
|
||||||
|
|
||||||
|
### Composite Score: **83.7 / 100**
|
||||||
|
### Grade: **B+**
|
||||||
|
|
||||||
|
Calculation: (8.0×0.20 + 6.8×0.15 + 10.0×0.10 + 10.0×0.15 + 9.0×0.10 + 6.5×0.10 + 8.5×0.10 + 8.5×0.10) × 10
|
||||||
|
= (1.60 + 1.02 + 1.00 + 1.50 + 0.90 + 0.65 + 0.85 + 0.85) × 10
|
||||||
|
= 8.37 × 10 = **83.7**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delta from Previous Assessment
|
||||||
|
|
||||||
|
| Dimension | Previous | Current | Change |
|
||||||
|
|-----------|----------|---------|--------|
|
||||||
|
| Test Health | 8.0 | 8.0 | 0.0 |
|
||||||
|
| Type Safety | 6.5 | 6.8 | **+0.3** |
|
||||||
|
| Lint Hygiene | 6.0 | 10.0 | **+4.0** ↑ |
|
||||||
|
| Architecture | 9.0 | 10.0 | **+1.0** ↑ |
|
||||||
|
| Documentation | 9.0 | 9.0 | 0.0 |
|
||||||
|
| Complexity | 7.0 | 6.5 | **-0.5** ↓ |
|
||||||
|
| Security | 8.5 | 8.5 | 0.0 |
|
||||||
|
| Maintainability | 8.5 | 8.5 | 0.0 |
|
||||||
|
| **Composite** | **78.3** | **83.7** | **+5.4** ↑ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top Improvements Since Last Assessment
|
||||||
|
|
||||||
|
1. **Lint Hygiene: 6.0 → 10.0** — All 89 ruff violations resolved (73 auto-fixed + 16 manual, including 8 `F601` duplicate dict keys). First perfect lint score.
|
||||||
|
2. **Architecture: 9.0 → 10.0** — Five modules (`io`, `plugin`, `report`, `script`, `template`) received proper `__all__` exports, completing the public-API surface across all 11 packages.
|
||||||
|
3. **Type Safety: 6.5 → 6.8** — 15 fewer mypy errors (49 → 34); all 17 `[type-arg]` errors from generic parameters resolved; 4 fewer files with errors (20 → 16).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Actions (Priority Order)
|
||||||
|
|
||||||
|
| # | Action | Effort | Impact | Dimensions Affected |
|
||||||
|
|---|--------|--------|--------|---------------------|
|
||||||
|
| 1 | Increase test coverage from 69.51% to ≥80%: add unit tests for uncovered branches in `web/state.py`, `plot/engine.py`, and `io/mme.py` parser edge-cases | 2–4 hr | Test Health +1.0 → 9.0 (+0.20 composite) | Test Health |
|
||||||
|
| 2 | Resolve 9 `[attr-defined]` + 6 `[no-any-return]` + 4 `[return-value]` mypy errors in the web layer to reach <10 total errors | 1–2 hr | Type Safety +1.2 → 8.0 (+0.18 composite) | Type Safety |
|
||||||
|
| 3 | Decompose `channel_grid.py` (418 lines) and `channel/model.py` (456 lines) into focused sub-modules; extract sub-parsers from `mme.py` if it grows beyond 800 lines | 3–5 hr | Complexity +0.5 → 7.0 (+0.05 composite) | Complexity |
|
||||||
|
| 4 | Set up mkdocs-material + mkdocstrings to auto-generate API reference from existing docstrings | 1–2 hr | Documentation +1.0 → 10.0 (+0.10 composite) | Documentation |
|
||||||
|
| 5 | Replace `eval`-based math expression evaluator in `math_expr.py` with an AST-based parser (e.g., `ast.parse` + safe node visitor) to eliminate last eval usage | 2–3 hr | Security +0.5 → 9.0 (+0.05 composite) | Security |
|
||||||
|
|
||||||
|
**Projected composite after actions 1–4: ~87 (B+), after all 5: ~88 (B+)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Architecture qualitatively verified.** All five newly-added `__init__.py` files were inspected and follow the pattern of explicit imports + `__all__` lists. No layer violations detected: `io`, `transform`, `protocol`, and `channel` packages contain no imports from `web` or `plot`.
|
||||||
|
- **pytest-cov is now active.** The test run enforces a 60% coverage floor (`--cov --cov-report=term-missing` or equivalent in `pyproject.toml`). The overall 69.51% exceeds the floor but is below the 80% threshold required to push Test Health to 9.0. Note: the `--co` (collect-only) invocation showed only 30.81% because 11 files are skipped during collection; the full run gives 69.51%.
|
||||||
|
- **Complexity slight regression.** Two files crossed the 300-line threshold since the last run: `plot_callbacks.py` (249 → 324 lines) and `plot/engine.py` (257 → 303 lines). Both contain justified density (pipeline orchestration and Plotly rendering respectively), but the increase in count from 6 to 8 files >300 reduces the score from 7.0 to 6.5 per the rubric interpolation.
|
||||||
|
- **Security subprocess false positive** persists: `math_expr.py:68` contains the string `"subprocess"` as an entry in its forbidden-token blocklist. No actual subprocess invocation exists in the codebase.
|
||||||
|
- **Type-arg errors fully resolved.** The previous leading error category (`[type-arg]` = 17) is now zero. Remaining errors are concentrated in `[attr-defined]` (9) and `[no-any-return]` (6), both in the web callback layer where Dash's typing stubs are incomplete.
|
||||||
|
- **Source line growth** (+238 lines, 10,325 → 10,563) is consistent with the five new `__init__.py` additions and growth in existing files.
|
||||||
68
src/impakt/web/assets/channel_nav.js
Normal file
68
src/impakt/web/assets/channel_nav.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Channel grid keyboard navigation.
|
||||||
|
*
|
||||||
|
* PgDn / ArrowDown: move focus to the next row in the channel grid.
|
||||||
|
* PgUp / ArrowUp: move focus to the previous row.
|
||||||
|
*
|
||||||
|
* Simulates a click on the target row's first cell, which triggers
|
||||||
|
* Dash's active_cell callback and updates the focus channel.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key !== "PageDown" && e.key !== "PageUp" &&
|
||||||
|
e.key !== "ArrowDown" && e.key !== "ArrowUp") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't intercept if user is typing in an input field
|
||||||
|
var tag = (e.target.tagName || "").toLowerCase();
|
||||||
|
if (tag === "input" || tag === "textarea" || tag === "select") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var table = document.getElementById("channel-grid");
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
var rows = table.querySelectorAll("tbody tr");
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
// Find the currently active (focused) row
|
||||||
|
var activeRow = table.querySelector("tbody tr.focused, tbody tr td.focused");
|
||||||
|
var currentIdx = -1;
|
||||||
|
if (activeRow) {
|
||||||
|
var row = activeRow.closest("tr");
|
||||||
|
currentIdx = Array.prototype.indexOf.call(rows, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for active cell via Dash's cell-active class
|
||||||
|
if (currentIdx < 0) {
|
||||||
|
var activeCell = table.querySelector("td.cell--active, td.dash-cell--active, td.focused");
|
||||||
|
if (activeCell) {
|
||||||
|
var parentRow = activeCell.closest("tr");
|
||||||
|
currentIdx = Array.prototype.indexOf.call(rows, parentRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var direction = (e.key === "PageDown" || e.key === "ArrowDown") ? 1 : -1;
|
||||||
|
var nextIdx = currentIdx + direction;
|
||||||
|
|
||||||
|
// Clamp to valid range
|
||||||
|
if (nextIdx < 0) nextIdx = 0;
|
||||||
|
if (nextIdx >= rows.length) nextIdx = rows.length - 1;
|
||||||
|
|
||||||
|
// Click the first data cell of the target row to trigger active_cell
|
||||||
|
var targetRow = rows[nextIdx];
|
||||||
|
if (targetRow) {
|
||||||
|
var cell = targetRow.querySelector("td");
|
||||||
|
if (cell) {
|
||||||
|
cell.click();
|
||||||
|
// Scroll the row into view
|
||||||
|
targetRow.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -62,6 +62,22 @@
|
|||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Channel grid: clicking a row selects the whole row, not just a cell */
|
||||||
|
#channel-grid td.cell--active {
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove the blue cell focus ring from DataTable */
|
||||||
|
#channel-grid .dash-cell.focused {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#channel-grid td.cell--selected {
|
||||||
|
background-color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Handles:
|
|||||||
indices to preserve selection across filter changes
|
indices to preserve selection across filter changes
|
||||||
- Selected channels badge display with consistent colors
|
- Selected channels badge display with consistent colors
|
||||||
- Per-channel CFC override controls
|
- Per-channel CFC override controls
|
||||||
|
- Focus channel: clicking a row sets it as the focus (plotted on top)
|
||||||
|
- PgUp/PgDn keyboard navigation through the channel grid
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,7 +15,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import dash
|
import dash
|
||||||
from dash import ALL, Input, Output, State
|
from dash import ALL, Input, Output, State, no_update
|
||||||
|
|
||||||
from impakt.plot.engine import DEFAULT_COLORS
|
from impakt.plot.engine import DEFAULT_COLORS
|
||||||
from impakt.web.components.channel_grid import (
|
from impakt.web.components.channel_grid import (
|
||||||
@@ -23,6 +25,9 @@ from impakt.web.components.channel_grid import (
|
|||||||
from impakt.web.components.transforms import build_per_channel_override_rows
|
from impakt.web.components.transforms import build_per_channel_override_rows
|
||||||
from impakt.web.state import AppState
|
from impakt.web.state import AppState
|
||||||
|
|
||||||
|
# Focus highlight color
|
||||||
|
_FOCUS_COLOR = "#ffc107" # amber/gold
|
||||||
|
|
||||||
|
|
||||||
def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
||||||
"""Register all channel selection and filtering callbacks."""
|
"""Register all channel selection and filtering callbacks."""
|
||||||
@@ -78,6 +83,25 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
def update_badges(selected_keys: list[str] | None) -> list[Any]:
|
def update_badges(selected_keys: list[str] | None) -> list[Any]:
|
||||||
return build_selected_channels_badges(selected_keys or [], app_state)
|
return build_selected_channels_badges(selected_keys or [], app_state)
|
||||||
|
|
||||||
|
# --- Focus channel: clicking a row sets it as the plot focus ---
|
||||||
|
|
||||||
|
@app.callback(
|
||||||
|
Output("focus-channel-store", "data"),
|
||||||
|
[Input("channel-grid", "active_cell")],
|
||||||
|
[State("channel-grid", "data")],
|
||||||
|
)
|
||||||
|
def update_focus_channel(
|
||||||
|
active_cell: dict[str, Any] | None,
|
||||||
|
visible_data: list[dict[str, Any]] | None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Set focus channel when user clicks a row."""
|
||||||
|
if active_cell is None or not visible_data:
|
||||||
|
return no_update
|
||||||
|
row_idx = active_cell.get("row", -1)
|
||||||
|
if 0 <= row_idx < len(visible_data):
|
||||||
|
return visible_data[row_idx]["key"]
|
||||||
|
return no_update
|
||||||
|
|
||||||
@app.callback(
|
@app.callback(
|
||||||
[
|
[
|
||||||
Output("channel-grid", "data"),
|
Output("channel-grid", "data"),
|
||||||
@@ -91,6 +115,7 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
Input("facet-meas", "value"),
|
Input("facet-meas", "value"),
|
||||||
Input("facet-direction", "value"),
|
Input("facet-direction", "value"),
|
||||||
Input("selected-channels-store", "data"),
|
Input("selected-channels-store", "data"),
|
||||||
|
Input("focus-channel-store", "data"),
|
||||||
],
|
],
|
||||||
[State("channel-grid-all-rows", "data")],
|
[State("channel-grid-all-rows", "data")],
|
||||||
)
|
)
|
||||||
@@ -101,6 +126,7 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
meas: str | None,
|
meas: str | None,
|
||||||
direction: str | None,
|
direction: str | None,
|
||||||
selected_keys: list[str] | None,
|
selected_keys: list[str] | None,
|
||||||
|
focus_key: str | None,
|
||||||
all_rows: list[dict[str, Any]] | None,
|
all_rows: list[dict[str, Any]] | None,
|
||||||
) -> tuple[list[dict[str, Any]], list[int], list[dict[str, Any]]]:
|
) -> tuple[list[dict[str, Any]], list[int], list[dict[str, Any]]]:
|
||||||
"""Filter rows AND recompute selected_rows + color styling."""
|
"""Filter rows AND recompute selected_rows + color styling."""
|
||||||
@@ -134,17 +160,34 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
|
|
||||||
# Build style_data_conditional for coloring selected rows
|
# Build style_data_conditional for coloring selected rows
|
||||||
style_cond: list[dict[str, Any]] = []
|
style_cond: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Focus row highlight (full row, amber background)
|
||||||
|
for idx, row in enumerate(filtered):
|
||||||
|
if focus_key and row["key"] == focus_key:
|
||||||
|
style_cond.append(
|
||||||
|
{
|
||||||
|
"if": {"row_index": idx},
|
||||||
|
"backgroundColor": f"{_FOCUS_COLOR}30",
|
||||||
|
"borderLeft": f"3px solid {_FOCUS_COLOR}",
|
||||||
|
"fontWeight": "600",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pinned (checked) row colors — applied after focus so they layer
|
||||||
for idx, row in enumerate(filtered):
|
for idx, row in enumerate(filtered):
|
||||||
if row["key"] in color_map:
|
if row["key"] in color_map:
|
||||||
ci = color_map[row["key"]]
|
ci = color_map[row["key"]]
|
||||||
color = DEFAULT_COLORS[ci % len(DEFAULT_COLORS)]
|
color = DEFAULT_COLORS[ci % len(DEFAULT_COLORS)]
|
||||||
style_cond.append(
|
entry: dict[str, Any] = {
|
||||||
{
|
"if": {"row_index": idx},
|
||||||
"if": {"row_index": idx},
|
"backgroundColor": f"{color}18",
|
||||||
"backgroundColor": f"{color}18", # Very light tint
|
"borderLeft": f"3px solid {color}",
|
||||||
"borderLeft": f"3px solid {color}",
|
}
|
||||||
}
|
# If this row is also the focus, use a stronger tint
|
||||||
)
|
if focus_key and row["key"] == focus_key:
|
||||||
|
entry["backgroundColor"] = f"{color}40"
|
||||||
|
entry["fontWeight"] = "600"
|
||||||
|
style_cond.append(entry)
|
||||||
|
|
||||||
return filtered, selected_indices, style_cond
|
return filtered, selected_indices, style_cond
|
||||||
|
|
||||||
|
|||||||
@@ -160,14 +160,19 @@ def _build_plot_spec(
|
|||||||
cursor_x1: float | None,
|
cursor_x1: float | None,
|
||||||
cursor_x2: float | None,
|
cursor_x2: float | None,
|
||||||
corridors: list[dict[str, Any]] | None = None,
|
corridors: list[dict[str, Any]] | None = None,
|
||||||
|
focus_key: str | None = None,
|
||||||
|
focus_channel: tuple[str, Channel] | None = None,
|
||||||
) -> PlotSpec:
|
) -> PlotSpec:
|
||||||
"""Build a PlotSpec from resolved channels and UI state.
|
"""Build a PlotSpec from resolved channels and UI state.
|
||||||
|
|
||||||
Channels are already transformed — they are wrapped in ChannelRef
|
Channels are already transformed — they are wrapped in ChannelRef
|
||||||
objects with no additional transform chain (transforms were applied
|
objects with no additional transform chain (transforms were applied
|
||||||
during resolution).
|
during resolution).
|
||||||
|
|
||||||
|
If ``focus_channel`` is provided, it is rendered last (on top) with
|
||||||
|
a thicker line so it stands out from pinned channels.
|
||||||
"""
|
"""
|
||||||
# Build ChannelRef objects
|
# Build ChannelRef objects — pinned channels first (thinner)
|
||||||
refs: list[ChannelRef] = []
|
refs: list[ChannelRef] = []
|
||||||
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)]
|
||||||
@@ -178,6 +183,20 @@ def _build_plot_spec(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add focus channel last so it renders on top
|
||||||
|
if focus_channel is not None:
|
||||||
|
f_label, f_ch = focus_channel
|
||||||
|
refs.append(
|
||||||
|
ChannelRef(
|
||||||
|
channel=f_ch,
|
||||||
|
style=PlotStyle(
|
||||||
|
label=f_label,
|
||||||
|
color="#ffc107", # amber — matches focus highlight in grid
|
||||||
|
line_width=2.5,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Build Corridor objects from raw dicts
|
# Build Corridor objects from raw dicts
|
||||||
corridor_objs: list[Corridor] = []
|
corridor_objs: list[Corridor] = []
|
||||||
if corridors:
|
if corridors:
|
||||||
@@ -219,6 +238,7 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
Output({"type": "plot-graph", "index": 0}, "figure"),
|
Output({"type": "plot-graph", "index": 0}, "figure"),
|
||||||
[
|
[
|
||||||
Input("selected-channels-store", "data"),
|
Input("selected-channels-store", "data"),
|
||||||
|
Input("focus-channel-store", "data"),
|
||||||
Input("cfc-select", "value"),
|
Input("cfc-select", "value"),
|
||||||
Input("show-resultant", "value"),
|
Input("show-resultant", "value"),
|
||||||
Input("y-align-check", "value"),
|
Input("y-align-check", "value"),
|
||||||
@@ -234,6 +254,7 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
)
|
)
|
||||||
def update_main_plot(
|
def update_main_plot(
|
||||||
selected_keys: list[str] | None,
|
selected_keys: list[str] | None,
|
||||||
|
focus_key: str | None,
|
||||||
cfc_value: str,
|
cfc_value: str,
|
||||||
show_resultant: bool,
|
show_resultant: bool,
|
||||||
y_align: bool,
|
y_align: bool,
|
||||||
@@ -259,7 +280,26 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
show_resultant,
|
show_resultant,
|
||||||
)
|
)
|
||||||
|
|
||||||
spec = _build_plot_spec(channels, cursor_x1, cursor_x2, corridors_data)
|
# Resolve focus channel (if not already pinned)
|
||||||
|
focus_channel: tuple[str, Channel] | None = None
|
||||||
|
if focus_key and focus_key not in (selected_keys or []):
|
||||||
|
focus_resolved = _resolve_channels(
|
||||||
|
[focus_key],
|
||||||
|
app_state,
|
||||||
|
cfc_value,
|
||||||
|
y_align,
|
||||||
|
x_align_method or "none",
|
||||||
|
x_align_value,
|
||||||
|
False, # no resultant for single focus
|
||||||
|
)
|
||||||
|
if focus_resolved:
|
||||||
|
focus_channel = focus_resolved[0]
|
||||||
|
|
||||||
|
spec = _build_plot_spec(
|
||||||
|
channels, cursor_x1, cursor_x2, corridors_data,
|
||||||
|
focus_key=focus_key,
|
||||||
|
focus_channel=focus_channel,
|
||||||
|
)
|
||||||
fig = _engine.render(spec, resample=True)
|
fig = _engine.render(spec, resample=True)
|
||||||
|
|
||||||
# Store the resampler for relayoutData callbacks
|
# Store the resampler for relayoutData callbacks
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ def build_layout(app_state: AppState, template_names: list[str] | None = None) -
|
|||||||
),
|
),
|
||||||
# --- Hidden stores ---
|
# --- Hidden stores ---
|
||||||
dcc.Store(id="selected-channels-store", data=[]),
|
dcc.Store(id="selected-channels-store", data=[]),
|
||||||
|
dcc.Store(id="focus-channel-store", data=None),
|
||||||
dcc.Store(id="session-store", data={}),
|
dcc.Store(id="session-store", data={}),
|
||||||
dcc.Store(id="page-refresh-trigger", data={}),
|
dcc.Store(id="page-refresh-trigger", data={}),
|
||||||
dcc.Store(id="splitter-state", data={"width": 320}),
|
dcc.Store(id="splitter-state", data={"width": 320}),
|
||||||
|
|||||||
Reference in New Issue
Block a user