From cc76404fb9cbfe4beeac3b2a3997e6cad5b49dca Mon Sep 17 00:00:00 2001 From: Jurriaan Roelofs Date: Tue, 14 Apr 2026 16:13:01 +0200 Subject: [PATCH 1/2] feat(reports): render RL charts in switchable 2D/3D tabs Before, the reports page rendered either the 2D line chart OR the 3D posterior landscape based on whether arm count was at or above chart_line_threshold. Admins who wanted the other view had to change the threshold and reload. Now both charts are always available behind tab controls; the threshold decides only which tab is active on first paint. Switching tabs never reloads the page. Implementation: - templates/rl-charts.html.twig: tablist + panes with proper role/aria-selected/aria-controls wiring, shared date filter above the tabs (removes duplicate filter blocks from the old two-box layout). - css/rl-charts.css: tab button styling with is-active state, pane box layout, :focus-visible outline for keyboard users. Old .rl-chart-row/.rl-chart-box classes dropped. - js/rl-plotly-charts.js: 2D and 3D render logic split into render2d()/render3d() closures. The active tab renders immediately; the inactive tab's chart is rendered lazily on its first activation so Plotly.newPlot has a visible container to size against. Every subsequent tab switch calls Plotly.Plots.resize() against the newly-visible chart. Tab click + ArrowLeft/ArrowRight keyboard navigation are both wired via once('rl-chart-tabs'). Default tab selection: numArms > chart_line_threshold picks '3d', everything else picks '2d'. The 2D chart still caps at 20 traces for readability and still hides its legend when numArms exceeds the threshold. --- css/rl-charts.css | 55 +++++++++++++++++--- js/rl-plotly-charts.js | 96 ++++++++++++++++++++++++++++------- templates/rl-charts.html.twig | 46 +++++++++-------- 3 files changed, 150 insertions(+), 47 deletions(-) diff --git a/css/rl-charts.css b/css/rl-charts.css index 42db21c..1686f11 100644 --- a/css/rl-charts.css +++ b/css/rl-charts.css @@ -7,24 +7,65 @@ margin-bottom: 2em; } -.rl-chart-row { +.rl-chart-tabs-wrapper { + margin-bottom: 1.5em; +} + +.rl-chart-tabs { display: flex; flex-wrap: wrap; - gap: 1.5em; - margin-bottom: 1.5em; + gap: 0.25em; + border-bottom: 2px solid #d0d0d0; + margin-bottom: 0; +} + +.rl-chart-tab { + appearance: none; + background: transparent; + border: 2px solid transparent; + border-bottom: none; + border-radius: 8px 8px 0 0; + padding: 0.5em 1.25em; + margin-bottom: -2px; + font: inherit; + font-weight: 500; + color: #555; + cursor: pointer; +} + +.rl-chart-tab:hover { + color: #000; +} + +.rl-chart-tab:focus-visible { + outline: 2px solid #0d6efd; + outline-offset: -2px; } -.rl-chart-box { - flex: 1 1 100%; - min-width: 0; +.rl-chart-tab.is-active { + background: #f8f9fa; + border-color: #d0d0d0; + color: #000; +} + +.rl-chart-pane-box { background: #f8f9fa; border: 2px solid #d0d0d0; - border-radius: 8px; + border-top: none; + border-radius: 0 8px 8px 8px; padding: clamp(8px, 1.5vw, 20px); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); position: relative; } +.rl-chart-pane { + display: none; +} + +.rl-chart-pane.is-active { + display: block; +} + .rl-chart-filters { display: flex; flex-wrap: wrap; diff --git a/js/rl-plotly-charts.js b/js/rl-plotly-charts.js index 3131023..bbd3c72 100644 --- a/js/rl-plotly-charts.js +++ b/js/rl-plotly-charts.js @@ -97,7 +97,8 @@ margin: config.margin }; - // Determine number of arms and which chart to show + // Determine number of arms. Threshold only decides the default tab; + // both charts are always available for switching. let numArms = 0; if (data.lineChartData && data.lineChartData.arms) { numArms = data.lineChartData.arms.length; @@ -105,26 +106,19 @@ numArms = data.surface3d.zMatrixScore.length; } - // Chart selection based on arm count (threshold from config or default 10): - // 1-threshold arms: 2D line chart - // threshold+1 arms: 3D Posterior Landscape const lineChartThreshold = data.chartLineThreshold || 9; - const showLineChart = numArms >= 1 && numArms <= lineChartThreshold; - const showLandscape = numArms > lineChartThreshold; + const defaultTab = numArms > lineChartThreshold ? '3d' : '2d'; - // Hide unused chart containers const lineChartEl = document.getElementById('rl-plotly-2d-lines'); const surface3dEl = document.getElementById('rl-plotly-3d-surface'); - if (lineChartEl) { - lineChartEl.parentElement.parentElement.style.display = showLineChart ? 'block' : 'none'; - } - if (surface3dEl) { - surface3dEl.parentElement.parentElement.style.display = showLandscape ? 'block' : 'none'; - } - - // 1. 2D Line Chart (up to threshold arms) - if (showLineChart && data.lineChartData && data.lineChartData.arms && data.lineChartData.arms.length > 0 && lineChartEl) { + // Chart renderers are split into closures so we can render lazily + // when the user first switches to that tab. Plotly needs its container + // to be visible during initial render to size correctly. + function render2d() { + if (!(data.lineChartData && data.lineChartData.arms && data.lineChartData.arms.length > 0 && lineChartEl)) { + return; + } try { const traces2d = []; const lineChartNumArms = data.lineChartData.arms.length; @@ -213,9 +207,11 @@ } } - // 2. 3D Posterior Landscape (loss-landscape style) - threshold+1 arms - const zMatrix = metric === 'score' ? data.surface3d.zMatrixScore : data.surface3d.zMatrixRate; - if (showLandscape && data.surface3d && zMatrix && zMatrix.length > 0 && surface3dEl) { + function render3d() { + const zMatrix = data.surface3d && (metric === 'score' ? data.surface3d.zMatrixScore : data.surface3d.zMatrixRate); + if (!(zMatrix && zMatrix.length > 0 && surface3dEl)) { + return; + } try { const landscapeNumArms = zMatrix.length; const numTimePoints = data.surface3d.xValues.length; @@ -394,6 +390,68 @@ } } + const rendered = { '2d': false, '3d': false }; + const renderers = { '2d': render2d, '3d': render3d }; + + function renderTab(tab) { + if (rendered[tab]) { + return; + } + const fn = renderers[tab]; + if (fn) { + fn(); + rendered[tab] = true; + } + } + + function activateTab(target) { + const tabs = document.querySelectorAll('.rl-chart-tab'); + tabs.forEach(function(btn) { + const on = btn.dataset.rlTab === target; + btn.classList.toggle('is-active', on); + btn.setAttribute('aria-selected', on ? 'true' : 'false'); + }); + const panes = document.querySelectorAll('.rl-chart-pane'); + panes.forEach(function(pane) { + pane.classList.toggle('is-active', pane.dataset.rlPane === target); + }); + + // Render lazily on first activation so the container is visible for + // Plotly's initial dimension calculation. + renderTab(target); + + // Already-rendered charts may have mis-sized while their pane was + // hidden; ask Plotly to recompute against the now-visible container. + const chartId = target === '3d' ? 'rl-plotly-3d-surface' : 'rl-plotly-2d-lines'; + const chartEl = document.getElementById(chartId); + if (chartEl && chartEl.data && chartEl.layout) { + Plotly.Plots.resize(chartEl); + } + } + + // Wire up tab buttons. Use once() so re-attaches don't double-bind. + const tabButtons = once('rl-chart-tabs', '.rl-chart-tab'); + tabButtons.forEach(function(btn) { + btn.addEventListener('click', function() { + activateTab(btn.dataset.rlTab); + }); + btn.addEventListener('keydown', function(e) { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') { + return; + } + e.preventDefault(); + const all = Array.from(document.querySelectorAll('.rl-chart-tab')); + const idx = all.indexOf(btn); + if (idx === -1) return; + const next = e.key === 'ArrowRight' + ? all[(idx + 1) % all.length] + : all[(idx - 1 + all.length) % all.length]; + next.focus(); + activateTab(next.dataset.rlTab); + }); + }); + + activateTab(defaultTab); } /** diff --git a/templates/rl-charts.html.twig b/templates/rl-charts.html.twig index 1649ed9..451ad8b 100644 --- a/templates/rl-charts.html.twig +++ b/templates/rl-charts.html.twig @@ -13,31 +13,35 @@ #}

