"use strict"; const state = { from: null, to: null, agg: "hour", device: "", showTemp: true, showHum: true, showOut: true, meta: null, }; let chart = null; let currentData = { series: {} }; const $ = (sel) => document.querySelector(sel); function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } function pad(n) { return String(n).padStart(2, "0"); } function toLocalInput(date) { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } function setStatus(ok) { const el = $("#status"); el.classList.toggle("ok", ok === true); el.classList.toggle("err", ok === false); } function applyPreset(days) { const now = new Date(); let from; if (days === "all") { const min = state.meta && state.meta.min ? new Date(state.meta.min.replace(" ", "T")) : null; from = min && !isNaN(min) ? min : new Date(now.getTime() - 7 * 864e5); } else { from = new Date(now.getTime() - Number(days) * 864e5); } state.from = toLocalInput(from); state.to = toLocalInput(now); $("#from").value = state.from; $("#to").value = state.to; load(); } // Kärbi kõik seeriad ühisesse ajavahemikku, et jooned algaksid ja lõpeksid koos. function clipToCommonRange(series) { // "· täidetud" hajuvusmärgistused ei tohi ühist vahemikku määrata: viimane märge // võib olla varasem kui viimane mõõtmine → muidu kärbitakse telg liiga vara ja // kaob viimane tund (nt 17:00). const isMarker = (s) => String(s.name || "").endsWith("· täidetud"); const active = series.filter((s) => Array.isArray(s.data) && s.data.length && !isMarker(s)); if (active.length < 2) return series; const start = Math.max(...active.map((s) => s.data[0][0])); const end = Math.min(...active.map((s) => s.data[s.data.length - 1][0])); if (!(start < end)) return series; return series.map((s) => { if (!Array.isArray(s.data)) return s; return { ...s, data: s.data.filter((d) => d && d[0] >= start && d[0] <= end) }; }); } function buildOption(data) { const text = cssVar("--text"); const muted = cssVar("--muted"); const border = cssVar("--border"); const tempColor = cssVar("--temp"); const humColor = cssVar("--hum"); const outColor = cssVar("--out"); const series = []; const palette = ["#1f6feb", "#e8590c", "#2fb344", "#9c36b5", "#f08c00", "#0ca678"]; const devices = Object.keys(data.series).sort(); let pi = 0; const humAxis = state.showHum; for (const dev of devices) { const s = data.series[dev]; const baseColor = palette[pi % palette.length]; pi += 1; if (state.showTemp) { series.push({ name: `${dev} · °C`, type: "line", yAxisIndex: 0, showSymbol: false, smooth: 0.25, lineStyle: { width: 2.4, color: tempColor }, itemStyle: { color: tempColor }, emphasis: { focus: "series" }, data: s.t.map((t, i) => [t, s.temperature[i]]), }); if (Array.isArray(s.taidetud) && s.taidetud.some(Boolean)) { series.push({ name: `${dev} · täidetud`, type: "scatter", yAxisIndex: 0, symbol: "emptyCircle", symbolSize: 9, itemStyle: { color: tempColor, borderWidth: 2 }, z: 3, data: s.t .map((t, i) => (s.taidetud[i] ? [t, s.temperature[i]] : null)) .filter(Boolean), }); } } if (state.showHum) { series.push({ name: `${dev} · %`, type: "line", yAxisIndex: 1, showSymbol: false, smooth: 0.25, lineStyle: { width: 1.8, type: "dashed", color: humColor }, itemStyle: { color: humColor }, emphasis: { focus: "series" }, data: s.t.map((t, i) => [t, s.humidity[i]]), }); } } const out = data.outside; if (state.showOut && out && Array.isArray(out.t) && out.t.length) { series.push({ name: "Väli · °C", type: "line", yAxisIndex: 0, showSymbol: false, smooth: 0.25, lineStyle: { width: 2, color: outColor }, itemStyle: { color: outColor }, emphasis: { focus: "series" }, data: out.t.map((t, i) => [t, out.temperature[i]]), }); if (Array.isArray(out.taidetud) && out.taidetud.some(Boolean)) { series.push({ name: "Väli · täidetud", type: "scatter", yAxisIndex: 0, symbol: "emptyCircle", symbolSize: 9, itemStyle: { color: outColor, borderWidth: 2 }, z: 3, data: out.t .map((t, i) => (out.taidetud[i] ? [t, out.temperature[i]] : null)) .filter(Boolean), }); } } return { backgroundColor: "transparent", animationDuration: 300, color: palette, grid: { left: 6, right: humAxis ? 6 : 6, top: 40, bottom: 62, containLabel: true }, legend: { top: 4, type: "scroll", textStyle: { color: text, fontSize: 12 }, inactiveColor: muted, }, tooltip: { trigger: "axis", backgroundColor: cssVar("--panel"), borderColor: border, textStyle: { color: text, fontSize: 12 }, axisPointer: { type: "cross", label: { backgroundColor: muted } }, valueFormatter: null, formatter: (params) => { const items = (params || []).filter( (p) => !String(p.seriesName).endsWith("· täidetud") ); if (!items.length) return ""; const d = new Date(items[0].value[0]); const head = `${d.toLocaleDateString("et-EE")} ${pad(d.getHours())}:${pad(d.getMinutes())}`; const lines = items.map((p) => { const v = p.value[1]; const unit = p.seriesName.endsWith("°C") ? " °C" : " %"; return `
${p.marker} ${p.seriesName}${v == null ? "–" : v + unit}
`; }); return `
${head}
${lines.join("")}`; }, }, xAxis: { type: "time", axisLine: { lineStyle: { color: border } }, axisLabel: { color: muted, hideOverlap: true }, splitLine: { show: false }, }, yAxis: [ { type: "value", name: "°C", scale: true, axisLine: { show: false }, axisLabel: { color: muted }, splitLine: { lineStyle: { color: border, type: "dashed" } }, }, ...(humAxis ? [ { type: "value", name: "%", scale: true, position: "right", axisLine: { show: false }, axisLabel: { color: muted }, splitLine: { show: false }, }, ] : []), ], dataZoom: [ { type: "inside", throttle: 50 }, { type: "slider", height: 22, bottom: 14, borderColor: border, textStyle: { color: muted }, fillColor: cssVar("--accent-soft") }, ], series: clipToCommonRange(series), }; } function render(data) { const chartEl = $("#chart"); const hasData = Object.keys(data.series).length > 0 && Object.values(data.series).some((s) => s.t.length); $("#hint").hidden = hasData; if (!hasData) { $("#hint").textContent = "Valitud vahemikus andmeid ei ole. Andur salvestab andmeid alates 2026-09-17."; } if (!chart) chart = echarts.init(chartEl, null, { renderer: "canvas" }); chart.setOption(buildOption(data), true); chart.resize(); } async function load() { const params = new URLSearchParams({ from: state.from, to: state.to, agg: state.agg, }); if (state.device) params.set("device", state.device); setStatus(null); try { const [tempRes, outRes] = await Promise.all([ fetch(`/api/temperatuur?${params}`), fetch(`/api/valisilm?${params}`), ]); if (!tempRes.ok) throw new Error(await tempRes.text()); const data = await tempRes.json(); if (outRes.ok) { try { data.outside = (await outRes.json()).series; } catch { data.outside = null; } } currentData = data; currentSeries = data.series; render(data); loadProtected(); setStatus(true); const f = new Date(state.from.replace(" ", "T")); const t = new Date(state.to.replace(" ", "T")); $("#range-label").textContent = `${f.toLocaleString("et-EE")} → ${t.toLocaleString("et-EE")}`; } catch (err) { console.error(err); setStatus(false); $("#hint").hidden = false; $("#hint").textContent = "Andmete laadimine ebaõnnestus."; } } async function loadMeta() { try { const res = await fetch("/api/meta"); if (!res.ok) throw new Error(); state.meta = await res.json(); $("#subtitle").textContent = "Zigbee andurid"; if (state.meta.min) { $("#range-label").textContent = `Andmed alates ${state.meta.min}`; } } catch { $("#subtitle").textContent = "Zigbee andurid"; } } async function loadLatest() { try { const res = await fetch("/api/latest"); if (!res.ok) return; const data = await res.json(); renderCards(data); } catch { /* vaikne */ } } function renderCards(data) { const cards = []; for (const r of data.readings) { cards.push(`
🌡️ ${r.device} · temperatuur
${r.temperature ?? "–"} °C
`); cards.push(`
💧 ${r.device} · niiskus
${r.humidity ?? "–"} %
`); } if (data.outside && data.outside.temperature != null) { cards.push(`
🌤️ Väli · temperatuur
${data.outside.temperature} °C
`); } if (data.open_faults.length) { const items = data.open_faults .map((f) => `
⚠️ ${f.device} · ${f.tyyp}${f.detail ? " · " + f.detail : ""}
`) .join(""); cards.push(`
Lahtised häired (${data.open_faults.length})
${items}
`); } $("#cards").innerHTML = cards.join(""); } function setTheme(theme) { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("nutikodu-theme", theme); $("#theme-toggle").textContent = theme === "dark" ? "☀️" : "🌙"; const meta = $("#meta-theme"); if (meta) meta.setAttribute("content", theme === "dark" ? "#0e1116" : "#1f6feb"); if (chart) chart.setOption(buildOption(currentData), true); } let currentSeries = {}; function init() { const saved = localStorage.getItem("nutikodu-theme"); const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; setTheme(saved || (prefersDark ? "dark" : "light")); $("#theme-toggle").addEventListener("click", () => { setTheme(document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark"); }); document.querySelectorAll("#presets button").forEach((btn) => { btn.addEventListener("click", () => { document.querySelectorAll("#presets button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); applyPreset(btn.dataset.days); }); }); document.querySelectorAll("#agg button").forEach((btn) => { btn.addEventListener("click", () => { document.querySelectorAll("#agg button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); state.agg = btn.dataset.agg; load(); }); }); $("#show-temp").addEventListener("change", (e) => { state.showTemp = e.target.checked; load(); }); $("#show-hum").addEventListener("change", (e) => { state.showHum = e.target.checked; load(); }); $("#show-out").addEventListener("change", (e) => { state.showOut = e.target.checked; load(); }); $("#apply").addEventListener("click", () => { state.from = $("#from").value; state.to = $("#to").value; document.querySelectorAll("#presets button").forEach((b) => b.classList.remove("active")); load(); }); window.addEventListener("resize", () => chart && chart.resize()); window.addEventListener("orientationchange", () => setTimeout(() => chart && chart.resize(), 250)); window.addEventListener("load", () => chart && chart.resize()); checkAuth().then(() => loadMeta().then(() => applyPreset(7))); loadLatest(); setInterval(loadLatest, 60000); setInterval(loadProtected, 60000); initTabs(); } // --- Uus67: energia + liikumine --- let chartEnergy = null; let chartMotion = null; function palColors() { return ["#1f6feb", "#e8590c", "#2fb344", "#9c36b7", "#f08c00", "#0ca678"]; } async function loadEnergy() { if (!state.from) return; const params = new URLSearchParams({ from: state.from, to: state.to, agg: state.agg }); try { const res = await fetch(`/api/energia?${params}`); if (!res.ok) return; renderEnergy(await res.json()); } catch (e) { console.error(e); } } function renderEnergy(data) { const el = document.getElementById("chart-energy"); if (!el) return; const seriesData = data.series || {}; const hasData = Object.keys(seriesData).length > 0 && Object.values(seriesData).some((s) => s.t.length); const hint = document.getElementById("hint-energy"); if (hint) { hint.hidden = hasData; if (!hasData) hint.textContent = "Energia andmeid veel pole (koguneb tunnipõhiselt, esimene rida täistunnil)."; } if (!hasData) { if (chartEnergy) chartEnergy.clear(); return; } if (!chartEnergy) chartEnergy = echarts.init(el, null, { renderer: "canvas" }); const pal = palColors(); const series = Object.keys(seriesData).sort().map((dev, i) => { const s = seriesData[dev]; return { name: `${dev} · W`, type: "line", showSymbol: false, smooth: 0.25, lineStyle: { width: 2.2, color: pal[i % pal.length] }, itemStyle: { color: pal[i % pal.length] }, data: s.t.map((t, k) => [t, s.power[k]]), }; }); chartEnergy.setOption({ backgroundColor: "transparent", tooltip: { trigger: "axis" }, legend: { top: 0, type: "scroll", textStyle: { color: cssVar("--text") } }, grid: { left: 6, right: 6, top: 34, bottom: 44, containLabel: true }, xAxis: { type: "time", axisLabel: { color: cssVar("--muted") }, axisLine: { lineStyle: { color: cssVar("--border") } } }, yAxis: { type: "value", name: "W", scale: true, axisLabel: { color: cssVar("--muted") }, splitLine: { lineStyle: { color: cssVar("--border"), type: "dashed" } } }, dataZoom: [{ type: "inside" }, { type: "slider", height: 18, bottom: 8, borderColor: cssVar("--border") }], series, }, true); chartEnergy.resize(); } async function loadMotion() { if (!state.from) return; const params = new URLSearchParams({ from: state.from, to: state.to }); try { const res = await fetch(`/api/liikumine?${params}`); if (!res.ok) return; renderMotion(await res.json()); } catch (e) { console.error(e); } } function renderMotion(data) { const el = document.getElementById("chart-motion"); if (!el) return; const ev = data.events || []; const hint = document.getElementById("hint-motion"); if (hint) { hint.hidden = ev.length > 0; if (!ev.length) hint.textContent = "Liikumise sündmusi veel pole."; } if (!ev.length) { if (chartMotion) chartMotion.clear(); return; } if (!chartMotion) chartMotion = echarts.init(el, null, { renderer: "canvas" }); const pal = palColors(); const devices = [...new Set(ev.map((e) => e.device))].sort(); const series = devices.map((dev, i) => ({ name: dev, type: "scatter", symbolSize: 13, itemStyle: { color: pal[i % pal.length] }, data: ev.filter((e) => e.device === dev && e.occupancy).map((e) => [e.t, 1]), })); chartMotion.setOption({ backgroundColor: "transparent", tooltip: { trigger: "axis", formatter: (p) => { if (!p || !p.length) return ""; const d = new Date(p[0].value[0]); return `${d.toLocaleString("et-EE")}
${p.map((x) => x.seriesName).join(", ")}`; }, }, legend: { top: 0, textStyle: { color: cssVar("--text") } }, grid: { left: 6, right: 6, top: 34, bottom: 44, containLabel: true }, xAxis: { type: "time", axisLabel: { color: cssVar("--muted") }, axisLine: { lineStyle: { color: cssVar("--border") } } }, yAxis: { type: "value", min: 0, max: 1.4, axisLabel: { show: false }, splitLine: { show: false } }, dataZoom: [{ type: "inside" }, { type: "slider", height: 18, bottom: 8, borderColor: cssVar("--border") }], series, }, true); chartMotion.resize(); } async function loadUus67Latest() { try { const res = await fetch("/api/uus67_latest"); if (!res.ok) return; const data = await res.json(); renderUus67Cards(data); } catch (e) { console.error(e); } } function renderUus67Cards(data) { const host = document.getElementById("cards-uus67"); if (!host) return; const cards = []; for (const e of data.energy || []) { cards.push(`
⚡ ${e.device} · võimsus
${e.power ?? "–"} W
`); if (e.energy != null) { cards.push(`
🔌 ${e.device} · kWh
${e.energy} kWh
`); } } for (const m of data.motion || []) { cards.push(`
🚶 ${m.device}
${m.occupancy ? "liikumine" : "rahu"}
`); } host.innerHTML = cards.join(""); } // --- Autentimine --- function ensureAwayButton() { if (document.getElementById("away-toggle")) return; const actions = document.querySelector(".header-actions"); const authLink = document.getElementById("auth-link"); if (!actions || !authLink) return; const btn = document.createElement("button"); btn.type = "button"; btn.id = "away-toggle"; btn.className = "away-pill"; btn.setAttribute("aria-label", "Kodust eemal lüliti"); btn.innerHTML = '\ud83c\udfe0Kodus'; btn.addEventListener("click", toggleAway); actions.insertBefore(btn, authLink); } function applyAuth(authed) { state.authed = authed; if (authed) { ensureAwayButton(); loadAway(); } else { const awayBtn = document.getElementById("away-toggle"); if (awayBtn) awayBtn.remove(); } const prot = document.getElementById("protected"); if (prot) prot.hidden = !authed; const vp = document.getElementById("varv-power"); if (vp) vp.hidden = !authed; const vc = document.getElementById("varv-calib-panel"); if (vc) vc.hidden = !authed; const vccm = document.getElementById("varv-ccm-panel"); if (vccm) vccm.hidden = !authed; if (authed) { initVarvPowerButtons(); loadVarvPower(); initVarvCalibButtons(); loadVarvCalib(); initVarvCcmButtons(); loadVarvCcm(); } const link = document.getElementById("auth-link"); if (link) { link.href = authed ? "/logout" : "/login"; link.title = authed ? "Logi välja" : "Sisselogimine"; link.textContent = authed ? "\ud83d\udd13" : "\ud83d\udd12"; } } async function checkAuth() { try { const res = await fetch("/api/me"); if (!res.ok) { applyAuth(false); return; } const data = await res.json(); applyAuth(!!data.authed); } catch { applyAuth(false); } } async function loadProtected() { if (!state.authed) return; loadEnergy(); loadMotion(); loadUus67Latest(); } // --- Kodust eemal (HA input_boolean) --- let awayState = null; async function loadAway() { if (!state.authed) return; try { const res = await fetch("/api/away"); if (!res.ok) return; const d = await res.json(); awayState = d.away; renderAway(d.away, d.available); } catch (e) { console.error(e); } } function renderAway(away, available) { const btn = document.getElementById("away-toggle"); if (!btn) return; const ico = btn.querySelector(".away-ico"); const title = document.getElementById("away-title"); if (away === true) { btn.classList.add("away-on"); if (ico) ico.textContent = "🚨"; if (title) title.textContent = "Eemal"; } else { btn.classList.remove("away-on"); if (ico) ico.textContent = "🏠"; if (title) title.textContent = "Kodus"; if (available === false && title) title.textContent = "HA offline"; } } async function toggleAway() { const on = !(awayState === true); awayState = on; renderAway(on, true); try { const res = await fetch("/api/away", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ on }), }); if (!res.ok) throw new Error("HTTP " + res.status); const d = await res.json(); awayState = d.away; renderAway(d.away, true); } catch (e) { console.error(e); loadAway(); } } // --- Värvisensor (AS7341, uus67.varvus) --- let varvActive = false; let chartVarvSpectrum = null; let chartVarvChroma = null; let varvLatest = []; let varvSelected = 0; const VARV_BANDS = [ ["f1", "415 nm", "#7b2ff7"], ["f2", "445 nm", "#3b5bdb"], ["f3", "480 nm", "#1c7ed6"], ["f4", "515 nm", "#2fb344"], ["f5", "555 nm", "#9acd32"], ["f6", "590 nm", "#f0b000"], ["f7", "630 nm", "#e8590c"], ["f8", "680 nm", "#c92a2a"], ]; async function loadVarv() { const hint = document.getElementById("varv-hint"); try { const [r1, r2] = await Promise.all([ fetch("/api/varv/latest?limit=60"), fetch("/api/varv/history?hours=24"), ]); if (!r1.ok || !r2.ok) throw new Error("HTTP " + r1.status + "/" + r2.status); varvLatest = (await r1.json()).rows || []; const history = (await r2.json()).rows || []; if (varvSelected >= varvLatest.length) varvSelected = 0; renderVarvCurrent(); renderVarvGrid(); renderVarvTimeline(history); renderVarvSpectrum(); renderVarvChroma(varvLatest); renderVarvCalibLive(); const upd = document.getElementById("varv-updated"); if (upd) upd.textContent = varvLatest.length ? "Viimane mõõtmine " + varvLatest[0].ts : "Andmeid pole"; if (hint) hint.hidden = true; } catch (e) { console.error(e); if (hint) { hint.hidden = false; hint.textContent = "Värviandmete laadimine ebaõnnestus."; } } } const varvFmt = (v) => (v == null ? "–" : Number(v).toFixed(3)); function renderVarvCurrent() { const sw = document.getElementById("varv-current-swatch"); const hex = document.getElementById("varv-hex"); const meta = document.getElementById("varv-current-meta"); if (!sw) return; if (!varvLatest.length) { sw.style.background = "transparent"; if (hex) hex.textContent = "#------"; if (meta) meta.textContent = "Andmeid pole."; return; } const c = varvLatest[0]; const p = varvPick(c); sw.style.background = p.hex; if (hex) hex.textContent = p.dark ? "PIME" : p.hex.toUpperCase(); if (meta) meta.innerHTML = `${c.ts}${varvCalibView ? " · kalibreeritud" : ""}` + (p.dark ? "
pime — värv ei ole usaldusväärne (valgusta pinda)" : `
x = ${varvFmt(p.x)} · y = ${varvFmt(p.y)} · gain ${c.gain}`); } function renderVarvGrid() { const grid = document.getElementById("varv-grid"); if (!grid) return; grid.innerHTML = varvLatest.map((c, i) => { const p = varvPick(c); return ``; }).join(""); grid.querySelectorAll(".swatch").forEach((el) => { el.addEventListener("click", () => { varvSelected = Number(el.dataset.i) || 0; renderVarvGrid(); renderVarvSpectrum(); }); }); } function renderVarvTimeline(rows) { const el = document.getElementById("varv-timeline"); if (!el) return; if (!rows.length) { el.innerHTML = 'Viimase 24 h andmeid pole.'; return; } el.innerHTML = rows.map((r) => { const d = new Date(r.t); const hh = pad(d.getHours()) + ":00"; return `
`; }).join(""); } function varvReflectance(c) { const d = varvCalibData && varvCalibData.dark; const w = varvCalibData && varvCalibData.white; if (!d || !w) return null; const out = {}; for (const b of VARV_BANDS) { const s = Number(c.bands[b[0]]) || 0; const dv = Number(d[b[0]]) || 0; const wv = Number(w[b[0]]) || 0; out[b[0]] = (wv - dv) > 0 ? Math.max(0, (s - dv) / (wv - dv)) : 0; } return out; } function renderVarvSpectrum() { const el = document.getElementById("chart-varv-spectrum"); if (!el || !varvLatest.length) { if (chartVarvSpectrum) chartVarvSpectrum.clear(); return; } const c = varvLatest[Math.min(varvSelected, varvLatest.length - 1)]; const refl = varvReflectance(c); const label = document.getElementById("varv-spectrum-label"); if (label) { label.textContent = refl ? `— ${c.ts} · ${((c.rgb_hex_cal || c.rgb_hex) || "").toUpperCase()} · reflekteeruvus %` : `— ${c.ts} · ${c.rgb_hex.toUpperCase()} · toored kanalid (valgusti-domineeritud)`; } if (!chartVarvSpectrum) chartVarvSpectrum = echarts.init(el, null, { renderer: "canvas" }); const data = VARV_BANDS.map((b) => ({ value: refl ? Math.round((refl[b[0]] || 0) * 1000) / 10 : (c.bands[b[0]] || 0), itemStyle: { color: b[2] }, })); chartVarvSpectrum.setOption({ grid: { left: 56, right: 16, top: 16, bottom: 32 }, xAxis: { type: "category", data: VARV_BANDS.map((b) => b[1]), axisLabel: { color: cssVar("--muted") }, }, yAxis: { type: "value", max: refl ? 100 : undefined, axisLabel: { color: cssVar("--muted"), formatter: refl ? "{value}%" : "{value}", }, splitLine: { lineStyle: { color: cssVar("--border") } }, }, tooltip: { trigger: "axis" }, series: [{ type: "bar", barWidth: "62%", data, }], }, true); chartVarvSpectrum.resize(); } function renderVarvChroma(rows) { const el = document.getElementById("chart-varv-chroma"); if (!el) return; if (!chartVarvChroma) chartVarvChroma = echarts.init(el, null, { renderer: "canvas" }); const pts = rows .filter((r) => r.x_chrom != null && r.y_chrom != null) .map((r) => [Number(r.x_chrom), Number(r.y_chrom), r.ts, r.rgb_hex]); chartVarvChroma.setOption({ grid: { left: 52, right: 24, top: 20, bottom: 40 }, xAxis: { type: "value", min: 0, max: 0.75, name: "x", nameTextStyle: { color: cssVar("--muted") }, axisLabel: { color: cssVar("--muted") }, splitLine: { lineStyle: { color: cssVar("--border") } }, }, yAxis: { type: "value", min: 0, max: 0.85, name: "y", nameTextStyle: { color: cssVar("--muted") }, axisLabel: { color: cssVar("--muted") }, splitLine: { lineStyle: { color: cssVar("--border") } }, }, tooltip: { formatter: (p) => { if (p.seriesName === "D65") return "valge punkt D65"; return `${p.value[3]}
x=${p.value[0].toFixed(3)} y=${p.value[1].toFixed(3)}
${p.value[2]}`; }, }, series: [ { name: "mõõtmised", type: "scatter", symbolSize: 11, data: pts, itemStyle: { color: "rgba(31,111,235,0.7)" }, }, { name: "D65", type: "scatter", symbol: "pin", symbolSize: 26, data: [[0.3127, 0.329]], itemStyle: { color: "#8b949e" }, }, ], }, true); chartVarvChroma.resize(); } // --- Värv: mõõtmise sisse/välja lüliti (ainult sisselogitult) --- let varvPowerBusy = false; let varvPowerWired = false; function initVarvPowerButtons() { if (varvPowerWired) return; const onBtn = document.getElementById("varv-on"); const offBtn = document.getElementById("varv-off"); if (onBtn) onBtn.addEventListener("click", () => setVarvPower(true)); if (offBtn) offBtn.addEventListener("click", () => setVarvPower(false)); varvPowerWired = true; } function renderVarvPower(d) { const st = document.getElementById("varv-power-status"); const onBtn = document.getElementById("varv-on"); const offBtn = document.getElementById("varv-off"); const on = d ? d.on : null; if (st) { if (on === true) { st.textContent = "SEES"; st.className = "badge ok"; } else if (on === false) { st.textContent = "VÄLJAS"; st.className = "badge err"; } else { st.textContent = "teadmata"; st.className = "badge muted"; } } if (onBtn) onBtn.disabled = varvPowerBusy || on === true; if (offBtn) offBtn.disabled = varvPowerBusy || on === false; } async function loadVarvPower() { if (!state.authed) return; try { const res = await fetch("/api/varv/power"); if (!res.ok) throw new Error("HTTP " + res.status); renderVarvPower(await res.json()); } catch (e) { console.error(e); renderVarvPower({ on: null }); } } async function setVarvPower(on) { if (!state.authed || varvPowerBusy) return; varvPowerBusy = true; const hint = document.getElementById("varv-power-hint"); renderVarvPower({ on }); try { const res = await fetch("/api/varv/power", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ on }), }); if (!res.ok) throw new Error("HTTP " + res.status); renderVarvPower(await res.json()); if (hint) { hint.hidden = false; hint.textContent = on ? "Mõõtmine sisse lülitatud." : "Mõõtmine välja lülitatud — ClickHouse'i ei kirjutata."; setTimeout(() => { hint.hidden = true; }, 5000); } } catch (e) { console.error(e); if (hint) { hint.hidden = false; hint.textContent = "Lülitamine ebaõnnestus."; } } finally { varvPowerBusy = false; loadVarvPower(); } } // --- Värv: kalibreerimine (tume/valge; nupud ainult sisselogitult) --- let varvCalibView = false; let varvCalibData = null; let varvCalibWired = false; function varvBandSum(c) { return Object.values(c.bands || {}).reduce((a, v) => a + (Number(v) || 0), 0); } function varvPick(c) { if (varvBandSum(c) < 250) return { hex: "#33383f", x: null, y: null, dark: true }; if (varvCalibView && c.rgb_hex_ccm) { return { hex: c.rgb_hex_ccm, x: c.x_chrom_cal, y: c.y_chrom_cal }; } if (varvCalibView && c.rgb_hex_cal) { return { hex: c.rgb_hex_cal, x: c.x_chrom_cal, y: c.y_chrom_cal }; } return { hex: c.rgb_hex, x: c.x_chrom, y: c.y_chrom }; } function initVarvCalibButtons() { if (varvCalibWired) return; const d = document.getElementById("varv-calib-dark"); const w = document.getElementById("varv-calib-white"); const t = document.getElementById("varv-calib-view"); if (d) d.addEventListener("click", () => captureVarvCalib("dark")); if (w) w.addEventListener("click", () => captureVarvCalib("white")); if (t) t.addEventListener("change", (e) => { varvCalibView = e.target.checked; renderVarvCurrent(); renderVarvGrid(); }); varvCalibWired = true; } function renderVarvCalib(d) { varvCalibData = d; const st = document.getElementById("varv-calib-status"); if (st) { const haveD = !!d.dark; const haveW = !!d.white; if (haveD && haveW) { st.textContent = "valmis"; st.className = "badge ok"; } else { st.textContent = `${haveD ? "tume ✓" : "tume ✗"} / ${haveW ? "valge ✓" : "valge ✗"}`; st.className = "badge warn"; } } const view = document.getElementById("varv-calib-view"); if (view) view.disabled = !(d.dark && d.white); const lbl = document.getElementById("varv-calib-view-label"); if (lbl) { lbl.textContent = (d.dark && d.white) ? "Kuva kalibreeritud värvi" : "Kuva kalibreeritud värvi (vaja tume + valge)"; } renderVarvSpectrum(); } async function loadVarvCalib() { try { const res = await fetch("/api/varv/calib"); if (!res.ok) throw new Error("HTTP " + res.status); renderVarvCalib(await res.json()); } catch (e) { console.error(e); } } function renderVarvCalibLive() { const el = document.getElementById("varv-calib-live"); if (!el) return; if (!varvLatest.length) { el.textContent = ""; return; } const bandSum = (r) => Object.values(r.bands || {}).reduce((a, v) => a + (Number(v) || 0), 0); const c = varvLatest[0]; const sum = bandSum(c); const sums = varvLatest.slice(0, 5).map(bandSum); const mx = Math.max(...sums); const mn = Math.min(...sums); const unstable = mx > 0 && (mx - mn) / mx > 0.2; let light; if (sum < 50) light = "PIME — valge jaoks vaja valgust"; else if (sum < 200) light = "hämar"; else light = "HELE"; const st = unstable ? "MUUTUB (oota stabiliseerumist)" : "stabiilne ✓"; el.textContent = `Viimane: ${(c.rgb_hex || "").toUpperCase()} · heledus ${Math.round(sum)} · ${light} · ${st}`; } async function captureVarvCalib(kind) { if (!state.authed) return; const hint = document.getElementById("varv-calib-hint"); try { const res = await fetch("/api/varv/calib", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind, last: 5 }), }); if (!res.ok) { let msg = `Salvestamine ebaõnnestus (HTTP ${res.status}).`; try { const e = await res.json(); if (typeof e.detail === "string") msg = e.detail; } catch (ignored) { /* ignore */ } throw new Error(msg); } const d = await res.json(); if (hint) { hint.hidden = false; hint.textContent = `Salvestatud: ${kind} (${d.rows} mõõtmist).`; setTimeout(() => { hint.hidden = true; }, 4500); } await loadVarvCalib(); loadVarv(); } catch (e) { console.error(e); if (hint) { hint.hidden = false; hint.textContent = e.message || "Salvestamine ebaõnnestus."; } } } // --- Värv: CCM (värvikaart) kalibratsioon --- let varvCcmWired = false; function initVarvCcmButtons() { if (varvCcmWired) return; document.querySelectorAll("[data-ref]").forEach((btn) => { btn.addEventListener("click", () => captureVarvRef(btn.dataset.ref, btn.dataset.hex)); }); const fit = document.getElementById("varv-ccm-fit"); if (fit) fit.addEventListener("click", fitVarvCcm); const colEl = document.getElementById("varv-ccm-color"); const hexEl = document.getElementById("varv-ccm-hex"); const prevEl = document.getElementById("varv-ccm-preview"); const capEl = document.getElementById("varv-ccm-capture"); const applyColor = (raw) => { const h = String(raw || "").replace("#", "").trim().toUpperCase(); if (!/^[0-9A-F]{6}$/.test(h)) return; if (prevEl) prevEl.style.background = "#" + h; if (colEl) colEl.value = "#" + h.toLowerCase(); if (hexEl) hexEl.value = h; }; if (colEl) colEl.addEventListener("input", () => applyColor(colEl.value)); if (hexEl) hexEl.addEventListener("change", () => applyColor(hexEl.value)); if (capEl) capEl.addEventListener("click", () => { const h = (hexEl ? hexEl.value : "").replace("#", "").trim().toUpperCase(); if (!/^[0-9A-F]{6}$/.test(h)) return; captureVarvRef("c" + h.toLowerCase(), h); }); varvCcmWired = true; } function renderVarvCcm(ccm, refs) { const st = document.getElementById("varv-ccm-status"); if (st) { if (ccm && ccm.ready) { st.textContent = "maatriks olemas"; st.className = "badge ok"; } else { st.textContent = "maatriks puudub"; st.className = "badge warn"; } } const list = document.getElementById("varv-ccm-list"); const names = (refs && refs.refs ? refs.refs : []).map((r) => r.name); if (list) list.textContent = names.length ? "Salvestatud: " + names.join(", ") : "Ühtegi referentsi pole veel."; } async function loadVarvCcm() { try { const [r1, r2] = await Promise.all([fetch("/api/varv/ccm"), fetch("/api/varv/ref")]); if (!r1.ok || !r2.ok) return; renderVarvCcm(await r1.json(), await r2.json()); } catch (e) { console.error(e); } } async function captureVarvRef(name, hex) { if (!state.authed) return; const hint = document.getElementById("varv-ccm-hint"); try { const res = await fetch("/api/varv/ref", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, hex, last: 5 }), }); if (!res.ok) { let msg = "Salvestamine ebaõnnestus."; try { const e = await res.json(); if (typeof e.detail === "string") msg = e.detail; } catch (ignored) { /* ignore */ } throw new Error(msg); } if (hint) hint.textContent = `Salvestatud: ${name} (${hex})`; loadVarvCcm(); } catch (e) { console.error(e); if (hint) hint.textContent = e.message || "Viga"; } } async function fitVarvCcm() { if (!state.authed) return; const hint = document.getElementById("varv-ccm-hint"); try { const res = await fetch("/api/varv/ccm/fit", { method: "POST" }); if (!res.ok) { let msg = "Sobitamine ebaõnnestus."; try { const e = await res.json(); if (typeof e.detail === "string") msg = e.detail; } catch (ignored) { /* ignore */ } throw new Error(msg); } const d = await res.json(); if (hint) hint.textContent = `Maatriks arvutatud (${d.n_refs} värvi).`; loadVarvCcm(); loadVarv(); } catch (e) { console.error(e); if (hint) hint.textContent = e.message || "Viga"; } } window.addEventListener("resize", () => { if (chartVarvSpectrum) chartVarvSpectrum.resize(); if (chartVarvChroma) chartVarvChroma.resize(); }); // --- Vahelehed (tabid): Andurid | Transport | Värv --- const transport = { active: false, days: 1 }; const VIEWS = ["andurid", "transport", "varv"]; function switchView(view) { if (!VIEWS.includes(view)) view = "andurid"; document.querySelectorAll("#tabs .tab").forEach((b) => { b.classList.toggle("active", b.dataset.view === view); }); for (const v of VIEWS) { const el = document.getElementById("view-" + v); if (el) el.hidden = v !== view; } transport.active = view === "transport"; varvActive = view === "varv"; if (location.hash !== "#" + view) history.replaceState(null, "", "#" + view); if (view === "transport") { loadTransport(); } else if (view === "varv") { loadVarv(); if (state.authed) { loadVarvPower(); loadVarvCalib(); loadVarvCcm(); } } else { if (chart) chart.resize(); if (chartEnergy) chartEnergy.resize(); if (chartMotion) chartMotion.resize(); } } function initTabs() { document.querySelectorAll("#tabs .tab").forEach((btn) => { btn.addEventListener("click", () => switchView(btn.dataset.view)); }); const days = document.getElementById("praam-days"); if (days) { days.querySelectorAll("button").forEach((b) => { b.addEventListener("click", () => { days.querySelectorAll("button").forEach((x) => x.classList.remove("active")); b.classList.add("active"); transport.days = Number(b.dataset.days) || 1; loadPraam(); }); }); } setInterval(() => { if (transport.active) loadTransport(); }, 60000); setInterval(() => { if (varvActive) { loadVarv(); if (state.authed) { loadVarvCalib(); loadVarvCcm(); } } }, 20000); switchView(VIEWS.includes(location.hash.slice(1)) ? location.hash.slice(1) : "andurid"); } function loadTransport() { loadBus(); loadPraam(); } function fmtDayLabel(iso, today) { if (!iso || iso === today) return ""; const d = new Date(iso + "T00:00:00"); const t = new Date((today || iso) + "T00:00:00"); const diff = Math.round((d - t) / 864e5); if (diff === 1) return "homme"; return d.toLocaleDateString("et-EE", { weekday: "short", day: "numeric", month: "short" }); } function setTransportUpdated(updated) { const el = document.getElementById("transport-updated"); if (el && updated) el.textContent = `Uuendatud ${updated}`; } async function loadBus() { const hint = document.getElementById("bus-hint"); try { const res = await fetch("/api/buss?limit=12"); if (!res.ok) throw new Error("HTTP " + res.status); renderBus(await res.json()); } catch (e) { console.error(e); if (hint) { hint.hidden = false; hint.textContent = "Bussiaegade laadimine ebaõnnestus."; } } } function renderBus(data) { const list = document.getElementById("bus-list"); const hint = document.getElementById("bus-hint"); if (!list) return; const rows = data.departures || []; if (!rows.length) { list.innerHTML = ""; if (hint) { hint.hidden = false; hint.textContent = "Täna enam busse ei välju."; } return; } if (hint) hint.hidden = true; list.innerHTML = rows.map((d) => { const day = fmtDayLabel(d.date, data.today); const dayBadge = day ? `${day}` : ""; let rt = ""; if (d.realtime && d.delay_min != null && d.delay_min !== 0) { rt = d.delay_min > 0 ? `+${d.delay_min} min` : `${d.delay_min} min`; } return `
${d.time} ${d.route || ""} → ${d.headsign || "?"} ${rt}${dayBadge}
`; }).join(""); } async function loadPraam() { const hint = document.getElementById("praam-hint"); try { const res = await fetch(`/api/praam?days=${transport.days}`); if (!res.ok) throw new Error("HTTP " + res.status); renderPraam(await res.json()); } catch (e) { console.error(e); if (hint) { hint.hidden = false; hint.textContent = "Praamiaegade laadimine ebaõnnestus."; } } } function renderPraam(data) { const list = document.getElementById("praam-list"); const hint = document.getElementById("praam-hint"); const warn = document.getElementById("praam-warning"); const rows = data.departures || []; if (warn) { if (Array.isArray(data.warnings) && data.warnings.length) { warn.hidden = false; warn.innerHTML = "⚠️ Ilmahoiatuse kontekst (Väinameri)" + data.warnings.map((w) => `
• ${w.area}: ${w.valid || w.text}
`).join("") + `
Praamiliiklus võib häiruda — kontrolli ` + `praamid.ee.
`; } else { warn.hidden = true; } } if (!rows.length) { list.innerHTML = ""; if (hint) { hint.hidden = false; hint.textContent = "Väljumisi ei leitud."; } return; } if (hint) hint.hidden = true; setTransportUpdated(data.updated); const groups = {}; for (const d of rows) (groups[d.direction] = groups[d.direction] || []).push(d); list.innerHTML = ["HR", "RH"].filter((k) => groups[k]).map((k) => { const items = groups[k].map((d) => { const day = fmtDayLabel(d.date, data.today); const dayBadge = day ? `${day}` : ""; const st = d.disrupted ? `${d.status_et}` : `${d.status_et}`; return `
${d.time} ${d.ship || ""} ${st}${dayBadge}
`; }).join(""); return `
${groups[k][0].direction_name}
${items}
`; }).join(""); } document.addEventListener("DOMContentLoaded", init);