mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-08 01:42:59 +02:00
web: allow certain charts to overflow upper bounds (#585)
* web: allow certain charts to overflow upper bounds * web: cover missing unit test vectors
This commit is contained in:
@@ -405,6 +405,77 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
|
||||
assert.equal(html.includes('node-detail__chart-point'), true);
|
||||
});
|
||||
|
||||
test('renderTelemetryCharts expands upper bounds when overflow metrics exceed defaults', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 120,
|
||||
device_metrics: {
|
||||
battery_level: 90,
|
||||
voltage: 7.2,
|
||||
current: 3.6,
|
||||
channel_utilization: 45,
|
||||
air_util_tx: 18,
|
||||
},
|
||||
environment_metrics: {
|
||||
temperature: 45,
|
||||
relative_humidity: 48,
|
||||
barometric_pressure: 1250,
|
||||
gas_resistance: 1200,
|
||||
iaq: 650,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const html = renderTelemetryCharts(node, { nowMs });
|
||||
assert.match(html, />7\.2<\/text>/);
|
||||
assert.match(html, />3\.6<\/text>/);
|
||||
assert.match(html, />45<\/text>/);
|
||||
assert.match(html, />650<\/text>/);
|
||||
assert.match(html, />1100<\/text>/);
|
||||
});
|
||||
|
||||
test('renderTelemetryCharts keeps default bounds when metrics stay within limits', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 180,
|
||||
device_metrics: {
|
||||
battery_level: 70,
|
||||
voltage: 4.5,
|
||||
current: 1.5,
|
||||
channel_utilization: 35,
|
||||
air_util_tx: 15,
|
||||
},
|
||||
environment_metrics: {
|
||||
temperature: 25,
|
||||
relative_humidity: 50,
|
||||
barometric_pressure: 1015,
|
||||
gas_resistance: 1500,
|
||||
iaq: 200,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const html = renderTelemetryCharts(node, { nowMs });
|
||||
assert.match(html, />6\.0<\/text>/);
|
||||
assert.match(html, />3\.0<\/text>/);
|
||||
assert.match(html, />40<\/text>/);
|
||||
assert.match(html, />500<\/text>/);
|
||||
});
|
||||
|
||||
test('renderNodeDetailHtml composes the table, neighbors, and messages', () => {
|
||||
const html = renderNodeDetailHtml(
|
||||
{
|
||||
|
||||
@@ -68,6 +68,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 6,
|
||||
ticks: 3,
|
||||
color: '#9ebcda',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
{
|
||||
id: 'current',
|
||||
@@ -77,6 +78,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 3,
|
||||
ticks: 3,
|
||||
color: '#3182bd',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -156,6 +158,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 40,
|
||||
ticks: 4,
|
||||
color: '#fc8d59',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
{
|
||||
id: 'humidity',
|
||||
@@ -220,6 +223,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 500,
|
||||
ticks: 5,
|
||||
color: '#636363',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -1004,6 +1008,31 @@ function buildSeriesPoints(entries, fields, domainStart, domainEnd) {
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective axis maximum when upper overflow is allowed.
|
||||
*
|
||||
* @param {Object} axis Axis descriptor.
|
||||
* @param {Array<{axisId: string, points: Array<{timestamp: number, value: number}>}>} seriesEntries Series entries.
|
||||
* @returns {number} Effective axis max.
|
||||
*/
|
||||
function resolveAxisMax(axis, seriesEntries) {
|
||||
if (!axis || axis.allowUpperOverflow !== true) {
|
||||
return axis?.max;
|
||||
}
|
||||
let observedMax = null;
|
||||
for (const entry of seriesEntries) {
|
||||
if (!entry || entry.axisId !== axis.id || !Array.isArray(entry.points)) continue;
|
||||
for (const point of entry.points) {
|
||||
if (!point || !Number.isFinite(point.value)) continue;
|
||||
observedMax = observedMax == null ? point.value : Math.max(observedMax, point.value);
|
||||
}
|
||||
}
|
||||
if (observedMax != null && Number.isFinite(axis.max) && observedMax > axis.max) {
|
||||
return observedMax;
|
||||
}
|
||||
return axis.max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a telemetry series as circles plus an optional translucent guide line.
|
||||
*
|
||||
@@ -1133,33 +1162,48 @@ function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
|
||||
const domainEnd = nowMs;
|
||||
const domainStart = nowMs - windowMs;
|
||||
const dims = createChartDimensions(spec);
|
||||
const axisMap = new Map(spec.axes.map(axis => [axis.id, axis]));
|
||||
const seriesEntries = spec.series
|
||||
.map(series => {
|
||||
const axis = axisMap.get(series.axis);
|
||||
if (!axis) return null;
|
||||
const points = buildSeriesPoints(entries, series.fields, domainStart, domainEnd);
|
||||
if (points.length === 0) return null;
|
||||
return { config: series, axis, points };
|
||||
return { config: series, axisId: series.axis, points };
|
||||
})
|
||||
.filter(entry => entry != null);
|
||||
if (seriesEntries.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const axesMarkup = spec.axes.map(axis => renderYAxis(axis, dims)).join('');
|
||||
const adjustedAxes = spec.axes.map(axis => {
|
||||
const resolvedMax = resolveAxisMax(axis, seriesEntries);
|
||||
if (resolvedMax != null && resolvedMax !== axis.max) {
|
||||
return { ...axis, max: resolvedMax };
|
||||
}
|
||||
return axis;
|
||||
});
|
||||
const axisMap = new Map(adjustedAxes.map(axis => [axis.id, axis]));
|
||||
const plottedSeries = seriesEntries
|
||||
.map(series => {
|
||||
const axis = axisMap.get(series.axisId);
|
||||
if (!axis) return null;
|
||||
return { config: series.config, axis, points: series.points };
|
||||
})
|
||||
.filter(entry => entry != null);
|
||||
if (plottedSeries.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const axesMarkup = adjustedAxes.map(axis => renderYAxis(axis, dims)).join('');
|
||||
const tickBuilder = typeof chartOptions.xAxisTickBuilder === 'function' ? chartOptions.xAxisTickBuilder : buildMidnightTicks;
|
||||
const tickFormatter = typeof chartOptions.xAxisTickFormatter === 'function' ? chartOptions.xAxisTickFormatter : formatCompactDate;
|
||||
const ticks = tickBuilder(nowMs, windowMs);
|
||||
const xAxisMarkup = renderXAxis(dims, domainStart, domainEnd, ticks, { labelFormatter: tickFormatter });
|
||||
|
||||
const seriesMarkup = seriesEntries
|
||||
const seriesMarkup = plottedSeries
|
||||
.map(series =>
|
||||
renderTelemetrySeries(series.config, series.points, series.axis, dims, domainStart, domainEnd, {
|
||||
lineReducer: chartOptions.lineReducer,
|
||||
}),
|
||||
)
|
||||
.join('');
|
||||
const legendItems = seriesEntries
|
||||
const legendItems = plottedSeries
|
||||
.map(series => {
|
||||
const legendLabel = stringOrNull(series.config.legend) ?? series.config.label;
|
||||
return `
|
||||
|
||||
Reference in New Issue
Block a user