diff --git a/config/install/rl.settings.yml b/config/install/rl.settings.yml index fdaf54b..a6911f7 100644 --- a/config/install/rl.settings.yml +++ b/config/install/rl.settings.yml @@ -1,4 +1,4 @@ debug_mode: false enable_event_log: true event_log_max_rows: 100000 -chart_line_threshold: 10 +chart_line_threshold: 9 diff --git a/css/rl-charts.css b/css/rl-charts.css index e910e3a..42db21c 100644 --- a/css/rl-charts.css +++ b/css/rl-charts.css @@ -91,21 +91,18 @@ gap: 0.75em; } -.rl-presets, -.rl-axes { +.rl-filter-group { display: flex; align-items: center; gap: 0.4em; } -.rl-presets strong, -.rl-axes strong { +.rl-filter-group strong { color: #555; white-space: nowrap; } -.rl-preset-select, -.rl-axis-select { +.rl-filter-select { padding: 0.25em 0.5em; background: #fff; border: 1px solid #ced4da; @@ -114,13 +111,11 @@ cursor: pointer; } -.rl-preset-select:hover, -.rl-axis-select:hover { +.rl-filter-select:hover { border-color: #adb5bd; } -.rl-preset-select:focus, -.rl-axis-select:focus { +.rl-filter-select:focus { border-color: #0d6efd; outline: none; box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25); diff --git a/js/rl-plotly-charts.js b/js/rl-plotly-charts.js index cb7a7e8..3131023 100644 --- a/js/rl-plotly-charts.js +++ b/js/rl-plotly-charts.js @@ -83,6 +83,13 @@ function initPlotlyCharts(data) { const config = getResponsiveConfig(); + // Y-axis metric: 'score' (Bayesian) or 'rate' (raw) + const metric = data.metric || 'score'; + const metricLabel = metric === 'score' ? 'Conversion Score' : 'Conversion Rate'; + const chartTitle = metric === 'score' + ? 'Learning-adjusted Conversion Rate (Conversion Score) Over Time' + : 'Conversion Rate Over Time'; + const defaultLayout = { paper_bgcolor: 'rgba(255,255,255,1)', plot_bgcolor: 'rgba(255,255,255,1)', @@ -94,14 +101,14 @@ let numArms = 0; if (data.lineChartData && data.lineChartData.arms) { numArms = data.lineChartData.arms.length; - } else if (data.surface3d && data.surface3d.zMatrix) { - numArms = data.surface3d.zMatrix.length; + } else if (data.surface3d && data.surface3d.zMatrixScore) { + 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 || 10; + const lineChartThreshold = data.chartLineThreshold || 9; const showLineChart = numArms >= 1 && numArms <= lineChartThreshold; const showLandscape = numArms > lineChartThreshold; @@ -116,7 +123,7 @@ surface3dEl.parentElement.parentElement.style.display = showLandscape ? 'block' : 'none'; } - // 1. 2D Line Chart - Conversion Rate Over Time (up to threshold arms) + // 1. 2D Line Chart (up to threshold arms) if (showLineChart && data.lineChartData && data.lineChartData.arms && data.lineChartData.arms.length > 0 && lineChartEl) { try { const traces2d = []; @@ -133,7 +140,8 @@ const yValues = []; arm.data.forEach(function(point) { xValues.push(point.x); - yValues.push(point.y); + // Use score or rate based on metric setting + yValues.push(metric === 'score' ? point.score : point.rate); }); traces2d.push({ @@ -146,7 +154,7 @@ color: arm.color, width: 2 }, - hovertemplate: '' + armLabel + '
' + xAxisLabel + ': %{x}
Rate: %{y:.1f}%' + hovertemplate: '' + armLabel + '
' + xAxisLabel + ': %{x}
' + metricLabel + ': %{y:.1f}%' }); } @@ -170,12 +178,12 @@ Plotly.newPlot('rl-plotly-2d-lines', traces2d, Object.assign({}, defaultLayout, { title: { - text: 'Conversion Rate Over Time', + text: chartTitle, font: { size: config.titleSize } }, xaxis: xAxisConfig, yaxis: { - title: { text: 'Conversion Rate (%)', font: { size: config.axisTitleSize } }, + title: { text: metricLabel + ' (%)', font: { size: config.axisTitleSize } }, tickfont: { size: config.tickSize }, gridcolor: 'rgba(0,0,0,0.1)', rangemode: 'tozero' @@ -206,15 +214,16 @@ } // 2. 3D Posterior Landscape (loss-landscape style) - threshold+1 arms - if (showLandscape && data.surface3d && data.surface3d.zMatrix && data.surface3d.zMatrix.length > 0 && surface3dEl) { + const zMatrix = metric === 'score' ? data.surface3d.zMatrixScore : data.surface3d.zMatrixRate; + if (showLandscape && data.surface3d && zMatrix && zMatrix.length > 0 && surface3dEl) { try { - const landscapeNumArms = data.surface3d.zMatrix.length; + const landscapeNumArms = zMatrix.length; const numTimePoints = data.surface3d.xValues.length; - // Get current (latest) conversion rate for each arm to sort + // Get current (latest) value for each arm to sort const armRates = []; for (let i = 0; i < landscapeNumArms; i++) { - const lastRate = data.surface3d.zMatrix[i][numTimePoints - 1] || 0; + const lastRate = zMatrix[i][numTimePoints - 1] || 0; armRates.push({ index: i, rate: lastRate }); } // Sort by rate ASCENDING (lowest rate = lowest index = front, highest rate = back) @@ -226,7 +235,7 @@ const armLabels = data.surface3d.armLabels || []; for (let i = 0; i < armRates.length; i++) { const origIdx = armRates[i].index; - sortedZMatrix.push(data.surface3d.zMatrix[origIdx]); + sortedZMatrix.push(zMatrix[origIdx]); sortedLabels.push(armLabels[origIdx] || ('Variant #' + origIdx)); } @@ -245,11 +254,11 @@ for (let ti = 0; ti < numTimePoints; ti++) { const xValue = data.surface3d.xValues[ti]; - const rate = sortedZMatrix[ai][ti]; + const val = sortedZMatrix[ai][ti]; // Use custom label if available for time-based axes const xDisplay = (data.xLabels && data.xLabels[xValue]) ? data.xLabels[xValue] : xValue; // Build complete hover text for each point - hoverRow.push('' + fullLabel + '
' + xAxisLabel3d + ': ' + xDisplay + '
Rate: ' + rate.toFixed(1) + '%'); + hoverRow.push('' + fullLabel + '
' + xAxisLabel3d + ': ' + xDisplay + '
' + metricLabel + ': ' + val.toFixed(1) + '%'); } hoverTextData.push(hoverRow); } @@ -260,10 +269,14 @@ tickfont: { size: config.tickSize } }; - // Show arm labels on Y-axis for small number of arms - if (landscapeNumArms <= lineChartThreshold) { + // Show arm labels on Y-axis when 15 or fewer variants for readability + // Use shorter labels for 3D axis (max 25 chars) to ensure proper alignment + if (landscapeNumArms <= 15) { + const shortLabels = sortedLabels.map(function(label) { + return truncateLabel(label, 25); + }); yAxisConfig.tickvals = armIndices; - yAxisConfig.ticktext = truncatedLabels; + yAxisConfig.ticktext = shortLabels; yAxisConfig.tickangle = 0; } @@ -346,17 +359,17 @@ z: 100 }, colorbar: { - title: { text: 'Conversion Rate', side: 'right', font: { size: config.axisTitleSize } }, + title: { text: metricLabel, side: 'right', font: { size: config.axisTitleSize } }, thickness: config.colorbarThickness, len: config.colorbarLen } }], Object.assign({}, defaultLayout, { - title: { text: 'Conversion Rate Over Time', font: { size: config.titleSize } }, + title: { text: chartTitle, font: { size: config.titleSize } }, scene: { xaxis: xAxis3dConfig, yaxis: yAxisConfig, zaxis: { - title: { text: 'Conversion Rate (%)', font: { size: config.axisTitleSize } }, + title: { text: metricLabel + ' (%)', font: { size: config.axisTitleSize } }, tickfont: { size: config.tickSize } }, camera: { @@ -373,7 +386,7 @@ if (landscapeNumArms < totalArmsAll) { const tipEl = surface3dEl.parentElement.querySelector('.rl-chart-tip'); if (tipEl) { - tipEl.innerHTML = 'Tip: Showing top ' + landscapeNumArms + ' active variants out of ' + totalArmsAll + ' total. Taller/brighter = better conversion rate.'; + tipEl.innerHTML = 'Tip: Showing top ' + landscapeNumArms + ' active variants out of ' + totalArmsAll + ' total. Taller/brighter = better ' + metricLabel.toLowerCase() + '.'; } } } catch (e) { diff --git a/rl.install b/rl.install index fc9e93b..52e93a2 100644 --- a/rl.install +++ b/rl.install @@ -614,7 +614,7 @@ function rl_update_10001() { function rl_update_10002() { $config = \Drupal::configFactory()->getEditable('rl.settings'); if ($config->get('chart_line_threshold') === NULL) { - $config->set('chart_line_threshold', 10); + $config->set('chart_line_threshold', 9); } $config->save(); diff --git a/src/Controller/ReportsController.php b/src/Controller/ReportsController.php index ad7cb84..3f3de61 100644 --- a/src/Controller/ReportsController.php +++ b/src/Controller/ReportsController.php @@ -281,6 +281,8 @@ public function experimentDetail($experiment_id) { $start_date = $request ? $request->query->get('start') : NULL; $end_date = $request ? $request->query->get('end') : NULL; $time_axis = $request ? $request->query->get('axis', 'trials') : 'trials'; + $metric = $request ? $request->query->get('metric', 'score') : 'score'; + $limit = (int) ($request ? $request->query->get('limit', 100) : 100); // Validate time axis value. $valid_axes = ['trials', 'daily', 'weekly', 'monthly', 'quarterly']; @@ -288,6 +290,18 @@ public function experimentDetail($experiment_id) { $time_axis = 'trials'; } + // Validate metric value. + $valid_metrics = ['score', 'rate']; + if (!in_array($metric, $valid_metrics)) { + $metric = 'score'; + } + + // Validate limit value. + $valid_limits = [5, 10, 25, 50, 75, 100]; + if (!in_array($limit, $valid_limits)) { + $limit = 100; + } + // Calculate date range from preset if provided. $date_range = $this->calculateDateRange($preset, $start_date, $end_date); @@ -297,7 +311,7 @@ public function experimentDetail($experiment_id) { // Build date filter form for charts. $date_filter = NULL; if (!empty($available_range)) { - $date_filter = $this->buildDateFilterForm($experiment_id, $date_range, $available_range, $preset, $time_axis); + $date_filter = $this->buildDateFilterForm($experiment_id, $date_range, $available_range, $preset, $time_axis, $metric, $limit); } // Add charts if we have snapshot data. @@ -307,7 +321,7 @@ public function experimentDetail($experiment_id) { $date_range['end'] ?? NULL ); if (!empty($snapshots)) { - $build['charts'] = $this->buildCharts($experiment_id, $snapshots, $arms, $time_axis, $date_filter); + $build['charts'] = $this->buildCharts($experiment_id, $snapshots, $arms, $time_axis, $date_filter, $metric, $limit); } else { // Show appropriate message based on whether event logging is enabled. @@ -333,7 +347,8 @@ public function experimentDetail($experiment_id) { ['data' => $this->t('Variant'), 'field' => 'arm_id'], ['data' => $this->t('Impressions'), 'field' => 'turns'], ['data' => $this->t('Conversions'), 'field' => 'rewards'], - ['data' => $this->t('Rate'), 'field' => 'success_rate', 'sort' => 'desc'], + ['data' => $this->t('Conversion Rate'), 'field' => 'conversion_rate', 'sort' => 'desc'], + ['data' => $this->t('Conversion Score'), 'field' => 'conversion_score'], ]; // Build row data with sortable values. @@ -342,7 +357,13 @@ public function experimentDetail($experiment_id) { // Validate and sanitize arm data. $arm = $this->armDataValidator->validateAndSanitize($arm, $experiment_id, $arm->arm_id); - $success_rate = $arm->turns > 0 ? ($arm->rewards / $arm->turns) * 100 : 0; + // Raw conversion rate. + $conversion_rate = $arm->turns > 0 ? ($arm->rewards / $arm->turns) * 100 : 0; + + // Bayesian posterior mean (Conversion Score). + $alpha = $arm->rewards + 1; + $beta = max(1, $arm->turns - $arm->rewards + 1); + $conversion_score = ($alpha / ($alpha + $beta)) * 100; // Get decorated arm name or fallback to escaped arm ID. $arm_display = $this->decoratorManager->decorateArm($experiment_id, $arm->arm_id); @@ -353,24 +374,28 @@ public function experimentDetail($experiment_id) { 'arm_name' => $arm_name, 'turns' => (int) $arm->turns, 'rewards' => (int) $arm->rewards, - 'success_rate' => $success_rate, + 'conversion_rate' => $conversion_rate, + 'conversion_score' => $conversion_score, ]; } // Sort by the selected column. - $order = $request ? $request->query->get('order', 'Rate') : 'Rate'; + $order = $request ? $request->query->get('order', 'Conversion Rate') : 'Conversion Rate'; $sort = $request ? $request->query->get('sort', 'desc') : 'desc'; - $sort_field = 'success_rate'; + $sort_field = 'conversion_rate'; if (stripos($order, 'Variant') !== FALSE) { $sort_field = 'arm_id'; } elseif (stripos($order, 'Impression') !== FALSE) { $sort_field = 'turns'; } - elseif (stripos($order, 'Conversion') !== FALSE) { + elseif (stripos($order, 'Conversions') !== FALSE) { $sort_field = 'rewards'; } + elseif (stripos($order, 'Conversion Rate') !== FALSE) { + $sort_field = 'conversion_rate'; + } // @phpstan-ignore argument.unresolvableType, argument.unresolvableType usort($arm_data, static function (array $a, array $b) use ($sort_field, $sort): int { @@ -385,7 +410,8 @@ public function experimentDetail($experiment_id) { ['data' => ['#markup' => $data['arm_name']]], $data['turns'], $data['rewards'], - number_format($data['success_rate'], 2) . '%', + number_format($data['conversion_rate'], 2) . '%', + number_format($data['conversion_score'], 2) . '%', ]; } @@ -414,11 +440,15 @@ public function experimentDetail($experiment_id) { * or 'quarterly'. * @param array|null $date_filter * Optional date filter render array. + * @param string $metric + * Y-axis metric: 'score' (Bayesian) or 'rate' (raw). + * @param int $limit + * Maximum number of top variants for 3D chart. * * @return array * Render array with charts. */ - protected function buildCharts(string $experiment_id, array $snapshots, array $arms, string $time_axis = 'trials', ?array $date_filter = NULL): array { + protected function buildCharts(string $experiment_id, array $snapshots, array $arms, string $time_axis = 'trials', ?array $date_filter = NULL, string $metric = 'score', int $limit = 100): array { // Organize snapshots by arm and calculate chart data. $arms_data = []; $all_x_values = []; @@ -444,15 +474,19 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a $turns = (int) $snapshot->turns; $rewards = (int) $snapshot->rewards; - // Calculate posterior mean. + // Calculate posterior mean (Conversion Score). $alpha = $rewards + 1; $beta = max(1, $turns - $rewards + 1); - $mean = $alpha / ($alpha + $beta); + $score = $alpha / ($alpha + $beta); + + // Calculate raw conversion rate. + $rate = $turns > 0 ? $rewards / $turns : 0; // For time-based axes, keep the latest snapshot per bucket. if (!isset($arms_data[$arm_id][$x_value]) || $created > $arms_data[$arm_id][$x_value]['created']) { $arms_data[$arm_id][$x_value] = [ - 'mean' => $mean, + 'score' => $score, + 'rate' => $rate, 'turns' => $turns, 'rewards' => $rewards, 'created' => $created, @@ -480,8 +514,8 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a } arsort($arm_totals); - // Use up to 100 arms for 3D Plotly visualizations. - $top_arms_3d = array_slice(array_keys($arm_totals), 0, 100); + // Use up to $limit arms for 3D Plotly visualizations. + $top_arms_3d = array_slice(array_keys($arm_totals), 0, $limit); // Build arm label map using decorators for human-readable names. $arm_labels = []; @@ -516,8 +550,14 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a ]; // Prepare chart data for both 2D line and 3D surface (single loop). + // Pass both score and rate so JS can switch between them. $line_chart_data = ['arms' => []]; - $surface_3d_data = ['xValues' => $x_values, 'armLabels' => [], 'zMatrix' => []]; + $surface_3d_data = [ + 'xValues' => $x_values, + 'armLabels' => [], + 'zMatrixScore' => [], + 'zMatrixRate' => [], + ]; $i = 0; foreach ($top_arms_3d as $arm_id) { if (!isset($arms_data[$arm_id])) { @@ -526,13 +566,16 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a $label = $arm_labels[$arm_id]; $color = $colors[$i % count($colors)]; $data_points = []; - $z_row = []; + $z_row_score = []; + $z_row_rate = []; foreach ($x_values as $x) { if (isset($arms_data[$arm_id][$x])) { - $rate = round($arms_data[$arm_id][$x]['mean'] * 100, 2); - $data_points[] = ['x' => $x, 'y' => $rate]; - $z_row[] = $rate; + $score = round($arms_data[$arm_id][$x]['score'] * 100, 2); + $rate = round($arms_data[$arm_id][$x]['rate'] * 100, 2); + $data_points[] = ['x' => $x, 'score' => $score, 'rate' => $rate]; + $z_row_score[] = $score; + $z_row_rate[] = $rate; } else { // Find closest previous value for 3D surface interpolation. @@ -542,18 +585,20 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a $closest = $point; } } - $z_row[] = $closest ? round($closest['mean'] * 100, 2) : 0; + $z_row_score[] = $closest ? round($closest['score'] * 100, 2) : 0; + $z_row_rate[] = $closest ? round($closest['rate'] * 100, 2) : 0; } } $line_chart_data['arms'][] = ['label' => $label, 'data' => $data_points, 'color' => $color]; $surface_3d_data['armLabels'][] = $label; - $surface_3d_data['zMatrix'][] = $z_row; + $surface_3d_data['zMatrixScore'][] = $z_row_score; + $surface_3d_data['zMatrixRate'][] = $z_row_rate; $i++; } // Plotly data (up to 100 arms for 3D visualizations). - $chart_line_threshold = $this->config('rl.settings')->get('chart_line_threshold') ?? 10; + $chart_line_threshold = $this->config('rl.settings')->get('chart_line_threshold') ?? 9; $total_arms_all = count($arm_totals); $plotly_data = [ 'lineChartData' => $line_chart_data, @@ -562,6 +607,7 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a 'totalArmsAll' => $total_arms_all, 'chartLineThreshold' => $chart_line_threshold, 'timeAxis' => $time_axis, + 'metric' => $metric, 'xAxisLabel' => (string) $x_axis_label, 'xLabels' => !empty($x_labels) ? $x_labels : NULL, ]; @@ -581,7 +627,7 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a '#theme' => 'rl_charts', '#title' => $this->t('Performance Over Time'), '#tip_hover' => $this->t('Hover for details. Higher = better.'), - '#tip_taller' => $this->t('Hover for details. Taller/brighter = better conversion rate.'), + '#tip_taller' => $this->t('Hover for details. Taller/brighter = better conversion score.'), '#interaction_hint' => $this->t('Drag to rotate @bullet Scroll to zoom', ['@bullet' => '•']), '#date_filter' => $date_filter, ]; @@ -765,11 +811,15 @@ protected function getTimeAxisLabel(string $time_axis) { * Currently selected preset. * @param string $current_axis * Currently selected time axis. + * @param string $current_metric + * Currently selected y-axis metric. + * @param int $current_limit + * Currently selected max variants limit for 3D chart. * * @return array * Render array for date filter. */ - protected function buildDateFilterForm(string $experiment_id, array $current_range, array $available_range, string $current_preset, string $current_axis = 'trials'): array { + protected function buildDateFilterForm(string $experiment_id, array $current_range, array $available_range, string $current_preset, string $current_axis = 'trials', string $current_metric = 'score', int $current_limit = 100): array { $base_url = Url::fromRoute('rl.reports.experiment_detail', [ 'experiment_id' => $experiment_id, ]); @@ -797,17 +847,41 @@ protected function buildDateFilterForm(string $experiment_id, array $current_ran 'quarterly' => $this->t('Quarterly'), ]; - // Helper to build dropdown options with URLs. - $build_options = function (array $items, string $param, string $other_param, string $other_value, string $other_default) use ($base_url): array { + $metrics = [ + 'score' => $this->t('Conversion Score'), + 'rate' => $this->t('Conversion Rate'), + ]; + + $limits = [ + 5 => '5', + 10 => '10', + 25 => '25', + 50 => '50', + 75 => '75', + 100 => '100', + ]; + + // Helper to build dropdown options with URLs, preserving other params. + $build_options = function (array $items, string $param, array $other_params) use ($base_url): array { $options = $urls = []; foreach ($items as $key => $label) { $url = clone $base_url; - $query = []; - if ($key && $key !== $other_default) { + $query = $other_params; + if ($key) { $query[$param] = $key; } - if ($other_value && $other_value !== $other_default) { - $query[$other_param] = $other_value; + // Remove default values from query. + if (isset($query['axis']) && $query['axis'] === 'trials') { + unset($query['axis']); + } + if (isset($query['preset']) && $query['preset'] === '') { + unset($query['preset']); + } + if (isset($query['metric']) && $query['metric'] === 'score') { + unset($query['metric']); + } + if (isset($query['limit']) && (int) $query['limit'] === 100) { + unset($query['limit']); } if (!empty($query)) { $url->setOption('query', $query); @@ -818,8 +892,26 @@ protected function buildDateFilterForm(string $experiment_id, array $current_ran return ['options' => $options, 'urls' => $urls]; }; - $preset_data = $build_options($presets, 'preset', 'axis', $current_axis, 'trials'); - $axis_data = $build_options($axes, 'axis', 'preset', $current_preset, ''); + $preset_data = $build_options($presets, 'preset', [ + 'axis' => $current_axis, + 'metric' => $current_metric, + 'limit' => $current_limit, + ]); + $axis_data = $build_options($axes, 'axis', [ + 'preset' => $current_preset, + 'metric' => $current_metric, + 'limit' => $current_limit, + ]); + $metric_data = $build_options($metrics, 'metric', [ + 'preset' => $current_preset, + 'axis' => $current_axis, + 'limit' => $current_limit, + ]); + $limit_data = $build_options($limits, 'limit', [ + 'preset' => $current_preset, + 'axis' => $current_axis, + 'metric' => $current_metric, + ]); // Format available date range for display. $range_text = $this->t('Data available from @start to @end', [ @@ -832,7 +924,7 @@ protected function buildDateFilterForm(string $experiment_id, array $current_ran '#attributes' => ['class' => ['rl-date-filter']], 'presets' => [ '#type' => 'container', - '#attributes' => ['class' => ['rl-presets']], + '#attributes' => ['class' => ['rl-filter-group']], 'label' => [ '#markup' => '' . $this->t('Time range:') . ' ', ], @@ -841,14 +933,14 @@ protected function buildDateFilterForm(string $experiment_id, array $current_ran '#options' => $preset_data['options'], '#value' => $current_preset, '#attributes' => [ - 'class' => ['rl-preset-select', 'rl-filter-select'], + 'class' => ['rl-filter-select'], 'data-urls' => json_encode($preset_data['urls']), ], ], ], 'axes' => [ '#type' => 'container', - '#attributes' => ['class' => ['rl-axes']], + '#attributes' => ['class' => ['rl-filter-group']], 'label' => [ '#markup' => '' . $this->t('X-axis:') . ' ', ], @@ -857,11 +949,43 @@ protected function buildDateFilterForm(string $experiment_id, array $current_ran '#options' => $axis_data['options'], '#value' => $current_axis, '#attributes' => [ - 'class' => ['rl-axis-select', 'rl-filter-select'], + 'class' => ['rl-filter-select'], 'data-urls' => json_encode($axis_data['urls']), ], ], ], + 'metrics' => [ + '#type' => 'container', + '#attributes' => ['class' => ['rl-filter-group']], + 'label' => [ + '#markup' => '' . $this->t('Y-axis:') . ' ', + ], + 'select' => [ + '#type' => 'select', + '#options' => $metric_data['options'], + '#value' => $current_metric, + '#attributes' => [ + 'class' => ['rl-filter-select'], + 'data-urls' => json_encode($metric_data['urls']), + ], + ], + ], + 'limits' => [ + '#type' => 'container', + '#attributes' => ['class' => ['rl-filter-group']], + 'label' => [ + '#markup' => '' . $this->t('Max variants:') . ' ', + ], + 'select' => [ + '#type' => 'select', + '#options' => $limit_data['options'], + '#value' => $current_limit, + '#attributes' => [ + 'class' => ['rl-filter-select'], + 'data-urls' => json_encode($limit_data['urls']), + ], + ], + ], 'range_info' => [ '#markup' => '
' . $range_text . '
', ], diff --git a/src/Form/RlSettingsForm.php b/src/Form/RlSettingsForm.php index c134eb0..38453dd 100644 --- a/src/Form/RlSettingsForm.php +++ b/src/Form/RlSettingsForm.php @@ -75,7 +75,7 @@ public function buildForm(array $form, FormStateInterface $form_state): array { '#type' => 'number', '#title' => $this->t('Line chart threshold'), '#description' => $this->t('Maximum number of variants to show in the 2D line chart. Experiments with more variants will use the 3D landscape visualization instead.'), - '#default_value' => $config->get('chart_line_threshold') ?? 10, + '#default_value' => $config->get('chart_line_threshold') ?? 9, '#min' => 2, '#max' => 50, ]; diff --git a/templates/rl-charts.html.twig b/templates/rl-charts.html.twig index 42f50f6..1649ed9 100644 --- a/templates/rl-charts.html.twig +++ b/templates/rl-charts.html.twig @@ -34,7 +34,7 @@ {{ date_filter }} {% endif %} -
{{ interaction_hint }}
+
{{ interaction_hint }}
{{ 'Tip:'|t }} {{ tip_taller }}