583 lines
22 KiB
HTML
583 lines
22 KiB
HTML
<html>
|
|
<head>
|
|
<title>Benchmarks</title>
|
|
<meta charset="UTF-8">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
|
<style type="text/css">
|
|
body { margin: 20px; font-size: 16px; color: #333; }
|
|
a { text-decoration: none; color: #06b; }
|
|
h2 { color: #222; font-size: 1.4em; }
|
|
h3 { color: #555; font-size: 1.2em; }
|
|
h4 { color: #888; font-size: 1.0em; }
|
|
.nav-tabs { font-size: 1.2em; }
|
|
.nav-tabs .nav-link { color: #999; }
|
|
.nav-tabs .nav-link.active { color: #555; font-weight: 500; }
|
|
.tab-content { margin: 20px 20px; }
|
|
|
|
/* Fixed width for header buttons to ensure groups match in size. */
|
|
.btn-time-range { width: 100px; }
|
|
.btn-scale-mode { width: 133.33px; /* (100 * 4) / 3 */ }
|
|
</style>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js" integrity="sha384-jb8JQMbMoBUzgWatfe6COACi2ljcDdZQ2OxczGA3bGNeWe+6DChMTBJemed7ZnvJ" crossorigin="anonymous"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-error-bars@4.4.5/build/index.umd.min.js" integrity="sha384-pmEzimiQMD67BbR0zE+ss0ugx98UAGIaiNlaKtGz7BBQ3UaT69z1CN323+oCogy0" crossorigin="anonymous"></script>
|
|
<script>
|
|
let chart_instances = [];
|
|
let current_scale_mode = 'linear-clipped';
|
|
let current_time_range = '4m';
|
|
let latest_timestamp = 0;
|
|
|
|
/* Constants for time calculations. */
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
/* Find the latest timestamp across all time-series data to use as "now". */
|
|
function calculate_latest_timestamp(json_data) {
|
|
let max_ts = 0;
|
|
for (const benchmark of json_data) {
|
|
if (benchmark.chart_type === 'line' && benchmark.data.labels) {
|
|
for (const ts of benchmark.data.labels) {
|
|
if (ts && ts > max_ts) {
|
|
max_ts = ts;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return max_ts;
|
|
}
|
|
|
|
/* Update the global time filter and refresh all time-series charts. */
|
|
function set_time_range(range, button) {
|
|
current_time_range = range;
|
|
|
|
/* Update button states. */
|
|
document.querySelectorAll('.btn-time-range').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
btn.classList.replace('btn-secondary', 'btn-outline-secondary');
|
|
});
|
|
if (button) {
|
|
button.classList.add('active');
|
|
button.classList.replace('btn-outline-secondary', 'btn-secondary');
|
|
} else {
|
|
const target_btn = document.querySelector(`.btn-time-range[data-range='${range}']`);
|
|
if (target_btn) {
|
|
target_btn.classList.add('active');
|
|
target_btn.classList.replace('btn-outline-secondary', 'btn-secondary');
|
|
}
|
|
}
|
|
|
|
const min_timestamp = get_min_timestamp(range);
|
|
|
|
for (const chart of chart_instances) {
|
|
if (chart.config._is_time_series) {
|
|
apply_time_range_to_chart(chart, min_timestamp);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Calculate the start timestamp for a given time range string. */
|
|
function get_min_timestamp(range) {
|
|
if (range === 'all') return 0;
|
|
if (range === '1m') return latest_timestamp - 30 * DAY_MS;
|
|
if (range === '4m') return latest_timestamp - 120 * DAY_MS;
|
|
if (range === '1y') return latest_timestamp - 365 * DAY_MS;
|
|
return 0;
|
|
}
|
|
|
|
/* Filter a chart's data points based on the selected time range. */
|
|
function apply_time_range_to_chart(chart, min_timestamp) {
|
|
const full_data = chart.config._full_data;
|
|
|
|
/* Find indices that match the time range. */
|
|
const valid_indices = [];
|
|
for (let i = 0; i < full_data.labels_raw.length; i++) {
|
|
const timestamp = full_data.labels_raw[i];
|
|
if (min_timestamp === 0 || timestamp >= min_timestamp) {
|
|
valid_indices.push(i);
|
|
}
|
|
}
|
|
|
|
/* Reconstruct labels and datasets based on valid indices. */
|
|
chart.data.labels = valid_indices.map(i => full_data.labels[i]);
|
|
chart.data.datasets.forEach((ds, ds_index) => {
|
|
const full_ds_data = full_data.datasets[ds_index].data;
|
|
ds.data = valid_indices.map(i => full_ds_data[i]);
|
|
});
|
|
|
|
/* Hide the entire section if there's no data in this range. */
|
|
const section = chart.canvas.closest('section');
|
|
if (section) {
|
|
section.style.display = valid_indices.length === 0 ? 'none' : 'block';
|
|
}
|
|
|
|
update_chart_bounds(chart);
|
|
chart.update('none');
|
|
}
|
|
|
|
/* Change the Y-axis scale (linear, log, etc.) for all visible charts. */
|
|
function set_scale_mode(mode, button) {
|
|
current_scale_mode = mode;
|
|
const scale_type = mode === 'logarithmic' ? 'logarithmic' : 'linear';
|
|
const clip_outliers = mode === 'linear-clipped';
|
|
|
|
/* Update button states. */
|
|
document.querySelectorAll('.btn-scale-mode').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
btn.classList.replace('btn-secondary', 'btn-outline-secondary');
|
|
});
|
|
if (button) {
|
|
button.classList.add('active');
|
|
button.classList.replace('btn-outline-secondary', 'btn-secondary');
|
|
} else {
|
|
const target_btn = document.querySelector(`.btn-scale-mode[data-mode='${mode}']`);
|
|
if (target_btn) {
|
|
target_btn.classList.add('active');
|
|
target_btn.classList.replace('btn-outline-secondary', 'btn-secondary');
|
|
}
|
|
}
|
|
|
|
/* Only update visible charts immediately for performance. */
|
|
for (const chart of chart_instances) {
|
|
const canvas = chart.canvas;
|
|
const is_visible = canvas.offsetParent !== null;
|
|
|
|
if (is_visible) {
|
|
apply_scale_to_chart(chart, scale_type, clip_outliers, true);
|
|
} else {
|
|
/* Mark for deferred update. */
|
|
chart._needs_scale_update = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Configure a specific chart instance with the selected scale settings. */
|
|
function apply_scale_to_chart(chart, scale_type, clip_outliers, animate) {
|
|
chart.options.scales.y.type = scale_type;
|
|
update_chart_bounds(chart);
|
|
chart.update(animate ? {
|
|
duration: scale_type === 'logarithmic' ? 800 : 0,
|
|
easing: 'easeOutQuart'
|
|
} : 'none');
|
|
chart._needs_scale_update = false;
|
|
}
|
|
|
|
/* Calculate robust and absolute maximums for a set of values.
|
|
* The robust maximum is used to clip outliers, calculated as the 75th percentile
|
|
* plus 5 times the Interquartile Range (IQR). */
|
|
function calculate_robust_max(values) {
|
|
if (values.length === 0) {
|
|
return { absolute_max: 0, robust_max: 0 };
|
|
}
|
|
|
|
const absolute_max = Math.max(...values);
|
|
let robust_max = absolute_max;
|
|
|
|
if (values.length > 4) {
|
|
/* Create a copy to sort. */
|
|
const sorted = Float64Array.from(values).sort();
|
|
const q1 = sorted[Math.floor(sorted.length * 0.25)];
|
|
const q3 = sorted[Math.floor(sorted.length * 0.75)];
|
|
const iqr = q3 - q1;
|
|
robust_max = q3 + 5 * iqr;
|
|
}
|
|
|
|
return { absolute_max, robust_max };
|
|
}
|
|
|
|
/* Recalculate robust and absolute maximums based on currently visible datasets. */
|
|
function update_chart_bounds(chart) {
|
|
let absolute_max = 0;
|
|
let robust_max = 0;
|
|
|
|
chart.data.datasets.forEach((ds, i) => {
|
|
if (!chart.isDatasetVisible(i)) {
|
|
return;
|
|
}
|
|
/* Filter out null/undefined values. */
|
|
const ds_values = ds.data.filter(v => v !== null && v !== undefined);
|
|
const ds_stats = calculate_robust_max(ds_values);
|
|
|
|
absolute_max = Math.max(absolute_max, ds_stats.absolute_max);
|
|
robust_max = Math.max(robust_max, ds_stats.robust_max);
|
|
});
|
|
|
|
chart.config._absolute_max = absolute_max;
|
|
chart.config._robust_max = robust_max;
|
|
|
|
const is_clipped = current_scale_mode === 'linear-clipped';
|
|
update_chart_max(chart, is_clipped);
|
|
}
|
|
|
|
/* Set a fixed maximum for the Y-axis if outlier clipping is active. */
|
|
function update_chart_max(chart, clip) {
|
|
const robust_max = chart.config._robust_max;
|
|
const absolute_max = chart.config._absolute_max;
|
|
if (clip && chart.options.scales.y.type === 'linear' && robust_max < absolute_max) {
|
|
chart.options.scales.y.max = robust_max;
|
|
} else {
|
|
chart.options.scales.y.max = undefined;
|
|
}
|
|
}
|
|
|
|
/* Show/hide UI controls like time ranges or scale modes based on the active tab's content. */
|
|
function update_ui_for_active_tab() {
|
|
let has_time_series = false;
|
|
for (const chart of chart_instances) {
|
|
if (chart.canvas.offsetParent !== null && chart.config._is_time_series) {
|
|
has_time_series = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const clipped_btn = document.getElementById('linear-clipped');
|
|
if (has_time_series) {
|
|
clipped_btn.classList.remove('d-none');
|
|
} else {
|
|
clipped_btn.classList.add('d-none');
|
|
if (current_scale_mode === 'linear-clipped') {
|
|
set_scale_mode('linear');
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Format Y-axis tick labels for memory or large numeric values. */
|
|
function chart_y_axis_tick_callback(value, index, values, is_memory) {
|
|
if (is_memory) {
|
|
return format_memory(value);
|
|
}
|
|
if (Math.abs(value) >= 10000) {
|
|
return value.toExponential(2);
|
|
}
|
|
return +(value.toFixed(4));
|
|
}
|
|
|
|
/* Isolate a single dataset when its legend item is clicked, or show all if already isolated. */
|
|
function chart_legend_click_callback(e, legendItem, legend) {
|
|
const index = legendItem.datasetIndex;
|
|
const ci = legend.chart;
|
|
const is_visible = ci.isDatasetVisible(index);
|
|
|
|
/* Check if any other dataset is currently visible. */
|
|
let other_visible = false;
|
|
for (let i = 0; i < ci.data.datasets.length; i++) {
|
|
if (i !== index && ci.isDatasetVisible(i)) {
|
|
other_visible = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!other_visible && is_visible) {
|
|
/* If this was the only one visible and we clicked it, show all. */
|
|
for (let i = 0; i < ci.data.datasets.length; i++) {
|
|
ci.setDatasetVisibility(i, true);
|
|
}
|
|
} else {
|
|
/* Otherwise, isolate this one. */
|
|
for (let i = 0; i < ci.data.datasets.length; i++) {
|
|
ci.setDatasetVisibility(i, i === index);
|
|
}
|
|
}
|
|
update_chart_bounds(ci);
|
|
ci.update();
|
|
}
|
|
|
|
/* Format tooltip labels to include dataset names and formatted values. */
|
|
function chart_tooltip_label_callback(context, is_memory) {
|
|
let label = context.dataset.label || '';
|
|
if (label) {
|
|
label += ': ';
|
|
}
|
|
if (context.parsed.y !== null) {
|
|
if (is_memory) {
|
|
label += format_memory(context.parsed.y);
|
|
} else {
|
|
label += context.parsed.y.toFixed(4);
|
|
}
|
|
}
|
|
return label;
|
|
}
|
|
|
|
/* Convert byte values into human-readable strings (MB, GB, etc.). */
|
|
function format_memory(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
/* Main entry point to initialize all benchmarks, create tabs, and render Chart.js instances. */
|
|
function draw_charts() {
|
|
/* Global Chart Configuration. */
|
|
Chart.defaults.plugins.colors.enabled = false;
|
|
Chart.defaults.animation.duration = 0;
|
|
Chart.defaults.transitions.active.animation.duration = 400;
|
|
|
|
/* Material Design Base Colors (HSL). */
|
|
const base_colors = [
|
|
[217, 89, 61], /* Blue */
|
|
[5, 69, 54], /* Red */
|
|
[44, 100, 48], /* Yellow */
|
|
[151, 83, 34], /* Green */
|
|
[291, 47, 51], /* Purple */
|
|
[187, 100, 38], /* Cyan */
|
|
[14, 100, 63], /* Deep Orange */
|
|
[60, 61, 38], /* Lime */
|
|
[231, 48, 56], /* Indigo */
|
|
[340, 82, 66], /* Pink */
|
|
[174, 42, 51], /* Teal */
|
|
[336, 78, 43] /* Dark Pink */
|
|
];
|
|
|
|
/* Generate a distinct color for each dataset using a Material Design base palette. */
|
|
function get_chart_color(index) {
|
|
const base = base_colors[index % base_colors.length];
|
|
const iteration = Math.floor(index / base_colors.length);
|
|
let l = base[2];
|
|
|
|
if (iteration > 0) {
|
|
/* Vary lightness for more datasets: alternates between darkening and lightening. */
|
|
const offset = (iteration % 2 === 1 ? -15 : 15) * Math.ceil(iteration / 2);
|
|
l = Math.max(10, Math.min(90, l + offset));
|
|
}
|
|
return `hsl(${base[0]}, ${base[1]}%, ${l}%)`;
|
|
}
|
|
|
|
/* This placeholder is replaced by the actual JSON data during generation. */
|
|
const json_data = %JSON_DATA%;
|
|
|
|
const max_ts = calculate_latest_timestamp(json_data);
|
|
if (max_ts > 0) {
|
|
latest_timestamp = max_ts;
|
|
}
|
|
|
|
const charts_nav_elem = document.getElementById("charts-nav");
|
|
const charts_content_elem = document.getElementById("charts-content");
|
|
|
|
/* Clear contents. */
|
|
charts_nav_elem.replaceChildren();
|
|
charts_content_elem.replaceChildren();
|
|
|
|
chart_instances = [];
|
|
|
|
/* Prepare UI and charts queue for each device. */
|
|
for (let i = 0; i < json_data.length; i++) {
|
|
const benchmark = json_data[i];
|
|
|
|
const tab_name = benchmark['name'].split(" ")[0];
|
|
const tab_id = "benchmark-" + tab_name;
|
|
let tab_div = document.getElementById(tab_id);
|
|
|
|
if (!tab_div) {
|
|
/* Create tab button. */
|
|
const li_nav = document.createElement('li');
|
|
li_nav.className = "nav-item";
|
|
charts_nav_elem.appendChild(li_nav);
|
|
|
|
const button_nav = document.createElement('button');
|
|
button_nav.id = tab_id + "-tab";
|
|
button_nav.classList.add("nav-link");
|
|
button_nav.setAttribute("data-bs-toggle", "tab");
|
|
button_nav.setAttribute("data-bs-target", "#" + tab_id);
|
|
button_nav.setAttribute("type", "button");
|
|
button_nav.setAttribute("role", "tab");
|
|
button_nav.setAttribute("aria-controls", tab_id);
|
|
button_nav.textContent = tab_name;
|
|
li_nav.appendChild(button_nav);
|
|
|
|
/* Create chart container div. */
|
|
tab_div = document.createElement('div');
|
|
tab_div.id = tab_id;
|
|
tab_div.classList.add("tab-pane");
|
|
tab_div.setAttribute("aria-labelledby", button_nav.id);
|
|
charts_content_elem.appendChild(tab_div);
|
|
|
|
if (i === 0) {
|
|
button_nav.classList.add("active");
|
|
tab_div.classList.add('show', 'active');
|
|
}
|
|
}
|
|
|
|
/* Wrap everything in a section for easy hiding. */
|
|
const section = document.createElement('section');
|
|
section.className = "benchmark-section mb-5";
|
|
tab_div.appendChild(section);
|
|
|
|
/* Create titles. */
|
|
const subtitle_h3 = document.createElement('h3');
|
|
subtitle_h3.textContent = benchmark['name'];
|
|
section.appendChild(subtitle_h3);
|
|
|
|
const subtitle_h4 = document.createElement('h4');
|
|
subtitle_h4.textContent = benchmark['device'];
|
|
section.appendChild(subtitle_h4);
|
|
|
|
if (benchmark['device_cpu'] && benchmark['device_cpu'] != benchmark['device']) {
|
|
const subtitle_h4 = document.createElement('h4');
|
|
subtitle_h4.textContent = benchmark['device_cpu'];
|
|
section.appendChild(subtitle_h4);
|
|
}
|
|
|
|
/* Create chart container. */
|
|
const chart_container = document.createElement('div');
|
|
chart_container.style.position = 'relative';
|
|
chart_container.style.height = '500px';
|
|
chart_container.style.width = '100%';
|
|
section.appendChild(chart_container);
|
|
|
|
/* Create chart canvas. */
|
|
const canvas = document.createElement('canvas');
|
|
canvas.id = "chart-" + i;
|
|
chart_container.appendChild(canvas);
|
|
|
|
/* Prepare Data. */
|
|
const chart_data = benchmark['data'];
|
|
const is_time_series = benchmark['chart_type'] === 'line';
|
|
const labels_raw = is_time_series ? [...chart_data.labels] : [];
|
|
|
|
if (is_time_series) {
|
|
chart_data.labels = chart_data.labels.map(ts => {
|
|
if (ts === null) return "";
|
|
return new Date(ts).toISOString().split('T')[0];
|
|
});
|
|
document.getElementById("timeRangeGroup").classList.remove("d-none");
|
|
}
|
|
|
|
/* Assign Colors Manually. */
|
|
for (let j = 0; j < chart_data.datasets.length; j++) {
|
|
const color = get_chart_color(j);
|
|
const ds = chart_data.datasets[j];
|
|
ds.backgroundColor = color;
|
|
ds.borderColor = color;
|
|
ds.borderWidth = 2;
|
|
ds.pointBackgroundColor = color;
|
|
ds.pointBorderColor = '#fff';
|
|
ds.pointRadius = 0;
|
|
ds.pointHoverRadius = 5;
|
|
ds.pointHitRadius = 10;
|
|
ds.hoverBackgroundColor = color;
|
|
ds.hoverBorderColor = '#fff';
|
|
ds.hoverBorderWidth = 2;
|
|
}
|
|
|
|
const is_memory = benchmark['name'].toLowerCase().indexOf("memory") !== -1;
|
|
const use_error_bars = benchmark['use_error_bars']
|
|
|
|
/* Draw Chart. */
|
|
const chart_type = (benchmark['chart_type'] === 'line'? 'line': 'bar') + (use_error_bars ? 'WithErrorBars': '');
|
|
const chart = new Chart(canvas, {
|
|
type: chart_type,
|
|
data: chart_data,
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
parsing: {
|
|
xAxisKey: 'x',
|
|
yAxisKey: 'y'
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: {
|
|
autoSkip: true,
|
|
autoSkipPadding: 20,
|
|
callback: (value, index, values) => chart_y_axis_tick_callback(value, index, values, is_memory)
|
|
}
|
|
}
|
|
},
|
|
interaction: {
|
|
mode: benchmark['chart_type'] === 'line' ? 'nearest' : 'index',
|
|
intersect: benchmark['chart_type'] === 'line',
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
position: 'right',
|
|
onClick: chart_legend_click_callback
|
|
},
|
|
tooltip: {
|
|
enabled: true,
|
|
callbacks: {
|
|
label: (context) => chart_tooltip_label_callback(context, is_memory)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
chart.config._is_time_series = is_time_series;
|
|
/* Deep copy data for filtering. */
|
|
chart.config._full_data = {
|
|
labels: [...chart_data.labels],
|
|
labels_raw: labels_raw,
|
|
datasets: chart_data.datasets.map(ds => ({ ...ds, data: [...ds.data] }))
|
|
};
|
|
|
|
if (is_time_series) {
|
|
apply_time_range_to_chart(chart, get_min_timestamp(current_time_range));
|
|
} else {
|
|
update_chart_bounds(chart);
|
|
}
|
|
|
|
chart_instances.push(chart);
|
|
}
|
|
|
|
/* Add event listeners to resize and update charts when tabs are shown. */
|
|
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(tab_el => {
|
|
tab_el.addEventListener('shown.bs.tab', (event) => {
|
|
const scale_type = current_scale_mode === 'logarithmic' ? 'logarithmic' : 'linear';
|
|
const clip_outliers = current_scale_mode === 'linear-clipped';
|
|
|
|
for (const chart of chart_instances) {
|
|
if (chart.canvas.offsetParent !== null) {
|
|
if (chart._needs_scale_update) {
|
|
apply_scale_to_chart(chart, scale_type, clip_outliers, false);
|
|
}
|
|
chart.resize('none');
|
|
}
|
|
}
|
|
update_ui_for_active_tab();
|
|
});
|
|
});
|
|
|
|
update_ui_for_active_tab();
|
|
}
|
|
|
|
/* Initialize when DOM is ready. */
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
/* Attach UI event listeners. */
|
|
document.querySelectorAll('.btn-time-range').forEach(btn => {
|
|
btn.addEventListener('click', (e) => set_time_range(e.target.dataset.range, e.target));
|
|
});
|
|
|
|
document.querySelectorAll('.btn-scale-mode').forEach(btn => {
|
|
btn.addEventListener('click', (e) => set_scale_mode(e.target.dataset.mode, e.target));
|
|
});
|
|
|
|
draw_charts();
|
|
});
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<h1>Benchmarks</h1>
|
|
<div class="d-flex flex-column align-items-end">
|
|
<div class="btn-group mb-2 d-none" id="timeRangeGroup" role="group" aria-label="Time Range">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm shadow-sm btn-time-range" data-range="1m">1 Month</button>
|
|
<button type="button" class="btn btn-secondary btn-sm shadow-sm btn-time-range active" data-range="4m">4 Months</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm shadow-sm btn-time-range" data-range="1y">12 Months</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm shadow-sm btn-time-range" data-range="all">All Time</button>
|
|
</div>
|
|
<div class="btn-group" role="group" aria-label="Scale Type">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm shadow-sm btn-scale-mode" data-mode="linear">Linear</button>
|
|
<button type="button" class="btn btn-secondary btn-sm shadow-sm btn-scale-mode active" id="linear-clipped" data-mode="linear-clipped">Linear (Clipped)</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm shadow-sm btn-scale-mode" data-mode="logarithmic">Logarithmic</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ul class="nav nav-tabs" id="charts-nav" role="tablist">
|
|
</ul>
|
|
<div class="tab-content" id="charts-content">
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
|
</body>
|
|
</html> |