Skip to content

Refactor score-set histogram controls: lens dropdown + gear popover, extracted into a composable and presentational component #702

Description

@bencap

Summary

ScoreSetHistogram.vue (~1,555 lines) is rendered twice on the score-set page (Score Distribution + Clinical Score Distribution) and each instance carries a heavy, inline control rig: a tab bar of up to five views, an "Active calibration" popover trigger, and a
"Custom" tab that swaps the chart for a tall stacked form. Replace the tab bar + Custom form with two compact header controls — a lens dropdown (named presets) and a gear popover (full control set with per-series eyeball show/hide toggles) — and extract the
state and controls into a use-histogram-lens composable and a presentational HistogramControls.vue component. The two histograms remain fully independent.

Problem

  • The control surface is cluttered: per instance, the tab bar (vizOptions / activeViz), the calibration trigger, and the "Custom" tab's stacked form (two fieldsets, three checkbox groups, a star rating, two selects, and a paragraph of inline prose) all compete for
    space, and the Custom form pushes the chart down when active.
  • The controls markup and state logic live inline in a 1,555-line component and are effectively duplicated across the two instances.
  • View selection relies on fragile hardcoded index checks (activeViz == 1, activeViz == 2) that couple behavior to tab ordering and break as soon as views are reorganized.
  • The two histograms are intentionally independent (each keeps its own filter/series state); only the ClinVar control DB/version is shared. Any refactor must preserve that boundary.

Proposed behavior

Header collapses to a single row: [ Lens ▾ ] [ Active calibration ▾ ] [ ⚙ ], with the chart full-size below.

Lens dropdown — named presets that write internal state. "Custom" is not a selectable menu item; it is what the dropdown displays when the current state matches no preset. Presets carry over from today's views:

  • Overall Distribution — all series off (plain aggregate). Always available.
  • Clinical View — Pathogenic/LP + Benign/LB on; Uncertain/Conflicting off; soft-conflict fold on; no star gate; default significances; protein-effect series off. Available when some variants have clinical significance.
  • Protein Effect View — all effect buckets on (excluding "No consequence", and excluding "Start/Stop Loss" when the hide-by-default flag is set); clinical series off. Available when protein-effect options exist.
  • Calibration Class View — calibration-driven series (see Implementation notes). Available when the selected calibration is class-based.

