Browse Source

nutikodu-web: uus Varv vaheleht (AS7341 varviandur)

- /api/varv/latest + /api/varv/history (uus67.varvus, avalik)

- UI: praegune varv, varviruutude ruudustik, spekter (f1..f8), varvitelg (24h), kromaatilisus (x,y)
ardo 1 tuần trước cách đây
mục cha
commit
62c2d48e86

+ 62 - 0
dev/nutikodu-web/app.py

@@ -532,6 +532,68 @@ async def uus67_latest() -> dict:
     }
 
 
+# --- Värvisensor (uus67.varvus, AS7341) ---
+
+VARV_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8")
+
+
+@app.get("/api/varv/latest")
+async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
+    """Värvisensori viimased mõõtmised (avalik)."""
+    cols = ", ".join(VARV_BANDS)
+    rows = ch_query(
+        f"SELECT toString(ts) AS ts, toUInt64(toUnixTimestamp(ts)) * 1000 AS t, "
+        f"rgb_hex, r, g, b, round(x_chrom, 4) AS x_chrom, round(y_chrom, 4) AS y_chrom, "
+        f"{cols}, clear, nir, gain "
+        f"FROM {CH_DB}.varvus ORDER BY ts DESC LIMIT {int(limit)}"
+    )
+    return {
+        "rows": [
+            {
+                "ts": r["ts"],
+                "t": int(r["t"]),
+                "rgb_hex": r.get("rgb_hex") or "#000000",
+                "r": int(r.get("r") or 0),
+                "g": int(r.get("g") or 0),
+                "b": int(r.get("b") or 0),
+                "x_chrom": r.get("x_chrom"),
+                "y_chrom": r.get("y_chrom"),
+                "bands": {b: r.get(b) for b in VARV_BANDS},
+                "clear": r.get("clear"),
+                "nir": r.get("nir"),
+                "gain": r.get("gain"),
+            }
+            for r in rows
+        ]
+    }
+
+
+@app.get("/api/varv/history")
+async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
+    """Värvitelg: tunnipõhine keskmine värv viimaste tundide jooksul (avalik)."""
+    rows = ch_query(
+        f"SELECT toString(toStartOfHour(ts)) AS bucket, "
+        f"toUInt64(toUnixTimestamp(toStartOfHour(ts))) * 1000 AS t, "
+        f"toUInt8(round(avg(r))) AS r, toUInt8(round(avg(g))) AS g, toUInt8(round(avg(b))) AS b, "
+        f"round(avg(x_chrom), 4) AS x_chrom, round(avg(y_chrom), 4) AS y_chrom, count() AS n "
+        f"FROM {CH_DB}.varvus WHERE ts >= now() - INTERVAL {int(hours)} HOUR "
+        f"GROUP BY toStartOfHour(ts) ORDER BY t"
+    )
+    out = []
+    for r in rows:
+        rv, gv, bv = int(r.get("r") or 0), int(r.get("g") or 0), int(r.get("b") or 0)
+        out.append({
+            "ts": r["bucket"],
+            "t": int(r["t"]),
+            "r": rv, "g": gv, "b": bv,
+            "rgb_hex": f"#{rv:02X}{gv:02X}{bv:02X}",
+            "x_chrom": r.get("x_chrom"),
+            "y_chrom": r.get("y_chrom"),
+            "n": int(r.get("n") or 0),
+        })
+    return {"hours": hours, "rows": out}
+
+
 @app.get("/api/latest")
 async def latest(request: Request) -> dict:
     readings = ch_query(

+ 196 - 9
dev/nutikodu-web/static/app.js

@@ -616,22 +616,206 @@ async function toggleAway() {
   }
 }
 
