fixed line chart noise x axis and add more date marker to split them up

This commit is contained in:
RNL
2026-02-25 23:05:24 +11:00
parent 7ca4cd57b5
commit 6d284ae79c
5 changed files with 463 additions and 137 deletions

View File

@@ -0,0 +1,302 @@
// Filament Detail Chart — Usage History
// Depends on: chart.js, chartjs-plugin-annotation
// Config injected by template: FILAMENT_USAGE_API_URL
let usageChart = null;
// Register annotation plugin once it's available
if (typeof ChartAnnotation !== 'undefined') {
Chart.register(ChartAnnotation);
}
// ── Time-select population ──────────────────────────────────────────────────
const startTimeSelect = document.getElementById('filamentStartTime');
const endTimeSelect = document.getElementById('filamentEndTime');
if (startTimeSelect && endTimeSelect) {
for (let h = 0; h < 24; h++) {
for (let m = 0; m < 60; m += 30) {
const t = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
startTimeSelect.add(new Option(t, t));
endTimeSelect.add(new Option(t, t));
}
}
// End-time gets one extra option so the last minute of the day is reachable
endTimeSelect.add(new Option('23:59', '23:59'));
startTimeSelect.value = '00:00';
endTimeSelect.value = '23:59';
}
// ── Default date inputs (last 24 h) ────────────────────────────────────────
(function setDefaultDates() {
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const sd = document.getElementById('filamentStartDate');
const ed = document.getElementById('filamentEndDate');
if (sd) sd.value = yesterday.toISOString().split('T')[0];
if (ed) ed.value = now.toISOString().split('T')[0];
}());
// ── Full-day checkbox ───────────────────────────────────────────────────────
const fullDayCheckbox = document.getElementById('filamentFullDayCheckbox');
if (fullDayCheckbox) {
fullDayCheckbox.addEventListener('change', function () {
const isFullDay = this.checked;
if (startTimeSelect) startTimeSelect.disabled = isFullDay;
if (endTimeSelect) endTimeSelect.disabled = isFullDay;
});
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/**
* Build date-separator annotations from "YYYY-MM-DD HH:MM" timestamp strings.
* Places a vertical dotted line at each day boundary, label at the bottom.
*/
function buildFilamentDateSeparators(timestamps) {
const annotations = {};
if (!timestamps || timestamps.length < 2) return annotations;
let count = 0;
for (let i = 1; i < timestamps.length; i++) {
const prevDate = timestamps[i - 1].split(' ')[0];
const currDate = timestamps[i].split(' ')[0];
if (currDate !== prevDate) {
const d = new Date(currDate + 'T00:00:00');
const label = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
annotations['dateSep_' + count] = {
type: 'line',
scaleID: 'x',
value: i,
borderColor: 'rgba(128, 128, 128, 0.45)',
borderWidth: 1,
borderDash: [4, 4],
drawTime: 'beforeDatasetsDraw',
label: {
display: true,
content: label,
position: 'end',
backgroundColor: 'rgba(100, 100, 100, 0.65)',
color: '#fff',
font: { size: 9 },
padding: { x: 4, y: 2 }
}
};
count++;
}
}
return annotations;
}
/**
* Build x-axis tick options that adapt to the date span.
*
* autoSkip: true — Chart.js selects evenly-spaced tick positions.
* maxTicksLimit — caps how many ticks are drawn.
* callback — formats the label at each chosen tick position.
*
* ≤1 day : up to 12 ticks, show "HH:MM"
* 27 days: up to dayCount×4 ticks (≤28), show "Feb 22 06:00"
* >7 days : up to min(dayCount, 20) ticks, show "Feb 22"
*/
function filamentXAxisTicks(isDarkMode, timestamps) {
const tickColor = isDarkMode ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.8)';
const dayCount = (timestamps && timestamps.length > 0)
? new Set(timestamps.map(t => t.split(' ')[0])).size
: 1;
let maxTicksLimit, formatCb;
if (dayCount <= 1) {
maxTicksLimit = 12;
formatCb = function (val) {
const label = this.getLabelForValue(val);
return label ? label.slice(11, 16) : ''; // "HH:MM"
};
} else if (dayCount <= 7) {
maxTicksLimit = Math.min(dayCount * 4, 28);
formatCb = function (val) {
const label = this.getLabelForValue(val);
if (!label) return '';
const datePart = label.split(' ')[0];
const timePart = label.length >= 16 ? label.slice(11, 16) : '';
const d = new Date(datePart + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' + timePart;
};
} else {
maxTicksLimit = Math.min(dayCount, 20);
formatCb = function (val) {
const label = this.getLabelForValue(val);
if (!label) return '';
const datePart = label.split(' ')[0];
const d = new Date(datePart + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
}
return {
color: tickColor,
autoSkip: true,
maxTicksLimit: maxTicksLimit,
maxRotation: 45,
minRotation: 0,
callback: formatCb
};
}
// ── Chart fetch / render ────────────────────────────────────────────────────
/**
* Fetch and render the usage chart.
*
* @param {boolean} sendDates When false (initial load / reset), no date params
* are sent so the backend can apply its default
* "last 24h or fallback to last available" logic.
* When true (explicit Refresh), the current input
* values are sent as-is.
*/
async function fetchFilamentUsageData(sendDates = true) {
const startDate = document.getElementById('filamentStartDate').value;
const endDate = document.getElementById('filamentEndDate').value;
const isFullDay = fullDayCheckbox ? fullDayCheckbox.checked : true;
const startTime = isFullDay ? '00:00' : (startTimeSelect ? startTimeSelect.value : '00:00');
const endTime = isFullDay ? '23:59' : (endTimeSelect ? endTimeSelect.value : '23:59');
const params = new URLSearchParams();
if (sendDates) {
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (startTime) params.append('start_time', startTime);
if (endTime) params.append('end_time', endTime);
}
try {
const response = await fetch(FILAMENT_USAGE_API_URL + '?' + params.toString());
const data = await response.json();
// If the backend used the fallback window, sync the date inputs so the
// user can see and extend the range from that starting point.
if (data.fallback_used && data.timestamps && data.timestamps.length > 0) {
const firstDate = data.timestamps[0].split(' ')[0];
const lastDate = data.timestamps[data.timestamps.length - 1].split(' ')[0];
const sd = document.getElementById('filamentStartDate');
const ed = document.getElementById('filamentEndDate');
if (sd) sd.value = firstDate;
if (ed) ed.value = lastDate;
}
// Update date-range label
const dateRangeSpan = document.getElementById('filamentDateRange');
if (dateRangeSpan) {
if (data.fallback_used) {
dateRangeSpan.textContent = '(Last available data — 24h window)';
} else if (startDate && endDate && sendDates) {
dateRangeSpan.textContent = `(${startDate} to ${endDate})`;
} else {
dateRangeSpan.textContent = '(Last 24 Hours)';
}
}
const isDarkMode = document.documentElement.getAttribute('data-coreui-theme') === 'dark';
const tickColor = isDarkMode ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.8)';
const gridColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
const sepAnnotations = buildFilamentDateSeparators(data.timestamps);
const xTicks = filamentXAxisTicks(isDarkMode, data.timestamps);
if (usageChart) {
usageChart.data.labels = data.timestamps;
usageChart.data.datasets[0].data = data.remaining;
usageChart.options.plugins.annotation.annotations = sepAnnotations;
usageChart.options.scales.x.ticks = xTicks;
usageChart.update();
} else {
const ctx = document.getElementById('usageChart').getContext('2d');
usageChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.timestamps,
datasets: [{
label: 'Remaining %',
data: data.remaining,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
tension: 0.3,
fill: true,
pointRadius: 0,
pointHoverRadius: 3,
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
annotation: { annotations: sepAnnotations },
legend: {
position: 'top',
labels: { color: tickColor }
},
tooltip: {
callbacks: {
label: function (ctx) {
return 'Remaining: ' + ctx.parsed.y + '%';
}
}
}
},
scales: {
x: {
ticks: xTicks,
grid: { color: gridColor }
},
y: {
beginAtZero: true,
max: 100,
ticks: {
color: tickColor,
callback: function (v) { return v + '%'; }
},
grid: { color: gridColor }
}
}
}
});
}
} catch (error) {
console.error('Error fetching filament usage data:', error);
}
}
// ── Event listeners ─────────────────────────────────────────────────────────
const refreshBtn = document.getElementById('refreshFilamentChart');
const resetBtn = document.getElementById('resetFilamentChart');
if (refreshBtn) {
// Refresh: honour whatever the user has typed in the date inputs
refreshBtn.addEventListener('click', function () { fetchFilamentUsageData(true); });
}
if (resetBtn) {
resetBtn.addEventListener('click', function () {
// Reset inputs to "last 24 hours" defaults, then let the backend
// decide (fallback if no recent data).
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const sd = document.getElementById('filamentStartDate');
const ed = document.getElementById('filamentEndDate');
if (sd) sd.value = yesterday.toISOString().split('T')[0];
if (ed) ed.value = now.toISOString().split('T')[0];
if (fullDayCheckbox) fullDayCheckbox.checked = true;
if (startTimeSelect) startTimeSelect.disabled = true;
if (endTimeSelect) endTimeSelect.disabled = true;
fetchFilamentUsageData(false);
});
}
// ── Initial load — no dates so backend fallback can fire ───────────────────
fetchFilamentUsageData(false);

