diff --git a/bambu_run/static/bambu_run/js/filament_detail.js b/bambu_run/static/bambu_run/js/filament_detail.js new file mode 100644 index 0000000..68e8bda --- /dev/null +++ b/bambu_run/static/bambu_run/js/filament_detail.js @@ -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" + * 2–7 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); diff --git a/bambu_run/static/bambu_run/js/printer_charts.js b/bambu_run/static/bambu_run/js/printer_charts.js index 9b3f7f4..7acb95d 100644 --- a/bambu_run/static/bambu_run/js/printer_charts.js +++ b/bambu_run/static/bambu_run/js/printer_charts.js @@ -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'); + }); +} diff --git a/bambu_run/static/bambu_run/js/printer_charts_control.js b/bambu_run/static/bambu_run/js/printer_charts_control.js index e8bb49c..3739674 100644 --- a/bambu_run/static/bambu_run/js/printer_charts_control.js +++ b/bambu_run/static/bambu_run/js/printer_charts_control.js @@ -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; diff --git a/bambu_run/templates/bambu_run/filament_detail.html b/bambu_run/templates/bambu_run/filament_detail.html index c9c1d53..f6f0e78 100644 --- a/bambu_run/templates/bambu_run/filament_detail.html +++ b/bambu_run/templates/bambu_run/filament_detail.html @@ -84,7 +84,7 @@
- Chart Filters + Filament Usage History (Last 24 Hours)
@@ -111,11 +111,11 @@
@@ -203,126 +203,39 @@ {% block extra_js %} + {% if not is_basic_user %} +{# Inject Django-specific values that the static JS file cannot know #} + {% else %}