-// --- Vahelehed (tabid): Andurid | Transport ---
+// --- 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);
+    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];
+  sw.style.background = c.rgb_hex;
+  if (hex) hex.textContent = c.rgb_hex.toUpperCase();
+  if (meta) meta.innerHTML =
+    `${c.ts}<br>RGB ${c.r} · ${c.g} · ${c.b}<br>` +
+    `x = ${varvFmt(c.x_chrom)} · y = ${varvFmt(c.y_chrom)} · gain ${c.gain}`;
+}
+
+function renderVarvGrid() {
+  const grid = document.getElementById("varv-grid");
+  if (!grid) return;
+  grid.innerHTML = varvLatest.map((c, i) =>
+    `<button type="button" class="swatch${i === varvSelected ? " sel" : ""}" ` +
+    `style="background:${c.rgb_hex}" title="${c.ts}  ${c.rgb_hex}" data-i="${i}"></button>`
+  ).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 = '<span class="varv-sub">Viimase 24 h andmeid pole.</span>';
+    return;
+  }
+  el.innerHTML = rows.map((r) => {
+    const d = new Date(r.t);
+    const hh = pad(d.getHours()) + ":00";
+    return `<div class="swatch tl" style="background:${r.rgb_hex}" ` +
+      `title="${r.ts.slice(0, 10)} ${hh}  ${r.rgb_hex} · ${r.n} mõõtmist"></div>`;
+  }).join("");
+}
+
+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 label = document.getElementById("varv-spectrum-label");
+  if (label) label.textContent = `— ${c.ts} · ${c.rgb_hex.toUpperCase()}`;
+  if (!chartVarvSpectrum) chartVarvSpectrum = echarts.init(el, null, { renderer: "canvas" });
+  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",
+      axisLabel: { color: cssVar("--muted") },
+      splitLine: { lineStyle: { color: cssVar("--border") } },
+    },
+    tooltip: { trigger: "axis" },
+    series: [{
+      type: "bar",
+      barWidth: "62%",
+      data: VARV_BANDS.map((b) => ({
+        value: c.bands[b[0]] || 0,
+        itemStyle: { color: b[2] },
+      })),
+    }],
+  }, 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]}<br>x=${p.value[0].toFixed(3)} y=${p.value[1].toFixed(3)}<br>${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();
+}
+
+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) {
-  const isTransport = view === "transport";
+  if (!VIEWS.includes(view)) view = "andurid";
   document.querySelectorAll("#tabs .tab").forEach((b) => {
     b.classList.toggle("active", b.dataset.view === view);
   });
-  const vAnd = document.getElementById("view-andurid");
-  const vTr = document.getElementById("view-transport");
-  if (vAnd) vAnd.hidden = isTransport;
-  if (vTr) vTr.hidden = !isTransport;
-  transport.active = isTransport;
+  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 (isTransport) {
+  if (view === "transport") {
     loadTransport();
+  } else if (view === "varv") {
+    loadVarv();
   } else {
     if (chart) chart.resize();
     if (chartEnergy) chartEnergy.resize();
@@ -657,7 +841,10 @@ function initTabs() {
   setInterval(() => {
     if (transport.active) loadTransport();
   }, 60000);
-  switchView(location.hash === "#transport" ? "transport" : "andurid");
+  setInterval(() => {
+    if (varvActive) loadVarv();
+  }, 20000);
+  switchView(VIEWS.includes(location.hash.slice(1)) ? location.hash.slice(1) : "andurid");
 }
 
 function loadTransport() {

+ 38 - 0
dev/nutikodu-web/static/index.html

@@ -31,6 +31,7 @@
     <nav class="tabs" id="tabs" aria-label="Vahelehed">
       <button type="button" class="tab active" data-view="andurid">🏠 Andurid</button>
       <button type="button" class="tab" data-view="transport">🚌 Transport</button>
+      <button type="button" class="tab" data-view="varv">🎨 Värv</button>
     </nav>
 
     <div id="view-andurid" class="view">
@@ -110,6 +111,43 @@
         <span id="transport-updated"></span>
       </footer>
     </div>
+
+    <div id="view-varv" class="view" hidden>
+      <section class="panel">
+        <div class="varv-current">
+          <div id="varv-current-swatch" class="varv-current-swatch"></div>
+          <div class="varv-current-info">
+            <div class="varv-hex" id="varv-hex">#------</div>
+            <div class="varv-meta" id="varv-current-meta">Laadin…</div>
+          </div>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">🎨 Viimased mõõtmised</h2>
+        <div id="varv-grid" class="swatch-grid"></div>
+        <div id="varv-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">📊 Spekter <span class="varv-sub" id="varv-spectrum-label"></span></h2>
+        <div id="chart-varv-spectrum" style="height:280px" aria-label="Kanali spekter"></div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">🕓 Värvitelg (viimased 24 h)</h2>
+        <div id="varv-timeline" class="swatch-timeline"></div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">🧭 Kromaatilisus (x, y)</h2>
+        <div id="chart-varv-chroma" style="height:340px" aria-label="Kromaatilisuse hajuvus"></div>
+      </section>
+
+      <footer class="foot">
+        <span id="varv-updated"></span>
+      </footer>
+    </div>
   </main>
 
   <script src="/vendor/echarts.min.js"></script>

+ 31 - 0
dev/nutikodu-web/static/style.css

@@ -213,3 +213,34 @@ main > * { min-width: 0; }
   .dep-time { min-width: 44px; font-size: 15px; }
   .dep-route { min-width: 0; }
 }
+
+/* --- Värvisensor (varv) --- */
+.varv-current { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
+.varv-current-swatch {
+  width: 110px; height: 110px; border-radius: 14px;
+  border: 1px solid var(--border); flex: 0 0 auto; box-shadow: var(--shadow);
+}
+.varv-current-info { min-width: 0; }
+.varv-hex {
+  font-size: 26px; font-weight: 700; letter-spacing: .5px;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+.varv-meta { color: var(--muted); font-size: 13px; margin-top: 6px; line-height: 1.6; }
+.varv-sub { color: var(--muted); font-size: 13px; font-weight: 400; }
+
+.swatch-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(54px, 1fr)); gap: 6px; }
+.swatch {
+  aspect-ratio: 1 / 1; width: 100%; border-radius: 8px; border: 1px solid var(--border);
+  cursor: pointer; padding: 0; transition: transform .08s ease;
+}
+.swatch:active { transform: scale(.95); }
+.swatch.sel { outline: 2px solid var(--accent); outline-offset: 1px; }
+
+.swatch-timeline { display: flex; flex-wrap: wrap; gap: 4px; }
+.swatch-timeline .swatch { width: 30px; height: 30px; aspect-ratio: auto; cursor: default; }
+
+@media (max-width: 520px) {
+  .varv-current-swatch { width: 84px; height: 84px; }
+  .varv-hex { font-size: 22px; }
+  .swatch-grid { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); }
+}