Gear popover (reuse the existing PrimeVue Popover pattern used for the calibration trigger):

  • Series zone — one row per series: color swatch, label, and an eyeball toggle (pi-eye / pi-eye-slash). A series whose classifier matches zero current variants renders greyed/disabled (kept visible, not removed).
    • Clinical series: Pathogenic/LP (#e41a1c), Benign/LB (#377eb8), Uncertain (#999999), Conflicting (#984ea3). Uncertain and Conflicting rows appear only when the soft-conflict fold is off.
    • Protein-effect series: one row per effect bucket, using the bucket's own color and name.
  • Data zone — control DB + version selects (bound to the shared clinvar-controls store); minimum ClinVar stars (rating); "Fold soft conflicts" checkbox with its explanatory text moved from inline prose to a ? tooltip; the kept fine-grained "Include variants with
    classification" significance checkboxes (power-user membership filter); the cross-filter checkbox group retitled to e.g. "Only variants with a protein effect"; and a new "Show legend" checkbox.

Active calibration selector stays as its own header control, unchanged.

Acceptance criteria

  • The lens dropdown reproduces the previous tab behavior for Overall Distribution, Clinical View, Protein Effect View, and Calibration Class View, with the same availability gating.
  • Selecting a preset writes its full snapshot; editing any eyeball or data-zone control flips the dropdown display to "Custom"; re-selecting a preset resets state to that preset.
  • Gear eyeballs show/hide individual series; series with zero matching variants render greyed/disabled rather than being omitted.
  • Turning the soft-conflict fold on removes the Uncertain and Conflicting eyeball rows; turning it off restores them.
  • The "Show legend" checkbox hides/shows the on-chart legend.
  • The "Active calibration" selector works exactly as before.
  • The two histograms on the page behave independently: toggling series/filters/lens on one does not affect the other, except the shared control DB/version which stays synchronized.
  • No behavior depends on hardcoded activeViz index checks; those are removed.
  • HistogramControls.vue holds no state — it receives values via props and reports all changes via events; it never mutates parent state directly.
  • use-histogram-lens is instantiated per histogram instance so each gets independent reactive state.
  • npx vue-tsc --noEmit passes and npx eslint is clean for the touched files.

Implementation notes

Three-way decomposition, mirroring the existing useClinvarControls precedent (a factory returning a reactive store, instantiated once in the parent setup() and passed down as the shared clinical prop). Independence is a call-site property: instantiate the new
composable per-histogram; never declare its reactive() at module scope. Keep the Options API + small setup() style already used in ScoreSetView.vue and ScoreSetHistogram.vue — do not rewrite the large file to <script setup>.

  • New use-histogram-lens composable (ui/src/composables) — per-instance factory taking reactive deps as refs/getters: variants, the clinical store, proteinEffectOptionsAvailable, hideStartAndStopLossByDefault, defaultHistogram, and controlVersion.
    Exposes read-only state (seriesVisibility keyed by series key, minStars, softConflictsEnabled, selectedSignificances, controlVariantFilters, showLegend), derived values (availableSeries with per-series color/label/availability, the plotted series array,
    currentLens, lensOptions), and actions (applyLens(id), toggleSeries(key), plus setters). The ~140-line series-building logic moves here, rebuilt from seriesVisibility plus { softConflictsEnabled, selectedSignificances, minStars }, reusing the existing
    proteinEffectSeries, controlSeries / resolveControlSeries, and EFFECT_BUCKETS. When no series are visible, series is null so the chart draws the plain aggregate distribution.

  • New HistogramControls.vue (ui/src/components/score-set) — presentational <script setup> component. Props: lens options, current lens, available series, series visibility, the scalar data-zone values, and the clinical store (for the DB/version selects).
    Events: @apply-lens (id), @toggle-series (key), and v-model: for min-stars, soft-conflicts, selected-significances, control-variant-filters, and show-legend. Use defineModel (Vue 3.4 is available) or the explicit update:<field> emit pattern to match
    nearby components. Reused by both histogram instances.

  • ScoreSetHistogram.vue — call useHistogramLens(...) in setup() and spread onto this alongside the existing composables. Replace the inline tabs + Custom form with <HistogramControls> plus the retained calibration selector and the D3 chart container. Map
    control events to composable actions; bind the composable's series to the chart. Keep the D3 lifecycle, tooltip construction, export functions, and parent selection-sync in place. Remove the fragile index-based special cases (the activeViz == 1
    control-variant-filter branch, the activeViz == 2 variant-type-filter branch, the tooltip's view == 'clinical' check, and the clinvar-control legend-note gate), replacing them with derivations from visibility state (e.g. "any clinical series currently visible").
    Drop defaultVizApplied, the vizOptions clamp watcher, assureActiveVizIsAvailable, and the now-unused Tabs/TabList/Tab imports.

  • histogram.ts (ui/src/lib) — add a showLegend flag mirroring the existing legendNote accessor: a module-level default-true let, a fluent showLegend(value?) accessor, and a gate on the legend data-join so it also requires showLegend. In
    renderOrRefreshHistogram, call .showLegend(lens.showLegend) near the existing .legendNote(...) call and re-render on change. This change is isolated and can land last.

Assumptions / scope boundaries:

  • Calibration Class View is the one lens not driven by seriesVisibility; its series come from the selected calibration's functional classifications plus async-loaded class variants. For this issue it switches the series axis and the gear Series zone shows those class
    series read-only. Making calibration-class series individually eyeball-toggleable is a follow-up.
  • The fine-grained significance classification checkboxes are kept (in the Data zone) rather than collapsed into the four coarse clinical eyeballs.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    app: frontendTask implementation touches the frontend

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions