mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 18:05:13 +08:00
🎨 Sync 2026-08-19 20:43:52
This commit is contained in:
1 parent
78fa165967
commit
c6bde98f6f
17 files changed
+4255
No files matched your search
@@ -0,0 +1,506 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
|
||||
/* Shared between the Test page's recent-history block and the full History
|
||||
* page, so the two can never draw the same data differently. */
|
||||
|
||||
/* Labels are translated here, where the extractor can see them; consumers
|
||||
* take them display-ready, the way RANGES and PHASES do. */
|
||||
const METRICS = [
|
||||
[ 'download_mbps', _('Download'), 'Mbps' ],
|
||||
[ 'upload_mbps', _('Upload'), 'Mbps' ],
|
||||
[ 'ping_ms', _('Ping'), 'ms' ],
|
||||
[ 'jitter_ms', _('Jitter'), 'ms' ]
|
||||
];
|
||||
|
||||
/* Series that share a unit share an axis; mixing Mbps and ms on one scale
|
||||
* would flatten the milliseconds into the floor. */
|
||||
const GROUPS = {
|
||||
speed: [ 'download_mbps', 'upload_mbps' ],
|
||||
latency: [ 'ping_ms', 'jitter_ms' ]
|
||||
};
|
||||
|
||||
/* Labels are translated here, where the extractor can see them: switcher()
|
||||
* takes them display-ready. The key is the seconds, never the label. */
|
||||
const RANGES = [
|
||||
[ _('24h'), 86400 ],
|
||||
[ _('7d'), 604800 ],
|
||||
[ _('30d'), 2592000 ],
|
||||
[ _('1y'), 31536000 ],
|
||||
[ _('All'), 0 ]
|
||||
];
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/"/g, '""');
|
||||
}
|
||||
|
||||
function download(name, mime, text) {
|
||||
const blob = new Blob([ text ], { type: mime }),
|
||||
url = window.URL.createObjectURL(blob),
|
||||
link = E('a', { 'style': 'display:none', 'href': url, 'download': name });
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/* Bumped whenever librespeed.css changes, so a routine page load picks the
|
||||
* new stylesheet up instead of pairing new views with a cached old one. */
|
||||
const CSS_VERSION = '18';
|
||||
|
||||
function metricOf(key) {
|
||||
return METRICS.find(m => m[0] == key);
|
||||
}
|
||||
|
||||
/* A metric keeps one color everywhere: the first of its group wears the
|
||||
* theme's primary color, the second the fixed companion shade. */
|
||||
function colorIndexOf(key) {
|
||||
for (const g in GROUPS) {
|
||||
const i = GROUPS[g].indexOf(key);
|
||||
if (i >= 0)
|
||||
return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* One scale shared by the renderer and the hover layer: both must agree on
|
||||
* where a sample sits, or the tooltip would point beside the line. Spans
|
||||
* every requested series, and the day's min/max spread when aggregated. */
|
||||
function chartScale(entries, metrics, resolution) {
|
||||
const W = 600, H = 240, L = 46, R = 10, T = 14, B = 24;
|
||||
|
||||
if (!entries || entries.length < 1)
|
||||
return null;
|
||||
|
||||
const vals = [];
|
||||
|
||||
entries.forEach(e => {
|
||||
metrics.forEach(k => {
|
||||
if (typeof e[k] == 'number')
|
||||
vals.push(e[k]);
|
||||
if (resolution == '1d') {
|
||||
if (typeof e[k + '_min'] == 'number')
|
||||
vals.push(e[k + '_min']);
|
||||
if (typeof e[k + '_max'] == 'number')
|
||||
vals.push(e[k + '_max']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!vals.length)
|
||||
return null;
|
||||
|
||||
let min = Math.min.apply(null, vals),
|
||||
max = Math.max.apply(null, vals);
|
||||
|
||||
/* Vertical padding: 10% of the spread, but never less than 2% of the
|
||||
* value itself -- a connection steady within tenths of a Mbps must not
|
||||
* be zoomed into dramatic noise. A flat zero line still needs a
|
||||
* nonzero span to be drawable. */
|
||||
const pad = Math.max((max - min) * 0.1, Math.abs(max) * 0.02) || 1;
|
||||
min -= pad; max += pad;
|
||||
|
||||
return {
|
||||
W: W, H: H, L: L, R: R, T: T, B: B,
|
||||
min: min, max: max,
|
||||
xs: i => entries.length > 1
|
||||
? L + i * (W - L - R) / (entries.length - 1)
|
||||
: (L + W - R) / 2,
|
||||
ys: v => T + (H - T - B) * (1 - (v - min) / (max - min))
|
||||
};
|
||||
}
|
||||
|
||||
/* Timestamps for humans: daily aggregates carry a plain date, everything else
|
||||
* becomes the locale's date and time; garbage shows as itself. */
|
||||
function chartStampFull(e, resolution) {
|
||||
if (!e || !e.timestamp)
|
||||
return '?';
|
||||
if (resolution == '1d' || e.timestamp.indexOf('T') < 0)
|
||||
return e.timestamp;
|
||||
const d = new Date(e.timestamp);
|
||||
return isNaN(d.getTime()) ? e.timestamp : d.toLocaleString();
|
||||
}
|
||||
|
||||
function chartStampShort(e, resolution) {
|
||||
if (!e || !e.timestamp)
|
||||
return '';
|
||||
if (resolution == '1d' || e.timestamp.indexOf('T') < 0)
|
||||
return e.timestamp;
|
||||
const d = new Date(e.timestamp);
|
||||
return isNaN(d.getTime()) ? e.timestamp : d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function fmtVal(v, unit) {
|
||||
return (typeof v == 'number')
|
||||
? (v.toFixed(unit == 'Mbps' ? 2 : 1) + ' ' + unit) : '–';
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
METRICS: METRICS,
|
||||
GROUPS: GROUPS,
|
||||
RANGES: RANGES,
|
||||
|
||||
/* The app stylesheet, cache-busted by its own version: LuCI's resource
|
||||
* version only changes when luci-base does, which is not when we do. */
|
||||
cssLink() {
|
||||
return E('link', {
|
||||
'rel': 'stylesheet',
|
||||
'href': L.resource('librespeed/librespeed.css') + '?v=' + CSS_VERSION
|
||||
});
|
||||
},
|
||||
|
||||
/* Plain figures of one series: what is normal, how much it wobbles, and
|
||||
* where it stands now. The story sentences are built from these. */
|
||||
seriesStats(entries, metric) {
|
||||
const vals = (entries || []).map(e => e[metric]).filter(v => typeof v == 'number');
|
||||
|
||||
if (!vals.length)
|
||||
return null;
|
||||
|
||||
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
|
||||
|
||||
return {
|
||||
count: vals.length,
|
||||
avg: avg,
|
||||
min: Math.min.apply(null, vals),
|
||||
max: Math.max.apply(null, vals),
|
||||
current: vals[vals.length - 1]
|
||||
};
|
||||
},
|
||||
|
||||
/* A row of toggle buttons; the active one is highlighted. */
|
||||
switcher(view, items, current, onpick) {
|
||||
return E('div', {}, items.map(it =>
|
||||
E('button', {
|
||||
'class': it[0] == current ? 'cbi-button cbi-button-action' : 'cbi-button',
|
||||
'style': 'margin-right:.3em',
|
||||
'click': ui.createHandlerFn(view, function() {
|
||||
return onpick.call(view, it[0]);
|
||||
})
|
||||
/* Callers hand in display-ready labels: a runtime _() here
|
||||
* would both miss the extractor and translate twice. */
|
||||
}, [ it[1] != null ? it[1] : it[0] ])));
|
||||
},
|
||||
|
||||
/* The one chart component: hand-built SVG, the way the realtime status
|
||||
* graphs do it, colored by the stylesheet so it wears the active theme.
|
||||
*
|
||||
* opts.series keys drawn as lines (one unit family per chart)
|
||||
* opts.resolution 'raw' or '1d'; aggregates draw their min-max band
|
||||
* opts.hover guide line + a full-measurement tooltip
|
||||
* opts.legend { all, onToggle }: checkbox legend that switches
|
||||
* series on and off -- a control, not a caption
|
||||
*
|
||||
* The x axis is always time: a time series must never be reordered, or
|
||||
* the story it tells dissolves. Sorting belongs to the table.
|
||||
* Returns false when there is nothing to draw. */
|
||||
renderChart(container, entries, opts) {
|
||||
container.innerHTML = '';
|
||||
|
||||
const series = (opts.series || []).filter(k => metricOf(k));
|
||||
const sc = chartScale(entries, series, opts.resolution);
|
||||
|
||||
if (!sc) {
|
||||
if (opts.legend)
|
||||
this.renderLegend(container, entries, opts);
|
||||
return false;
|
||||
}
|
||||
|
||||
const W = sc.W, H = sc.H, L = sc.L, R = sc.R, T = sc.T, B = sc.B;
|
||||
|
||||
const grid = [ 0.25, 0.5, 0.75 ].map(f => {
|
||||
const y = T + (H - T - B) * f,
|
||||
v = sc.max - (sc.max - sc.min) * f;
|
||||
return '<line x1="%d" y1="%f" x2="%d" y2="%f" stroke="currentColor" stroke-opacity="0.15"/>'
|
||||
.format(L, y, W - R, y) +
|
||||
'<text x="%d" y="%f" font-size="10" fill="currentColor" fill-opacity="0.6" text-anchor="end">%s</text>'
|
||||
.format(L - 4, y + 3, v.toFixed(1));
|
||||
}).join('');
|
||||
|
||||
let body = '';
|
||||
|
||||
series.forEach(k => {
|
||||
const ci = colorIndexOf(k);
|
||||
const valid = entries
|
||||
.map((e, i) => (typeof e[k] == 'number') ? [ sc.xs(i), sc.ys(e[k]), e[k] ] : null)
|
||||
.filter(p => p != null);
|
||||
|
||||
if (!valid.length)
|
||||
return;
|
||||
|
||||
const pts = valid.map(p => '%f,%f'.format(p[0], p[1])).join(' ');
|
||||
|
||||
/* Aggregated entries carry the day's spread beside the mean,
|
||||
* drawn as a band under the mean line. */
|
||||
if (opts.resolution == '1d') {
|
||||
const lo = entries.map((e, i) => (typeof e[k + '_min'] == 'number')
|
||||
? '%f,%f'.format(sc.xs(i), sc.ys(e[k + '_min'])) : null).filter(p => p != null);
|
||||
const hi = entries.map((e, i) => (typeof e[k + '_max'] == 'number')
|
||||
? '%f,%f'.format(sc.xs(i), sc.ys(e[k + '_max'])) : null).filter(p => p != null);
|
||||
|
||||
if (lo.length)
|
||||
body += '<polygon class="librespeed-band librespeed-fill-%d" points="%s %s"/>'
|
||||
.format(ci, lo.join(' '), hi.reverse().join(' '));
|
||||
}
|
||||
|
||||
/* The soft fill reads well under one line; under two it is mud. */
|
||||
if (series.length == 1 && valid.length > 1)
|
||||
body += '<polygon class="librespeed-area librespeed-fill-%d" points="%s %f,%f %f,%f"/>'
|
||||
.format(ci, pts, valid[valid.length - 1][0], H - B, valid[0][0], H - B);
|
||||
|
||||
/* The dashed average is the series' "what is normal" reference. */
|
||||
const avg = valid.reduce((a, p) => a + p[2], 0) / valid.length;
|
||||
body += '<line class="librespeed-avgline librespeed-stroke-%d" x1="%d" y1="%f" x2="%d" y2="%f"/>'
|
||||
.format(ci, L, sc.ys(avg), W - R, sc.ys(avg));
|
||||
body += '<text x="%d" y="%f" font-size="9" fill="currentColor" fill-opacity=".55" text-anchor="end">avg %s</text>'
|
||||
.format(W - R, sc.ys(avg) - 4, avg.toFixed(avg >= 100 ? 0 : 1));
|
||||
|
||||
body += (valid.length > 1)
|
||||
? '<polyline class="librespeed-line librespeed-stroke-%d" points="%s"/>'.format(ci, pts)
|
||||
: '<circle class="librespeed-dot librespeed-fill-%d" cx="%f" cy="%f" r="3"/>'
|
||||
.format(ci, valid[0][0], valid[0][1]);
|
||||
|
||||
/* Dots make a sparse series readable; with hundreds of points
|
||||
* they would just thicken the line, so they bow out. */
|
||||
if (valid.length > 1 && valid.length <= 60)
|
||||
body += valid.map(p =>
|
||||
'<circle class="librespeed-dot librespeed-fill-%d" cx="%f" cy="%f" r="2"/>'
|
||||
.format(ci, p[0], p[1])).join('');
|
||||
});
|
||||
|
||||
/* A date-only timestamp names a local calendar day; new Date() would
|
||||
* read it as UTC midnight and shift it in negative offsets. */
|
||||
const first = entries[0],
|
||||
last = entries[entries.length - 1],
|
||||
mid = entries[Math.floor((entries.length - 1) / 2)];
|
||||
const s1 = chartStampShort(first, opts.resolution),
|
||||
s2 = chartStampShort(mid, opts.resolution),
|
||||
s3 = chartStampShort(last, opts.resolution);
|
||||
const midLabel = (entries.length > 2 && s2 != s1 && s2 != s3)
|
||||
? '<text x="%f" y="%d" font-size="10" fill="currentColor" fill-opacity="0.6" text-anchor="middle">%h</text>'
|
||||
.format((L + W - R) / 2, H - 6, s2)
|
||||
: '';
|
||||
|
||||
const unit = metricOf(series[0]) ? metricOf(series[0])[2] : '';
|
||||
|
||||
const chart = E('div', {});
|
||||
chart.innerHTML =
|
||||
'<svg viewBox="0 0 %d %d" style="width:100%%; max-height:280px" xmlns="http://www.w3.org/2000/svg">'
|
||||
.format(W, H) +
|
||||
grid + body +
|
||||
'<text x="%d" y="%d" font-size="10" fill="currentColor" fill-opacity="0.6">%h</text>'
|
||||
.format(L, H - 6, s1) +
|
||||
midLabel +
|
||||
'<text x="%d" y="%d" font-size="10" fill="currentColor" fill-opacity="0.6" text-anchor="end">%h (%s)</text>'
|
||||
.format(W - R, H - 6, s3, unit) +
|
||||
'</svg>';
|
||||
container.appendChild(chart);
|
||||
|
||||
if (opts.hover !== false)
|
||||
this.attachChartHover(chart, entries, series, opts.resolution, sc);
|
||||
|
||||
if (opts.legend)
|
||||
this.renderLegend(container, entries, opts);
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/* The legend doubles as the series switch: each entry is a checkbox with
|
||||
* the series' color and its average over the visible range. */
|
||||
renderLegend(container, entries, opts) {
|
||||
const leg = E('div', { 'class': 'librespeed-legend' });
|
||||
|
||||
opts.legend.all.forEach(k => {
|
||||
const m = metricOf(k);
|
||||
const st = this.seriesStats(entries, k);
|
||||
const cb = E('input', { 'type': 'checkbox' });
|
||||
|
||||
cb.checked = opts.series.indexOf(k) >= 0;
|
||||
cb.addEventListener('change', () => opts.legend.onToggle(k, cb.checked));
|
||||
|
||||
leg.appendChild(E('label', { 'class': 'librespeed-legend-item' }, [
|
||||
cb,
|
||||
E('span', { 'class': 'librespeed-swatch librespeed-bg-%d'.format(colorIndexOf(k)) }),
|
||||
E('span', {}, [ m[1] ]),
|
||||
st ? E('span', { 'class': 'librespeed-muted' },
|
||||
[ ' ' + fmtVal(st.avg, m[2]) + ' ' + _('avg') ]) : ''
|
||||
]));
|
||||
});
|
||||
|
||||
container.appendChild(leg);
|
||||
},
|
||||
|
||||
/* Hover layer: a guide line, a highlighted sample per series, and an HTML
|
||||
* tooltip carrying the whole measurement -- every metric, the server and
|
||||
* how the test travelled -- because whoever inspects one point wants to
|
||||
* know about that test, not one number of it. Daily aggregates show the
|
||||
* day's spread of the drawn series instead. */
|
||||
attachChartHover(container, entries, series, resolution, sc) {
|
||||
const svg = container.querySelector('svg');
|
||||
|
||||
if (!svg || !sc)
|
||||
return;
|
||||
|
||||
container.classList.add('librespeed-chart-holder');
|
||||
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const guide = document.createElementNS(NS, 'line');
|
||||
guide.setAttribute('class', 'librespeed-guide');
|
||||
guide.setAttribute('y1', sc.T);
|
||||
guide.setAttribute('y2', sc.H - sc.B);
|
||||
guide.style.display = 'none';
|
||||
|
||||
const dots = series.map(k => {
|
||||
const d = document.createElementNS(NS, 'circle');
|
||||
d.setAttribute('class', 'librespeed-dot librespeed-fill-%d'.format(colorIndexOf(k)));
|
||||
d.setAttribute('r', '3.5');
|
||||
d.style.display = 'none';
|
||||
return d;
|
||||
});
|
||||
|
||||
const hit = document.createElementNS(NS, 'rect');
|
||||
hit.setAttribute('x', sc.L);
|
||||
hit.setAttribute('y', sc.T);
|
||||
hit.setAttribute('width', sc.W - sc.L - sc.R);
|
||||
hit.setAttribute('height', sc.H - sc.T - sc.B);
|
||||
hit.setAttribute('fill', 'transparent');
|
||||
|
||||
svg.appendChild(guide);
|
||||
dots.forEach(d => svg.appendChild(d));
|
||||
svg.appendChild(hit);
|
||||
|
||||
const tip = E('div', { 'class': 'librespeed-tooltip' });
|
||||
container.appendChild(tip);
|
||||
|
||||
const row = (lab, txt) => E('div', { 'class': 'librespeed-tooltip-row' }, [
|
||||
E('span', { 'class': 'librespeed-muted' }, [ lab ]),
|
||||
E('span', {}, [ txt ])
|
||||
]);
|
||||
|
||||
const hide = () => {
|
||||
guide.style.display = 'none';
|
||||
dots.forEach(d => (d.style.display = 'none'));
|
||||
tip.style.display = 'none';
|
||||
};
|
||||
|
||||
hit.addEventListener('mousemove', ev => {
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const x = (ev.clientX - rect.left) * sc.W / rect.width;
|
||||
const step = entries.length > 1
|
||||
? (sc.W - sc.L - sc.R) / (entries.length - 1) : 1;
|
||||
const i = Math.max(0, Math.min(entries.length - 1,
|
||||
Math.round((x - sc.L) / step)));
|
||||
const e = entries[i];
|
||||
|
||||
const px = sc.xs(i);
|
||||
let anchorY = null;
|
||||
|
||||
guide.setAttribute('x1', px);
|
||||
guide.setAttribute('x2', px);
|
||||
guide.style.display = '';
|
||||
|
||||
series.forEach((k, si) => {
|
||||
if (typeof e[k] == 'number') {
|
||||
const py = sc.ys(e[k]);
|
||||
dots[si].setAttribute('cx', px);
|
||||
dots[si].setAttribute('cy', py);
|
||||
dots[si].style.display = '';
|
||||
anchorY = (anchorY == null) ? py : Math.min(anchorY, py);
|
||||
}
|
||||
else
|
||||
dots[si].style.display = 'none';
|
||||
});
|
||||
|
||||
if (anchorY == null)
|
||||
return hide();
|
||||
|
||||
tip.innerHTML = '';
|
||||
tip.appendChild(E('div', { 'style': 'font-weight:600; margin-bottom:.2em' },
|
||||
[ chartStampFull(e, resolution) ]));
|
||||
|
||||
if (resolution == '1d') {
|
||||
series.forEach(k => {
|
||||
const m = metricOf(k);
|
||||
if (typeof e[k] != 'number')
|
||||
return;
|
||||
let txt = fmtVal(e[k], m[2]);
|
||||
if (typeof e[k + '_min'] == 'number')
|
||||
txt += ' (%s – %s)'.format(
|
||||
e[k + '_min'].toFixed(m[2] == 'Mbps' ? 0 : 1),
|
||||
e[k + '_max'].toFixed(m[2] == 'Mbps' ? 0 : 1));
|
||||
tip.appendChild(row(m[1], txt));
|
||||
});
|
||||
}
|
||||
else {
|
||||
METRICS.forEach(m => {
|
||||
if (typeof e[m[0]] == 'number')
|
||||
tip.appendChild(row(m[1], fmtVal(e[m[0]], m[2])));
|
||||
});
|
||||
|
||||
if (e.server && e.server.name)
|
||||
tip.appendChild(E('div', { 'style': 'margin-top:.2em' },
|
||||
[ e.server.name ]));
|
||||
|
||||
const meta = [];
|
||||
if (e.interface)
|
||||
meta.push(e.interface);
|
||||
if (e.family)
|
||||
meta.push(e.family == 'ipv6' ? 'IPv6' : 'IPv4');
|
||||
if (e.proto)
|
||||
meta.push(e.proto.toUpperCase());
|
||||
if (meta.length)
|
||||
tip.appendChild(E('div', { 'class': 'librespeed-muted' },
|
||||
[ meta.join(' · ') ]));
|
||||
}
|
||||
|
||||
tip.style.display = 'block';
|
||||
|
||||
const cr = container.getBoundingClientRect();
|
||||
const pxs = rect.left - cr.left + px * rect.width / sc.W;
|
||||
const pys = rect.top - cr.top + anchorY * rect.height / sc.H;
|
||||
let left = pxs - tip.offsetWidth / 2;
|
||||
left = Math.max(0, Math.min(cr.width - tip.offsetWidth, left));
|
||||
tip.style.left = left + 'px';
|
||||
tip.style.top = Math.max(0, pys - tip.offsetHeight - 14) + 'px';
|
||||
});
|
||||
hit.addEventListener('mouseleave', hide);
|
||||
},
|
||||
|
||||
/* Daily aggregates carry each metric's spread beside the mean; the CSV
|
||||
* must not lose it, so aggregated exports gain the _min/_max columns. */
|
||||
exportCSV(entries, resolution) {
|
||||
const agg = (resolution == '1d');
|
||||
const keys = METRICS.map(m => m[0]);
|
||||
const n = v => (typeof v == 'number') ? v : '';
|
||||
|
||||
let head = 'timestamp,resolution,interface,family,proto,server,' + keys.join(',');
|
||||
|
||||
if (agg)
|
||||
head += ',' + keys.map(k => k + '_min,' + k + '_max').join(',');
|
||||
|
||||
const lines = [ head ];
|
||||
|
||||
(entries || []).forEach(e => {
|
||||
/* Every text field is quoted, so esc()'s quote-doubling is correct
|
||||
* everywhere and embedded commas cannot shift columns. */
|
||||
let row = '"%s","%s","%s","%s","%s","%s",'.format(
|
||||
esc(e.timestamp), agg ? '1d' : 'raw', esc(e.interface),
|
||||
esc(e.family), esc(e.proto), esc(e.server && e.server.name)) +
|
||||
keys.map(k => n(e[k])).join(',');
|
||||
|
||||
if (agg)
|
||||
row += ',' + keys.map(k => n(e[k + '_min']) + ',' + n(e[k + '_max'])).join(',');
|
||||
|
||||
lines.push(row);
|
||||
});
|
||||
|
||||
download('librespeed-history.csv', 'text/csv', lines.join('\n') + '\n');
|
||||
},
|
||||
|
||||
exportJSON(entries, resolution) {
|
||||
download('librespeed-history.json', 'application/json',
|
||||
JSON.stringify({ resolution: resolution || 'raw', entries: entries || [] }, null, '\t') + '\n');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
/* The one piece of CSS this application owns: layout and type scale. Colors,
|
||||
* borders, buttons and spacing inside sections stay with the theme. */
|
||||
|
||||
.librespeed-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(18rem, 1fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.librespeed-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.librespeed-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.librespeed-value {
|
||||
font-size: 2.2em;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.librespeed-value-sm {
|
||||
font-size: 1.5em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Muted through opacity rather than a hardcoded grey, so it dims whatever
|
||||
* foreground color the active theme uses. */
|
||||
.librespeed-muted {
|
||||
opacity: .65;
|
||||
}
|
||||
|
||||
.librespeed-caps {
|
||||
font-size: .85em;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.librespeed-center {
|
||||
text-align: center;
|
||||
padding: 1.5em 1em;
|
||||
}
|
||||
|
||||
.librespeed-progress {
|
||||
width: 60%;
|
||||
max-width: 20em;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
accent-color: var(--primary-color-medium, #5bc0de);
|
||||
}
|
||||
|
||||
/* The chart takes its color from the theme where one is exposed, and falls
|
||||
* back to a neutral accent where none is. */
|
||||
.librespeed-chart-line {
|
||||
stroke: var(--primary-color-medium, #5bc0de);
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.librespeed-chart-fill {
|
||||
fill: var(--primary-color-medium, #5bc0de);
|
||||
fill-opacity: .1;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.librespeed-chart-band {
|
||||
fill: var(--primary-color-medium, #5bc0de);
|
||||
fill-opacity: .18;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.librespeed-chart-dot {
|
||||
fill: var(--primary-color-medium, #5bc0de);
|
||||
}
|
||||
|
||||
.librespeed-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: .5em;
|
||||
margin: .4em 0;
|
||||
}
|
||||
|
||||
.librespeed-push {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.librespeed-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: .5em;
|
||||
margin-top: .75em;
|
||||
}
|
||||
|
||||
.librespeed-steps {
|
||||
display: flex;
|
||||
gap: 1.75em;
|
||||
justify-content: center;
|
||||
margin: .5em 0 0;
|
||||
}
|
||||
|
||||
.librespeed-step { opacity: .35; }
|
||||
.librespeed-step-active { opacity: 1; font-weight: 600; }
|
||||
.librespeed-step-done { opacity: .65; }
|
||||
|
||||
.librespeed-spark {
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
margin: .75em 0 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.librespeed-gauge {
|
||||
width: 250px;
|
||||
max-width: 100%;
|
||||
margin: .5em auto 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.librespeed-gauge-bg {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-opacity: .12;
|
||||
stroke-width: 12;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.librespeed-gauge-fill {
|
||||
fill: none;
|
||||
stroke: var(--primary-color-medium, #5bc0de);
|
||||
stroke-width: 12;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.librespeed-gauge text { fill: currentColor; }
|
||||
|
||||
.librespeed-gauge-tick {
|
||||
stroke: currentColor;
|
||||
stroke-opacity: .25;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.librespeed-gauge-needle {
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
opacity: .85;
|
||||
}
|
||||
|
||||
.librespeed-gauge-hub { fill: currentColor; }
|
||||
|
||||
.librespeed-chart-holder { position: relative; }
|
||||
|
||||
.librespeed-guide {
|
||||
stroke: currentColor;
|
||||
stroke-opacity: .3;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.librespeed-avg {
|
||||
stroke: currentColor;
|
||||
stroke-opacity: .45;
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.librespeed-tooltip {
|
||||
position: absolute;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
background: var(--background-color-high, #fff);
|
||||
color: var(--text-color-high, #333);
|
||||
border: 1px solid rgba(128, 128, 128, .4);
|
||||
border-radius: 3px;
|
||||
padding: .3em .6em;
|
||||
font-size: .85em;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .2);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.librespeed-tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.25em;
|
||||
}
|
||||
|
||||
.librespeed-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1.5em;
|
||||
margin-top: .5em;
|
||||
}
|
||||
|
||||
.librespeed-line { fill: none; stroke-width: 1.5; }
|
||||
.librespeed-area { stroke: none; fill-opacity: .1; }
|
||||
.librespeed-band { stroke: none; fill-opacity: .16; }
|
||||
.librespeed-dot { stroke: none; }
|
||||
|
||||
.librespeed-avgline {
|
||||
fill: none;
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 3;
|
||||
stroke-opacity: .55;
|
||||
}
|
||||
|
||||
/* One theme color for every series: the second one differs by pattern, not
|
||||
* by a hardcoded hue -- a dashed line and a striped swatch survive any theme
|
||||
* and read for color-blind users too. */
|
||||
.librespeed-stroke-0 { stroke: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-stroke-1 { stroke: var(--primary-color-medium, #5bc0de); }
|
||||
polyline.librespeed-stroke-1 { stroke-dasharray: 6 3; }
|
||||
.librespeed-avgline.librespeed-stroke-1 { stroke-dasharray: 2 4; }
|
||||
.librespeed-fill-0 { fill: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-fill-1 { fill: var(--primary-color-medium, #5bc0de); }
|
||||
circle.librespeed-fill-1 { fill-opacity: .55; }
|
||||
.librespeed-bg-0 { background: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-bg-1 {
|
||||
background: repeating-linear-gradient(90deg,
|
||||
var(--primary-color-medium, #5bc0de) 0 4px, transparent 4px 7px);
|
||||
}
|
||||
|
||||
.librespeed-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5em;
|
||||
margin: .35em 0 0;
|
||||
}
|
||||
|
||||
.librespeed-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.librespeed-swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.librespeed-result-gauges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1.5em;
|
||||
margin: .5em 0 0;
|
||||
}
|
||||
|
||||
.librespeed-result-gauges .librespeed-gauge {
|
||||
width: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.librespeed-fg-0 { color: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-fg-1 { color: var(--primary-color-medium, #5bc0de); }
|
||||
|
||||
.librespeed-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .75em;
|
||||
margin: .35em 0 1em;
|
||||
}
|
||||
|
||||
.librespeed-metric-card {
|
||||
border: 1px solid rgba(128, 128, 128, .25);
|
||||
border-top-width: 3px;
|
||||
border-radius: 4px;
|
||||
padding: .45em .9em;
|
||||
min-width: 11em;
|
||||
cursor: pointer;
|
||||
opacity: .5;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.librespeed-metric-card-on { opacity: 1; }
|
||||
.librespeed-metric-card:hover { border-color: rgba(128, 128, 128, .45); }
|
||||
|
||||
.librespeed-accent-0 { border-top-color: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-accent-1 { border-top-color: var(--primary-color-medium, #5bc0de); border-top-style: dashed; }
|
||||
|
||||
.librespeed-card-value {
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.librespeed-hint {
|
||||
border-bottom: 1px dotted currentColor;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.librespeed-meta {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: .15em 1.25em;
|
||||
width: fit-content;
|
||||
margin: 1em auto 0;
|
||||
text-align: left;
|
||||
font-size: .95em;
|
||||
}
|
||||
Reference in new issue
Block a user