mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 16:44:23 +08:00
300 lines
10 KiB
JavaScript
300 lines
10 KiB
JavaScript
'use strict';
|
|
'require baseclass';
|
|
|
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
|
|
function svg(tag, attrs, children) {
|
|
const node = document.createElementNS(SVG_NS, tag);
|
|
|
|
for (const key in (attrs || {}))
|
|
node.setAttribute(key, attrs[key]);
|
|
|
|
(children || []).forEach(child => {
|
|
if (child != null)
|
|
node.appendChild(typeof(child) == 'string' ? document.createTextNode(child) : child);
|
|
});
|
|
|
|
return node;
|
|
}
|
|
|
|
function scale(value, min, max) {
|
|
return Math.max(0, Math.min(100, (value - min) / (max - min) * 100));
|
|
}
|
|
|
|
// Data rates take decimal prefixes, as link speeds and ISP plans do.
|
|
const RATE_UNITS = [ _('bit/s'), _('Kbit/s'), _('Mbit/s'), _('Gbit/s') ];
|
|
|
|
function rateUnit(value) {
|
|
let exp = 0;
|
|
|
|
while (value >= 1000 && exp < RATE_UNITS.length - 1) {
|
|
value /= 1000;
|
|
exp++;
|
|
}
|
|
|
|
return exp;
|
|
}
|
|
|
|
return baseclass.extend({
|
|
formatRate(value, targetExp) {
|
|
if (value == null)
|
|
return '-';
|
|
|
|
const exp = (targetExp != null) ? targetExp : rateUnit(value);
|
|
const unit = Math.pow(1000, exp);
|
|
const num = value / unit;
|
|
|
|
if (num === 0)
|
|
return '0 %s'.format(RATE_UNITS[exp]);
|
|
|
|
if (num > 0 && num < 0.01 && exp > 0)
|
|
return '< 0.01 %s'.format(RATE_UNITS[exp]);
|
|
|
|
if (num < 1 && exp > 0)
|
|
return '%.2f %s'.format(num, RATE_UNITS[exp]);
|
|
|
|
return '%.1f %s'.format(num, RATE_UNITS[exp]);
|
|
},
|
|
|
|
rateScale(peak) {
|
|
const exp = rateUnit(peak);
|
|
const unit = Math.pow(1000, exp);
|
|
const raw = Math.max(peak / unit, 1e-9) / 4;
|
|
const magnitude = Math.pow(10, Math.floor(Math.log10(raw)));
|
|
const step = [ 1, 2, 2.5, 5, 10 ].map(n => n * magnitude).find(candidate => candidate >= raw);
|
|
|
|
return {
|
|
max: step * 4 * unit,
|
|
unit: RATE_UNITS[exp],
|
|
ticks: [ 0, 1, 2, 3, 4 ].map(n => ({ value: step * n * unit, label: String(+(step * n).toFixed(2)) })),
|
|
format: value => this.formatRate(value, exp)
|
|
};
|
|
},
|
|
|
|
icon(name, className) {
|
|
return E('span', {
|
|
'class': 'dashboard-icon ' + (className || ''),
|
|
'style': '--dashboard-icon: url(%s)'.format(L.resource('view/dashboard/icons/' + name + '.svg'))
|
|
});
|
|
},
|
|
|
|
badge(text, kind) {
|
|
return E('span', { 'class': 'label ' + (kind || '') }, [ text ]);
|
|
},
|
|
|
|
// A card is a plain .cbi-section, so the theme draws it.
|
|
card(opts) {
|
|
return E('div', { 'class': 'cbi-section ' + (opts.className || '') }, [
|
|
opts.title ? E('h3', {}, [ opts.title ]) : '',
|
|
opts.desc ? E('div', { 'class': 'cbi-section-descr' }, [ opts.desc ]) : ''
|
|
].concat(opts.body));
|
|
},
|
|
|
|
kpi(opts) {
|
|
const parts = (opts.sub || []).filter(part => part != null && part !== '');
|
|
const sub = opts.stacked
|
|
? parts.map(part => E('small', { 'class': 'dashboard-kpi-sub dashboard-kpi-line', 'title': part }, [ part ]))
|
|
: [ E('small', { 'class': 'dashboard-kpi-sub' }, parts.flatMap((part, i) => i ? [ ' · ', part ] : [ part ])) ];
|
|
|
|
return E('div', { 'class': 'cbi-section dashboard-kpi ' + (opts.className || '') }, [
|
|
this.icon(opts.icon, 'dashboard-kpi-icon'),
|
|
E('div', { 'class': 'dashboard-kpi-text' }, [
|
|
E('small', {}, [ opts.title ]),
|
|
E('strong', { 'class': 'dashboard-kpi-value' }, opts.value)
|
|
].concat(sub))
|
|
]);
|
|
},
|
|
|
|
empty(text) {
|
|
return E('em', {}, [ text ]);
|
|
},
|
|
|
|
updateTable(table, opts) {
|
|
const cell = (tag, content, title) => E(tag, { 'class': tag, 'data-title': title || null }, [ content ?? '' ]);
|
|
const head = opts.head || [];
|
|
const rows = opts.rows || [];
|
|
const tbody = (table.tBodies && table.tBodies[0]) ? table.tBodies[0] : table;
|
|
const currentRows = tbody.querySelectorAll('tr:not(.table-titles):not(.table-foot):not(.placeholder)');
|
|
const placeholder = tbody.querySelector('tr.placeholder');
|
|
const foot = tbody.querySelector('tr.table-foot');
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const tr = E('tr', { 'class': 'tr ' + (i % 2 ? 'cbi-rowstyle-2' : 'cbi-rowstyle-1') },
|
|
rows[i].map((content, n) => cell('td', content, head[n])));
|
|
if (currentRows[i])
|
|
tbody.replaceChild(tr, currentRows[i]);
|
|
else if (foot)
|
|
tbody.insertBefore(tr, foot);
|
|
else
|
|
tbody.appendChild(tr);
|
|
}
|
|
|
|
for (let i = rows.length; i < currentRows.length; i++)
|
|
tbody.removeChild(currentRows[i]);
|
|
|
|
if (!rows.length && opts.emptyText) {
|
|
if (!placeholder) {
|
|
const tr = E('tr', { 'class': 'tr placeholder' }, [
|
|
E('td', { 'class': 'td', 'colspan': head.length }, [ E('em', {}, [ opts.emptyText ]) ])
|
|
]);
|
|
if (foot)
|
|
tbody.insertBefore(tr, foot);
|
|
else
|
|
tbody.appendChild(tr);
|
|
}
|
|
}
|
|
else if (placeholder) {
|
|
tbody.removeChild(placeholder);
|
|
}
|
|
|
|
if (opts.foot) {
|
|
if (!foot) {
|
|
const tr = E('tr', { 'class': 'tr table-foot' }, opts.foot.map(content => cell('td', content)));
|
|
tr.lastChild.setAttribute('colspan', head.length - opts.foot.length + 1);
|
|
tbody.appendChild(tr);
|
|
}
|
|
else {
|
|
while (foot.firstChild)
|
|
foot.removeChild(foot.firstChild);
|
|
opts.foot.map(content => cell('td', content)).forEach(td => foot.appendChild(td));
|
|
foot.lastChild.setAttribute('colspan', head.length - opts.foot.length + 1);
|
|
}
|
|
}
|
|
|
|
return table;
|
|
},
|
|
|
|
// Same markup as ui.Table: flat .table > .tr, header row .table-titles,
|
|
// data-title on the cells so themes can label them on phones.
|
|
table(opts) {
|
|
const existing = opts.node || (opts.id ? document.getElementById(opts.id) : null);
|
|
if (existing && existing.parentNode)
|
|
return this.updateTable(existing, opts);
|
|
|
|
const cell = (tag, content, title) => E(tag, { 'class': tag, 'data-title': title || null }, [ content ?? '' ]);
|
|
const head = opts.head || [];
|
|
const table = E('table', { 'id': opts.id || null, 'class': 'table ' + (opts.className || '') }, [
|
|
E('tr', { 'class': 'tr table-titles' }, head.map(title => cell('th', title)))
|
|
]);
|
|
|
|
return this.updateTable(table, opts);
|
|
},
|
|
|
|
donut(opts) {
|
|
const radius = 40;
|
|
const circumference = 2 * Math.PI * radius;
|
|
const total = opts.series.reduce((sum, item) => sum + item.value, 0);
|
|
const gap = (opts.series.filter(item => item.value > 0).length > 1) ? 3 : 0;
|
|
const circles = [ svg('circle', { 'class': 'dashboard-donut-track', 'cx': 50, 'cy': 50, 'r': radius }) ];
|
|
let offset = 0;
|
|
|
|
opts.series.forEach((item, i) => {
|
|
const length = total ? item.value / total * circumference : 0;
|
|
|
|
if (length > 0)
|
|
circles.push(svg('circle', {
|
|
'class': 'dashboard-donut-seg dashboard-series-' + (i + 1),
|
|
'cx': 50, 'cy': 50, 'r': radius,
|
|
'stroke-dasharray': '%.2f %.2f'.format(Math.max(0, length - gap), circumference - Math.max(0, length - gap)),
|
|
'stroke-dashoffset': '%.2f'.format(-offset),
|
|
'transform': 'rotate(-90 50 50)'
|
|
}));
|
|
|
|
offset += length;
|
|
});
|
|
|
|
circles.push(svg('text', { 'class': 'dashboard-donut-value', 'x': 50, 'y': 53 }, [ String(total) ]));
|
|
circles.push(svg('text', { 'class': 'dashboard-donut-label', 'x': 50, 'y': 64 }, [ opts.centerLabel || '' ]));
|
|
|
|
return E('div', { 'class': 'dashboard-donut' }, [
|
|
svg('svg', { 'viewBox': '0 0 100 100', 'role': 'img', 'aria-label': opts.ariaLabel || '' }, circles),
|
|
E('ul', { 'class': 'dashboard-legend' }, opts.series.map((item, i) => E('li', {}, [
|
|
E('i', { 'class': 'dashboard-series-' + (i + 1) }),
|
|
E('span', { 'class': 'dashboard-legend-name' }, item.label),
|
|
E('b', {}, [ String(item.value) ]),
|
|
E('small', {}, [ total ? '%d%%'.format(Math.round(item.value / total * 100)) : '0%' ])
|
|
])))
|
|
]);
|
|
},
|
|
|
|
barChart(opts) {
|
|
const min = opts.min || 0;
|
|
const max = (opts.max != null) ? opts.max : 1;
|
|
const many = opts.items.length > 10;
|
|
const yAxis = E('div', { 'class': 'dashboard-bars-axis' }, opts.ticks.map(tick =>
|
|
E('span', { 'style': 'top:%.2f%%'.format(100 - scale(tick.value, min, max)) }, [ tick.label ])));
|
|
const grid = E('div', { 'class': 'dashboard-bars-grid' }, opts.ticks.map(tick =>
|
|
E('i', { 'style': 'top:%.2f%%'.format(100 - scale(tick.value, min, max)) })));
|
|
const plot = E('div', { 'class': 'dashboard-bars-plot' }, [ grid ]);
|
|
|
|
opts.items.forEach(item => {
|
|
plot.appendChild(E('div', { 'class': 'dashboard-bars-col', 'title': item.title || item.label }, [
|
|
E('div', { 'class': 'dashboard-bars-stack' }, item.values.map(v =>
|
|
E('i', { 'class': v.className || '', 'style': 'height:%.2f%%'.format((v.value != null) ? scale(v.value, min, max) : 0) }))),
|
|
E('div', { 'class': 'dashboard-bars-label' }, [ item.label ])
|
|
]));
|
|
});
|
|
|
|
return E('div', { 'class': 'dashboard-bars ' + (many ? 'dashboard-bars-many' : '') }, [
|
|
yAxis,
|
|
E('div', { 'class': 'dashboard-bars-scroll', 'data-chart': opts.id || '' }, [ plot ])
|
|
]);
|
|
},
|
|
|
|
// A time series of points `{ t, v }`, placed by their time: the x axis
|
|
// spans `span` seconds up to the last point. A null `v` leaves a gap, and
|
|
// a point with gaps on both sides is drawn as a dot.
|
|
lines(opts) {
|
|
const max = opts.max || 1;
|
|
const shapes = [];
|
|
const end = opts.series.reduce((t, series) => Math.max(t, ...series.values.map(p => p.t)), 0);
|
|
|
|
opts.series.forEach((series, n) => {
|
|
let run = [];
|
|
|
|
series.values.concat([ { v: null } ]).forEach(p => {
|
|
if (p.v != null) {
|
|
run.push([ 100 - (end - p.t) / (opts.span * 10), 100 - scale(p.v, 0, max) ]);
|
|
return;
|
|
}
|
|
|
|
if (run.length) {
|
|
if (run.length == 1)
|
|
run.push(run[0]);
|
|
|
|
const points = run.map(p => '%.2f,%.2f'.format(p[0], p[1])).join(' ');
|
|
|
|
if (series.area)
|
|
shapes.push(svg('polygon', {
|
|
'class': 'dashboard-series-' + (n + 1),
|
|
'points': '%.2f,100 %s %.2f,100'.format(run[0][0], points, run[run.length - 1][0])
|
|
}));
|
|
|
|
shapes.push(svg('polyline', { 'class': 'dashboard-series-' + (n + 1), 'points': points }));
|
|
}
|
|
|
|
run = [];
|
|
});
|
|
});
|
|
|
|
return E('div', { 'class': 'dashboard-bars' }, [
|
|
E('div', { 'class': 'dashboard-bars-axis' }, opts.ticks.map(tick =>
|
|
E('span', { 'style': 'top:%.2f%%'.format(100 - scale(tick.value, 0, max)) }, [ tick.label ]))),
|
|
E('div', { 'class': 'dashboard-lines-plot' }, [
|
|
E('div', { 'class': 'dashboard-bars-grid' }, opts.ticks.map(tick =>
|
|
E('i', { 'style': 'top:%.2f%%'.format(100 - scale(tick.value, 0, max)) }))),
|
|
svg('svg', { 'viewBox': '0 0 100 100', 'preserveAspectRatio': 'none', 'role': 'img', 'aria-label': opts.ariaLabel || '' }, shapes)
|
|
]),
|
|
E('div', { 'class': 'dashboard-lines-time' }, [
|
|
E('span', {}, [ (opts.span >= 60) ? _('%d min ago').format(Math.round(opts.span / 60)) : _('%d s ago').format(opts.span) ]),
|
|
E('span', {}, [ _('now') ])
|
|
])
|
|
]);
|
|
},
|
|
|
|
legend(items) {
|
|
return E('div', { 'class': 'dashboard-legend-inline' }, items.map(item =>
|
|
E('span', {}, [ E('i', { 'class': item.className }), item.label, (item.value != null) ? E('b', {}, [ item.value ]) : '' ])));
|
|
}
|
|
});
|