View File

@@ -55,7 +55,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverRadius: 3,
spanGaps: true
},
{
@@ -67,7 +67,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverRadius: 3,
spanGaps: true
}
]
@@ -90,7 +90,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverRadius: 3,
spanGaps: true
},
{
@@ -102,7 +102,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverRadius: 3,
spanGaps: true
}
]
@@ -125,7 +125,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverRadius: 3,
fill: true
}
]
@@ -148,7 +148,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverRadius: 3,
spanGaps: true
},
{
@@ -159,7 +159,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverRadius: 3,
spanGaps: true
}
]
@@ -182,7 +182,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverRadius: 3,
spanGaps: true
}
]
@@ -246,7 +246,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverRadius: 3,
yAxisID: 'y',
spanGaps: true
},
@@ -258,7 +258,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverRadius: 3,
yAxisID: 'y1',
spanGaps: true
}
@@ -342,7 +342,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverRadius: 3,
fill: true
},
{
@@ -354,7 +354,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverRadius: 3,
spanGaps: true
}
]
@@ -452,6 +452,11 @@ function initPrinterCharts(printerData, apiUrl) {
}
});
// Add date separator markers when data spans multiple days
if (printerData.dates && printerData.dates.length > 0) {
applyDateSeparatorsToAllPrinterCharts(printerData.timestamps, printerData.dates);
}
// Set up theme observer for dynamic theme switching
setupThemeObserver();
}
@@ -623,7 +628,7 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverRadius: 3,
spanGaps: false // Don't connect across null values (filament changes)
});
});
@@ -731,3 +736,79 @@ function setupThemeObserver() {
attributeFilter: ['data-coreui-theme']
});
}
/**
* Build date-separator annotations for multi-day charts.
* Detects where consecutive dates differ and returns a vertical dotted line
* annotation at each boundary index, labelled with the new date.
*
* @param {string[]} timestamps - HH:MM display labels (one per data point)
* @param {string[]} dates - YYYY-MM-DD dates (same length as timestamps)
* @returns {Object} chartjs-plugin-annotation annotations keyed as "dateSep_N"
*/
function buildDateSeparatorAnnotations(timestamps, dates) {
const annotations = {};
if (!dates || dates.length < 2) return annotations;
let count = 0;
for (let i = 1; i < dates.length; i++) {
if (dates[i] !== dates[i - 1]) {
// Format date as "Feb 25" for a compact label
const d = new Date(dates[i] + 'T00:00:00');
const label = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
annotations['dateSep_' + count] = {
type: 'line',
scaleID: 'x',
value: i,
borderColor: 'rgba(128, 128, 128, 0.45)',
borderWidth: 1,
borderDash: [4, 4],
drawTime: 'beforeDatasetsDraw',
label: {
display: true,
content: label,
position: 'end',
backgroundColor: 'rgba(100, 100, 100, 0.65)',
color: '#fff',
font: { size: 9 },
padding: { x: 4, y: 2 }
}
};
count++;
}
}
return annotations;
}
/**
* Apply date-separator annotations to all printer charts.
* Preserves any existing "marker_*" (project marker) annotations.
*
* @param {string[]} timestamps
* @param {string[]} dates
*/
function applyDateSeparatorsToAllPrinterCharts(timestamps, dates) {
const sepAnnotations = buildDateSeparatorAnnotations(timestamps, dates);
const charts = [
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
];
charts.forEach(chart => {
if (!chart) return;
if (!chart.options.plugins.annotation) {
chart.options.plugins.annotation = { annotations: {} };
}
const existing = chart.options.plugins.annotation.annotations;
// Remove any old dateSep_* entries then re-add updated ones
Object.keys(existing).forEach(key => {
if (key.startsWith('dateSep_')) delete existing[key];
});
Object.assign(existing, sepAnnotations);
chart.update('none');
});
}

