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.
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-lenscomposable and a presentationalHistogramControls.vuecomponent. The two histograms remain fully independent.Problem
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 forspace, and the Custom form pushes the chart down when active.
activeViz == 1,activeViz == 2) that couple behavior to tab ordering and break as soon as views are reorganized.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:
Gear popover (reuse the existing PrimeVue Popover pattern used for the calibration trigger):
pi-eye/pi-eye-slash). A series whose classifier matches zero current variants renders greyed/disabled (kept visible, not removed).#e41a1c), Benign/LB (#377eb8), Uncertain (#999999), Conflicting (#984ea3). Uncertain and Conflicting rows appear only when the soft-conflict fold is off.?tooltip; the kept fine-grained "Include variants withclassification" 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
activeVizindex checks; those are removed.HistogramControls.vueholds no state — it receives values via props and reports all changes via events; it never mutates parent state directly.use-histogram-lensis instantiated per histogram instance so each gets independent reactive state.npx vue-tsc --noEmitpasses andnpx eslintis clean for the touched files.Implementation notes
Three-way decomposition, mirroring the existing
useClinvarControlsprecedent (a factory returning areactivestore, instantiated once in the parentsetup()and passed down as the sharedclinicalprop). Independence is a call-site property: instantiate the newcomposable per-histogram; never declare its
reactive()at module scope. Keep the Options API + smallsetup()style already used inScoreSetView.vueandScoreSetHistogram.vue— do not rewrite the large file to<script setup>.New
use-histogram-lenscomposable (ui/src/composables) — per-instance factory taking reactive deps as refs/getters:variants, theclinicalstore,proteinEffectOptionsAvailable,hideStartAndStopLossByDefault,defaultHistogram, andcontrolVersion.Exposes read-only state (
seriesVisibilitykeyed by series key,minStars,softConflictsEnabled,selectedSignificances,controlVariantFilters,showLegend), derived values (availableSerieswith per-series color/label/availability, the plottedseriesarray,currentLens,lensOptions), and actions (applyLens(id),toggleSeries(key), plus setters). The ~140-line series-building logic moves here, rebuilt fromseriesVisibilityplus{ softConflictsEnabled, selectedSignificances, minStars }, reusing the existingproteinEffectSeries,controlSeries/resolveControlSeries, andEFFECT_BUCKETS. When no series are visible,seriesis 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 theclinicalstore (for the DB/version selects).Events:
@apply-lens(id),@toggle-series(key), andv-model:formin-stars,soft-conflicts,selected-significances,control-variant-filters, andshow-legend. UsedefineModel(Vue 3.4 is available) or the explicitupdate:<field>emit pattern to matchnearby components. Reused by both histogram instances.
ScoreSetHistogram.vue— calluseHistogramLens(...)insetup()and spread ontothisalongside the existing composables. Replace the inline tabs + Custom form with<HistogramControls>plus the retained calibration selector and the D3 chart container. Mapcontrol events to composable actions; bind the composable's
seriesto the chart. Keep the D3 lifecycle, tooltip construction, export functions, and parent selection-sync in place. Remove the fragile index-based special cases (theactiveViz == 1control-variant-filter branch, the
activeViz == 2variant-type-filter branch, the tooltip'sview == '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 ashowLegendflag mirroring the existinglegendNoteaccessor: a module-level default-truelet, a fluentshowLegend(value?)accessor, and a gate on the legend data-join so it also requiresshowLegend. InrenderOrRefreshHistogram, 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:
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 classseries read-only. Making calibration-class series individually eyeball-toggleable is a follow-up.