{{ title }}

-
-
- {% if date_filter %} -
- {{ date_filter }} -
- {% endif %} -
-
- {{ 'Tip:'|t }} {{ tip_hover }} +
+ {% if date_filter %} +
+ {{ date_filter }}
+ {% endif %} + +
+ +
-
-
-
- {% if date_filter %} -
- {{ date_filter }} +
+
+
+
+ {{ 'Tip:'|t }} {{ tip_hover }} +
+
+
+
{{ interaction_hint }}
+
+
+ {{ 'Tip:'|t }} {{ tip_taller }}
- {% endif %} -
{{ interaction_hint }}
-
-
- {{ 'Tip:'|t }} {{ tip_taller }}
From 190e11a43d390f63a3aeccc1edc86d26c3120ac8 Mon Sep 17 00:00:00 2001 From: Jurriaan Roelofs Date: Tue, 14 Apr 2026 16:19:10 +0200 Subject: [PATCH 2/2] fix: read chart container height at render time to unblock 3D scaling Plotly throws "Something went wrong with axis scaling" when the 3D surface is rendered against a container with 0 height, which happens on first tab activation because getResponsiveConfig() was called at init while the 3D pane was still display:none. Reading the height inside render2d()/render3d() picks up the real clientHeight once the pane is active. Fallback raised from 200px to 70vh to match the min-height in rl-charts.css. --- js/rl-plotly-charts.js | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/js/rl-plotly-charts.js b/js/rl-plotly-charts.js index bbd3c72..3af6770 100644 --- a/js/rl-plotly-charts.js +++ b/js/rl-plotly-charts.js @@ -21,17 +21,16 @@ } }; - /** - * Get the actual height of a chart container element. - */ function getContainerHeight(elementId) { const el = document.getElementById(elementId); if (el) { const height = el.clientHeight || el.offsetHeight; - // Return at least a minimum height - return Math.max(height, 200); + if (height > 0) { + return height; + } } - return 400; // Fallback + // Hidden tab fallback matches the 70vh min-height in rl-charts.css. + return Math.max(Math.round(window.innerHeight * 0.7), 400); } /** @@ -97,8 +96,6 @@ margin: config.margin }; - // Determine number of arms. Threshold only decides the default tab; - // both charts are always available for switching. let numArms = 0; if (data.lineChartData && data.lineChartData.arms) { numArms = data.lineChartData.arms.length; @@ -112,9 +109,6 @@ const lineChartEl = document.getElementById('rl-plotly-2d-lines'); const surface3dEl = document.getElementById('rl-plotly-3d-surface'); - // Chart renderers are split into closures so we can render lazily - // when the user first switches to that tab. Plotly needs its container - // to be visible during initial render to size correctly. function render2d() { if (!(data.lineChartData && data.lineChartData.arms && data.lineChartData.arms.length > 0 && lineChartEl)) { return; @@ -152,7 +146,7 @@ }); } - const lineChartHeight = config.height2d; + const lineChartHeight = getContainerHeight('rl-plotly-2d-lines'); // Configure x-axis based on time axis type const xAxisConfig = { @@ -374,7 +368,7 @@ }, aspectratio: { x: 1.5, y: 1, z: 0.8 } }, - height: config.height + height: getContainerHeight('rl-plotly-3d-surface') }), { responsive: true }); // Update tip text if showing subset of variants @@ -416,12 +410,8 @@ pane.classList.toggle('is-active', pane.dataset.rlPane === target); }); - // Render lazily on first activation so the container is visible for - // Plotly's initial dimension calculation. renderTab(target); - // Already-rendered charts may have mis-sized while their pane was - // hidden; ask Plotly to recompute against the now-visible container. const chartId = target === '3d' ? 'rl-plotly-3d-surface' : 'rl-plotly-2d-lines'; const chartEl = document.getElementById(chartId); if (chartEl && chartEl.data && chartEl.layout) { @@ -429,7 +419,6 @@ } } - // Wire up tab buttons. Use once() so re-attaches don't double-bind. const tabButtons = once('rl-chart-tabs', '.rl-chart-tab'); tabButtons.forEach(function(btn) { btn.addEventListener('click', function() {