View File

@@ -77,11 +77,12 @@ function populateTimeDropdowns(startSelect, endSelect) {
}
times.forEach(time => {
const option1 = new Option(time, time);
const option2 = new Option(time, time);
startSelect.add(option1);
endSelect.add(option2);
startSelect.add(new Option(time, time));
endSelect.add(new Option(time, time));
});
// End-time gets one extra option so the last minute of the day is reachable
endSelect.add(new Option('23:59', '23:59'));
}
/**
@@ -235,6 +236,11 @@ function updateAllPrinterCharts(data) {
filamentTimelineChart.update();
}
// Apply date separator markers (multi-day views)
if (data.dates && data.dates.length > 0) {
applyDateSeparatorsToAllPrinterCharts(data.timestamps, data.dates);
}
// Add project markers to all charts
if (data.project_markers) {
addProjectMarkersToCharts(data.project_markers, data.timestamps);
@@ -275,8 +281,11 @@ function addProjectMarkersToCharts(markers, timestamps) {
chart.options.plugins.annotation = { annotations: {} };
}
// Clear existing project markers
chart.options.plugins.annotation.annotations = {};
// Clear existing project markers but preserve date-separator annotations
const allAnnotations = chart.options.plugins.annotation.annotations;
Object.keys(allAnnotations).forEach(key => {
if (!key.startsWith('dateSep_')) delete allAnnotations[key];
});
// Track active tooltip
let activeMarkerTooltip = null;