// app_index.js const socket = io(); let currentPeriod = "1d"; let chartCo2, chartTemp, chartHumi, chartTickets; // Filter state — all selected by default let filterOrgs = { items: [], selected: new Set() }; let filterTypes = { items: [], selected: new Set() }; // ── Colors ───────────────────────────────────────────────────────────────────── const COLOR_CO2 = "rgb(197, 225, 167)"; const COLOR_TEMP = "rgb(242, 79, 41)"; const COLOR_HUMI = "rgb(103, 182, 244)"; const COLOR_TOTAL = "rgb(254, 223, 135)"; /* MEDIUM yellow — total tickets */ const COLOR_FILTERED = "rgb(242, 79, 41)"; /* VERY_HIGH red — filtered tickets */ // ── Crosshair plugin ─────────────────────────────────────────────────────────── const sensorCharts = () => [chartCo2, chartTemp, chartHumi].filter(Boolean); const crosshairPlugin = { id: "crosshairSync", afterDraw(chart) { if (chart._crosshairX == null) return; const ctx = chart.ctx, area = chart.chartArea; ctx.save(); ctx.beginPath(); ctx.moveTo(chart._crosshairX, area.top); ctx.lineTo(chart._crosshairX, area.bottom); ctx.lineWidth = 1; ctx.strokeStyle = "rgba(255,255,255,0.3)"; ctx.stroke(); ctx.restore(); } }; Chart.register(crosshairPlugin); function syncCrosshair(sourceChart, x) { sensorCharts().forEach(c => { if (c === sourceChart) return; const src = sourceChart.scales.x, tgt = c.scales.x; if (!src || !tgt) return; c._crosshairX = tgt.left + ((x - src.left) / src.width) * tgt.width; c.draw(); }); } function clearCrosshair() { sensorCharts().forEach(c => { c._crosshairX = null; c.draw(); }); } function attachCrosshairEvents(canvas, getChart) { canvas.addEventListener("mousemove", e => { const chart = getChart(); if (!chart) return; const x = e.clientX - canvas.getBoundingClientRect().left; chart._crosshairX = x; chart.draw(); syncCrosshair(chart, x); }); canvas.addEventListener("mouseleave", clearCrosshair); } // ── Chart factory ────────────────────────────────────────────────────────────── function makeSensorChart(canvasId, label, color) { return new Chart(document.getElementById(canvasId).getContext("2d"), { type: "line", data: { datasets: [{ label, data: [], borderColor: color, backgroundColor: color.replace("rgb(","rgba(").replace(")",", 0.15)"), borderWidth: 1.5, pointRadius: 0, tension: 0.3, fill: true }]}, options: { responsive: true, maintainAspectRatio: false, animation: false, interaction: { mode: "index", intersect: false }, plugins: { legend: { display: false } }, scales: { x: { type: "time", time: { tooltipFormat: "dd.MM HH:mm", displayFormats: { hour: "HH:mm", day: "dd.MM" } }, ticks: { color: "#666", maxTicksLimit: 5 }, grid: { color: "#2a2a2a" } }, y: { ticks: { color: "#666" }, grid: { color: "#2a2a2a" } } } } }); } function makeAreaChart() { return new Chart(document.getElementById("chartTickets").getContext("2d"), { type: "line", data: { datasets: [ { label: "Total", data: [], borderColor: COLOR_TOTAL, backgroundColor: "rgba(254,223,135,0.3)", borderWidth: 1.5, pointRadius: 0, stepped: "before", fill: false, spanGaps: true, order: 2 }, { label: "Filtered", data: [], borderColor: COLOR_FILTERED, backgroundColor: "rgba(242,79,41,0.5)", borderWidth: 1.5, pointRadius: 0, stepped: "before", fill: false, spanGaps: true, order: 1 }, ]}, options: { responsive: true, maintainAspectRatio: false, animation: false, interaction: { mode: "index", intersect: false }, plugins: { legend: { position: "bottom", labels: { color: "#b0b0b0", boxWidth: 10, font: { size: 11 }, padding: 8 } } }, scales: { x: { type: "time", time: { tooltipFormat: "dd.MM HH:mm", displayFormats: { hour: "HH:mm", day: "dd.MM" } }, ticks: { color: "#666", maxTicksLimit: 8 }, grid: { color: "#2a2a2a" } }, y: { ticks: { color: "#666", stepSize: 1, callback: v => Number.isInteger(v) ? v : null }, grid: { color: "#2a2a2a" } } } } }); } function initCharts() { chartCo2 = makeSensorChart("chartCo2", "CO₂ (ppm)", COLOR_CO2); chartTemp = makeSensorChart("chartTemp", "Temp (°C)", COLOR_TEMP); chartHumi = makeSensorChart("chartHumi", "Humi (%)", COLOR_HUMI); chartTickets = makeAreaChart(); attachCrosshairEvents(document.getElementById("chartCo2"), () => chartCo2); attachCrosshairEvents(document.getElementById("chartTemp"), () => chartTemp); attachCrosshairEvents(document.getElementById("chartHumi"), () => chartHumi); } // ── Multi-select dropdown ────────────────────────────────────────────────────── function toggleDropdown(id) { const panel = document.getElementById(`${id}-panel`); const isOpen = panel.classList.contains("open"); // Close all document.querySelectorAll(".ms-panel.open").forEach(p => p.classList.remove("open")); if (!isOpen) panel.classList.add("open"); } // Close dropdowns when clicking outside document.addEventListener("click", e => { if (!e.target.closest(".ms-dropdown")) { document.querySelectorAll(".ms-panel.open").forEach(p => p.classList.remove("open")); } }); function buildDropdown(id, items, filter) { const list = document.getElementById(`${id}-list`); list.innerHTML = items.map(item => ` `).join(""); updateLabel(id, filter); } function updateSelection(id, checkbox) { const filter = id === "ddOrgs" ? filterOrgs : filterTypes; if (checkbox.checked) filter.selected.add(checkbox.value); else filter.selected.delete(checkbox.value); updateLabel(id, filter); } function updateLabel(id, filter) { const total = filter.items.length; const selected = filter.selected.size; const label = id === "ddOrgs" ? "Organizations" : "Types"; document.getElementById(`${id}-label`).textContent = selected === total ? label : `${label} (${selected}/${total})`; } function filterItems(id, query) { const q = query.toLowerCase(); const items = document.querySelectorAll(`#${id}-list .ms-item`); items.forEach(item => { item.style.display = item.textContent.toLowerCase().includes(q) ? "" : "none"; }); } function selectAllItems(id) { const filter = id === "ddOrgs" ? filterOrgs : filterTypes; filter.items.forEach(item => filter.selected.add(item.value)); buildDropdown(id, filter.items, filter); } function selectNoneItems(id) { const filter = id === "ddOrgs" ? filterOrgs : filterTypes; filter.selected.clear(); buildDropdown(id, filter.items, filter); } function applyFilter(id) { document.getElementById(`${id}-panel`).classList.remove("open"); loadTicketHistory(); } // ── Data loading ─────────────────────────────────────────────────────────────── function loadData(period) { currentPeriod = period; socket.emit("get_sensor_data", { period }); loadTicketHistory(); } function loadTicketHistory() { socket.emit("get_ticket_history", { period: currentPeriod, org_ids: [...filterOrgs.selected], types: [...filterTypes.selected], }); } // ── Socket events ────────────────────────────────────────────────────────────── socket.on("connect", () => { initCharts(); socket.emit("get_ticket_filters", {}); loadData(currentPeriod); }); socket.on("connect_error", () => setStatus("Connection error. Please reload.", false)); socket.on("ticket_filters", function(data) { // Build org filter filterOrgs.items = (data.orgs || []).map(o => ({ value: o.id, label: o.name })); filterOrgs.selected = new Set(filterOrgs.items.map(i => i.value)); buildDropdown("ddOrgs", filterOrgs.items, filterOrgs); // Build type filter filterTypes.items = (data.types || []).map(t => ({ value: t, label: t })); filterTypes.selected = new Set(filterTypes.items.map(i => i.value)); buildDropdown("ddTypes", filterTypes.items, filterTypes); }); socket.on("sensor_data", function(data) { const toXY = arr => (arr || []).map(r => ({ x: r.ts, y: r.v })); chartCo2.data.datasets[0].data = toXY(data.co2); chartTemp.data.datasets[0].data = toXY(data.temperature); chartHumi.data.datasets[0].data = toXY(data.humidity); chartCo2.update(); chartTemp.update(); chartHumi.update(); }); socket.on("ticket_history", function(data) { const labels = data.labels || []; const total = data.total || []; const filtered = data.filtered || []; chartTickets.data.datasets[0].data = labels.map((ts, i) => ({ x: ts, y: total[i] })); chartTickets.data.datasets[1].data = labels.map((ts, i) => ({ x: ts, y: filtered[i] })); // Clamp x axis to last known point — prevents Chart.js from extending to 0 if (labels.length > 0) { chartTickets.options.scales.x.max = labels[labels.length - 1]; } chartTickets.update(); }); socket.on("tickets_updated", (data) => { setStatus(`Tickets updated at ${data.time} (${data.count} tickets)`, null); loadTicketHistory(); }); // ── Range buttons ────────────────────────────────────────────────────────────── document.querySelectorAll(".btn-range").forEach(btn => { btn.addEventListener("click", () => { document.querySelectorAll(".btn-range").forEach(b => b.classList.remove("active")); btn.classList.add("active"); loadData(btn.dataset.period); }); }); // ── Helpers ──────────────────────────────────────────────────────────────────── function setStatus(message, success) { const el = document.getElementById("statusMessage"); el.innerText = message; if (success === true) el.style.color = "#4db6ac"; else if (success === false) el.style.color = "#e57373"; else el.style.color = "#bb86fc"; } function escapeHtml(str) { return String(str).replace(/&/g,"&").replace(//g,">"); }