signalk-czone 0.3.0-beta.19 → 0.3.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0-beta.21
4
+
5
+ - Includes ZCF Favorites in the circuit favorites list, counts and stars by default. Browser additions and removals persist as local overrides across reloads and ZCF updates.
6
+
7
+ ## 0.3.0-beta.20
8
+
9
+ - Shows individual monitoring readings as live for three minutes, then keeps their last values dimmed with their age; distinguishes stale rows from no data (#38, thanks @superstar76).
10
+ - Reconstructs earlier DC power trends from recorded voltage and current while preserving directly recorded power history.
11
+
3
12
  ## 0.3.0-beta.19
4
13
 
5
14
  - Adds opt-in Monitoring for meters, tanks, temperatures, humidity, pressure and switch inputs, with expanded trends and circuit temperature chips (#36, thanks @superstar76).
package/README.md CHANGED
@@ -202,7 +202,7 @@ Off by default: tick **Show the Monitoring tab** in the configuration panel to t
202
202
  | Environment | pressure senders | 130314 |
203
203
  | Inputs | switch inputs | (see Switch inputs) |
204
204
 
205
- A reading is matched by the NMEA 2000 instance set in the Configuration Tool, and the temperature or pressure source or tank fluid type. Each row says **LIVE**, or **NOT ON BUS** with the instance nothing is sending ("Nothing is sending instance 3 on NMEA 2000"), which is the number to set on the sending device. NMEA 2000 "not available" values are never shown as readings.
205
+ A reading is matched by the NMEA 2000 instance set in the Configuration Tool, and the temperature or pressure source or tank fluid type. Each reading is **LIVE** while it has updated in the last 3 minutes. After that its last value stays, dimmed, with its age ("Charge · 5 min ago"), counting up; a row with nothing live says when it was last updated. A row that has never had a reading says **No data received** with its instance ("No data received · instance 3"), which is the number to set on the sending device. NMEA 2000 "not available" values are never shown as readings.
206
206
 
207
207
  **Which device.** Instances are not unique across devices: a Victron charger sends a battery status on the battery's instance as well as the shunt, and a wired meter can share an instance with another device. So each reading comes from one device:
208
208
 
@@ -211,7 +211,7 @@ A reading is matched by the NMEA 2000 instance set in the Configuration Tool, an
211
211
  3. A third-party DC meter: where devices say what they are (127506), only one of the meter's DC type is used, and for a battery one that reports a state of charge beats one that does not, so a shunt beats a charger. Ties go to the lowest address, so the choice is the same after a restart.
212
212
  4. Otherwise the first device heard keeps the reading until it has been quiet for two minutes.
213
213
 
214
- Values are published under `electrical.czone.monitoring.<name>.*`, as the meters already were: `voltage`, `current`, `power`, `capacity.stateOfCharge`, `frequency`, `temperature`, `relativeHumidity`, `pressure`, `currentLevel`, `currentVolume`, `capacity`. **DC power** (watts) is volts × amps of the same 127508 message, so solar, alternator and wind output can be seen and trended.
214
+ Values are published under `electrical.czone.monitoring.<name>.*`, as the meters already were: `voltage`, `current`, `power`, `capacity.stateOfCharge`, `frequency`, `temperature`, `relativeHumidity`, `pressure`, `currentLevel`, `currentVolume`, `capacity`. **DC power** (watts) is volts × amps of the same 127508 message, so solar, alternator and wind output can be seen and trended. Its trend goes back as far as the meter's volts and amps: before power was first recorded, the chart works it out from them (the latest of each at that time; for long periods, from their averages, with the band from their ranges). Where power itself was recorded, those exact figures are used.
215
215
 
216
216
  **Senders bridged onto NMEA 2000 by the same machine.** Signal K does not pass a plugin its own transmissions, or a GX's:
217
217
 
@@ -548,3 +548,7 @@ Certificate installation and app installation menus vary by device and browser.
548
548
 
549
549
  The app still needs a live Signal K connection. No offline caching or service
550
550
  worker is installed, and installing it does not grant circuit-write permission.
551
+
552
+ ### Circuit favorites
553
+
554
+ Circuits marked Favorites in the loaded ZCF appear as favorites by default. The star buttons customize favorites for the current browser: you can add other circuits or hide a ZCF favorite. These choices survive browser and server restarts and apply when the ZCF is reloaded or updated. They are stored in browser site data, do not change the ZCF, and are independent on each browser/device. Clearing site data restores the ZCF defaults.
package/index.js CHANGED
@@ -10,6 +10,7 @@ const monitoring = require('./lib/monitoring')
10
10
  const sensors = require('./lib/sensors')
11
11
  const { createCandump, PGNS: CANDUMP_PGNS } = require('./lib/candump')
12
12
  const { createSourcePicker } = require('./lib/sources')
13
+ const { powerHistory } = require('./lib/power-history')
13
14
  const { createTrends } = require('./lib/trends')
14
15
  const signalk = require('./lib/signalk')
15
16
  const confirmOffLib = require('./lib/confirm-off')
@@ -24,7 +25,9 @@ const CZONE_CONFIG_BLOCK_SIZE = 200
24
25
  const CZONE_CONFIG_READ_TIMEOUT_MS = 45000
25
26
  const PLUGIN_ID = 'signalk-czone'
26
27
  // A monitored value older than this is no longer live (NOT ON BUS).
27
- const MONITOR_LIVE_MS = 120e3
28
+ // A reading is LIVE while it has updated within this long; after that it is
29
+ // shown dimmed with its age ("Updated 5 min ago").
30
+ const MONITOR_LIVE_MS = 180e3
28
31
  // A circuit's temperature is dropped after this long without a reading.
29
32
  const TEMPERATURE_STALE_MS = 300e3
30
33
  const MONITOR_GROUPS = { battery: 'Batteries', solar: 'Solar', alternator: 'Alternators', converter: 'Converters', wind: 'Wind Generators' }
@@ -1027,7 +1030,7 @@ module.exports = function (app) {
1027
1030
  const now = Date.now()
1028
1031
  const reading = (path, field) => {
1029
1032
  const v = monitorValues.get(path)
1030
- return { field, path, value: v ? v.value : null, at: v ? new Date(v.at).toISOString() : null, live: !!v && now - v.at < MONITOR_LIVE_MS }
1033
+ return { field, path, value: v ? v.value : null, at: v ? new Date(v.at).toISOString() : null, ageMs: v ? now - v.at : null, live: !!v && now - v.at < MONITOR_LIVE_MS }
1031
1034
  }
1032
1035
  const item = (group, kind, x, fields) => {
1033
1036
  const readings = fields.filter(f => x.signalK[f]).map(f => reading(x.signalK[f], f))
@@ -1727,7 +1730,13 @@ module.exports = function (app) {
1727
1730
  if (!trends) return res.json({ available: false, reason: 'not_started', data: [] })
1728
1731
  const q = req.query
1729
1732
  const custom = q.from !== undefined && q.to !== undefined
1730
- res.json(trends.read(skPath, custom ? { from: Number(q.from), to: Number(q.to) } : q.range))
1733
+ const range = custom ? { from: Number(q.from), to: Number(q.to) } : q.range
1734
+ // A DC meter's power: before it was first recorded, worked out from
1735
+ // the meter's recorded volts and amps (lib/power-history).
1736
+ const m = /^electrical\.czone\.monitoring\.(.+)\.power$/.exec(skPath)
1737
+ const meter = m && monitoringRuntime ? monitoringRuntime.meters.find(x => x.slug === m[1] && x.type === 'DC') : null
1738
+ if (meter) return res.json(powerHistory(trends.read(skPath, range), trends.read(meter.signalK.voltage, range), trends.read(meter.signalK.current, range)))
1739
+ res.json(trends.read(skPath, range))
1731
1740
  })
1732
1741
 
1733
1742
  router.get('/trend/status', (_req, res) => {
@@ -0,0 +1,66 @@
1
+ 'use strict'
2
+
3
+ // DC power history for a chart.
4
+ //
5
+ // Live DC power is volts x amps of one 127508 message (lib/monitoring), and
6
+ // it is recorded like any other value. Before it was recorded (it is new in
7
+ // 0.3.0-beta.19) the meter's volts and amps were already being recorded, so
8
+ // the chart works that earlier part out from them:
9
+ // - each point is volts x amps, the latest of each at that time, as long as
10
+ // neither is older than the gap the chart allows;
11
+ // - for summarised periods (rows of [t, average, min, max]) the value is
12
+ // the product of the averages and the band is the range the two bands
13
+ // allow, so it is close but not exact.
14
+ // Wherever power itself was recorded, those exact figures are used.
15
+ //
16
+ // Inputs and output are the trend store's read() results:
17
+ // { available, data: [[t, value]] or [[t, avg, min, max]], gapMs, ... }
18
+
19
+ const round1 = v => Math.round(v * 10) / 10
20
+ const low = row => (row.length > 2 && row[2] !== null ? row[2] : row[1])
21
+ const high = row => (row.length > 3 && row[3] !== null ? row[3] : row[1])
22
+
23
+ // volts x amps, for times before `until` (ms; Infinity = all).
24
+ function multiply (volts, amps, until = Infinity) {
25
+ const v = (volts && volts.data) || []
26
+ const a = (amps && amps.data) || []
27
+ const gap = Math.max(volts.gapMs || 0, amps.gapMs || 0) || 16 * 60e3
28
+ const times = [...new Set([...v.map(r => r[0]), ...a.map(r => r[0])])].filter(t => t < until).sort((x, y) => x - y)
29
+ const out = []
30
+ let i = 0
31
+ let j = 0
32
+ let rv = null
33
+ let ra = null
34
+ for (const t of times) {
35
+ while (i < v.length && v[i][0] <= t) rv = v[i++]
36
+ while (j < a.length && a[j][0] <= t) ra = a[j++]
37
+ if (!rv || !ra || t - rv[0] > gap || t - ra[0] > gap) continue
38
+ if (rv.length > 2 || ra.length > 2) {
39
+ const corners = [low(rv) * low(ra), low(rv) * high(ra), high(rv) * low(ra), high(rv) * high(ra)]
40
+ out.push([t, round1(rv[1] * ra[1]), round1(Math.min(...corners)), round1(Math.max(...corners))])
41
+ } else {
42
+ out.push([t, round1(rv[1] * ra[1])])
43
+ }
44
+ }
45
+ return out
46
+ }
47
+
48
+ // power, volts, amps: read() results for the same range.
49
+ function powerHistory (power, volts, amps) {
50
+ if (!power || !power.available || power.error) return power
51
+ if (!volts || !volts.available || !amps || !amps.available) return power
52
+ const recorded = Array.isArray(power.data) ? power.data : []
53
+ const first = recorded.length ? recorded[0][0] : Infinity
54
+ const before = multiply(volts, amps, first)
55
+ if (!before.length) return power
56
+ return {
57
+ ...power,
58
+ data: before.concat(recorded),
59
+ points: before.length + recorded.length,
60
+ gapMs: Math.max(power.gapMs || 0, volts.gapMs || 0, amps.gapMs || 0) || undefined,
61
+ // Up to here the figures are worked out from volts x amps.
62
+ computedUntil: before[before.length - 1][0]
63
+ }
64
+ }
65
+
66
+ module.exports = { powerHistory, multiply }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-czone",
3
- "version": "0.3.0-beta.19",
3
+ "version": "0.3.0-beta.21",
4
4
  "description": "BETA: Signal K CZone monitoring and control using dynamically parsed ZCF configuration and NMEA 2000",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -42,7 +42,7 @@
42
42
  "icon.png"
43
43
  ],
44
44
  "scripts": {
45
- "test": "node test/zcf.test.js && node test/zcf-fixtures.test.js && node test/circuit-state.test.js && node test/czone.test.js && node test/nmea2000.test.js && node test/control-x-plus.test.js && node test/signalk.test.js && node test/write.test.js && node test/circuit-status.test.js && node test/status-mapping-regression.test.js && node test/state.test.js && node test/config-network.test.js && node test/config-namespace.test.js && node test/monitoring.test.js && node test/sensors.test.js && node test/monitoring-plugin.test.js && node test/confirm-off.test.js && node test/webapp-current.test.js && node test/webapp-auth.test.js && node test/webapp-monitoring.test.js && node test/bridge.test.js && node test/victron-switches.test.js && node test/victron-label-steady.test.js && node test/switch-pane-plugin.test.js && node test/trends.test.js && node test/inputs.test.js && node --require ./test/helpers/gx-low-space.cjs test/trends.test.js"
45
+ "test": "node test/zcf.test.js && node test/zcf-fixtures.test.js && node test/circuit-state.test.js && node test/czone.test.js && node test/nmea2000.test.js && node test/control-x-plus.test.js && node test/signalk.test.js && node test/write.test.js && node test/circuit-status.test.js && node test/status-mapping-regression.test.js && node test/state.test.js && node test/config-network.test.js && node test/config-namespace.test.js && node test/monitoring.test.js && node test/sensors.test.js && node test/monitoring-plugin.test.js && node test/power-history.test.js && node test/confirm-off.test.js && node test/webapp-current.test.js && node test/webapp-favorites.test.js && node test/webapp-auth.test.js && node test/webapp-monitoring.test.js && node test/bridge.test.js && node test/victron-switches.test.js && node test/victron-label-steady.test.js && node test/switch-pane-plugin.test.js && node test/trends.test.js && node test/inputs.test.js && node --require ./test/helpers/gx-low-space.cjs test/trends.test.js"
46
46
  },
47
47
  "dependencies": {
48
48
  "signalk-czone-zcf": "0.1.0-beta.2"
package/public/index.html CHANGED
@@ -118,6 +118,9 @@
118
118
  .mon-val.on { border-color:rgba(46,213,115,.55); background:rgba(46,213,115,.12); }
119
119
  .mon-live { margin-left:10px; font-size:10px; font-weight:700; letter-spacing:.6px; color:#2ed573; white-space:nowrap; }
120
120
  .mon-row.off .mon-live { color:#ffb84d; }
121
+ /* A reading that has not updated for 3 minutes: dimmed, with its age. */
122
+ .mon-val.stale { opacity:.55; }
123
+ .mon-val.stale small { color:#ffb84d; }
121
124
  .temp-chip { display:inline-block; margin-left:8px; padding:1px 7px; border-radius:9px; border:1px solid rgba(24,213,232,.35); background:rgba(24,213,232,.08); color:var(--cyan); font:inherit; font-size:11px; font-weight:600; vertical-align:1px; }
122
125
  button.temp-chip { cursor:pointer; }
123
126
  @media (max-width: 600px) {
@@ -500,6 +503,10 @@
500
503
  const favoritesKey = 'signalk-czone-circuits:favorites';
501
504
  let favorites = new Set(JSON.parse(localStorage.getItem(favoritesKey) || '[]'));
502
505
 
506
+ // ZCF favorites are defaults; browser choices can add or hide them locally.
507
+ const hiddenFavoritesKey = `${favoritesKey}:hidden`;
508
+ let hiddenFavorites = new Set(JSON.parse(localStorage.getItem(hiddenFavoritesKey) || '[]'));
509
+
503
510
  const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
504
511
  const nameKey = c => c.slug || c.name;
505
512
  const stateOf = c => c.state && c.state.state === 'ON';
@@ -510,6 +517,7 @@
510
517
  return current != null && current > 0 ? `${current.toFixed(1)} A` : null;
511
518
  };
512
519
  const categoryData = c => c.categories || { master: [], sub: [], userDefined: [] };
520
+ const isFavorite = c => !hiddenFavorites.has(nameKey(c)) && (favorites.has(nameKey(c)) || (categoryData(c).master || []).includes('Favorites'));
513
521
  const allCategories = c => [...(categoryData(c).master || []), ...(categoryData(c).sub || []), ...(categoryData(c).userDefined || [])];
514
522
  const catList = () => {
515
523
  const assigned = new Set(circuits.flatMap(allCategories).filter(c => !topLevelCategories.has(c)));
@@ -524,14 +532,17 @@
524
532
  const icon = c => MFD ? mfdIcon(c) : (categoryIcons[c] || '•');
525
533
  const color = c => categoryColors[c] || '#4bb5ff';
526
534
 
527
- function saveFavorites() { localStorage.setItem(favoritesKey, JSON.stringify([...favorites])); }
535
+ function saveFavorites() {
536
+ localStorage.setItem(favoritesKey, JSON.stringify([...favorites]));
537
+ localStorage.setItem(hiddenFavoritesKey, JSON.stringify([...hiddenFavorites]));
538
+ }
528
539
  function setView(v) { view=v; category=null; render(); if (v === 'monitor') { monitorLastFetch = 0; refreshMonitoring(); } }
529
540
  function setCategory(c) { category=c; view='category'; render(); }
530
541
  window.setView = setView; window.setCategory = setCategory;
531
542
 
532
543
  function renderNav() {
533
544
  const items = [
534
- ['all','⌂','All Circuits',circuits.length], ['inuse','●','In Use',activeCount()], ['favorites','☆','Favorites',circuits.filter(c=>favorites.has(nameKey(c))).length],
545
+ ['all','⌂','All Circuits',circuits.length], ['inuse','●','In Use',activeCount()], ['favorites','☆','Favorites',circuits.filter(isFavorite).length],
535
546
  ['ac','♆','AC',masterCount('AC')], ['dc','▣','DC',masterCount('DC')]
536
547
  ];
537
548
  if (monitor.items.length) items.push(['monitor','◔','Monitoring',monitor.items.length]);
@@ -549,7 +560,7 @@
549
560
  let list = circuits.filter(c => {
550
561
  if (c.showInCircuitList === false) return false;
551
562
  if (view==='inuse' && !stateOf(c)) return false;
552
- if (view==='favorites' && !favorites.has(nameKey(c))) return false;
563
+ if (view==='favorites' && !isFavorite(c)) return false;
553
564
  if (view==='ac' && !(categoryData(c).master || []).includes('AC')) return false;
554
565
  if (view==='dc' && !(categoryData(c).master || []).includes('DC')) return false;
555
566
  if (category && !allCategories(c).includes(category)) return false;
@@ -589,12 +600,26 @@
589
600
  showTrend({ path, title: decodeURIComponent(name), r: readingFor(item, field) });
590
601
  };
591
602
 
603
+ // How long ago a reading arrived: its age when fetched (by the server's
604
+ // clock, so a tablet or plotter with the wrong time does not matter), plus
605
+ // the time since. It counts up as the tab refreshes.
606
+ function monitorAge(ageMs) {
607
+ const ms = ageMs + (monitor.fetchedAt ? Date.now() - monitor.fetchedAt : 0);
608
+ const min = Math.floor(ms / 60000);
609
+ if (min < 60) return `${Math.max(1, min)} min ago`;
610
+ const h = Math.floor(min / 60);
611
+ if (h < 24) return `${h} h ${min % 60} min ago`;
612
+ return `${Math.floor(h / 24)} d ago`;
613
+ }
614
+
592
615
  function monitorValue(item, r) {
593
616
  const u = readingFor(item, r.field);
594
- const box = `<b>${u.f(r.value).toFixed(u.dp)}</b><i>${u.unit}</i><small>${u.label}</small>`;
617
+ const stale = r.live === false && Number.isFinite(r.ageMs);
618
+ const box = `<b>${u.f(r.value).toFixed(u.dp)}</b><i>${u.unit}</i><small>${stale ? `${u.label} · ${monitorAge(r.ageMs)}` : u.label}</small>`;
619
+ const cls = `mon-val${stale ? ' stale' : ''}`;
595
620
  return monitor.trendsEnabled && r.field !== 'capacity'
596
- ? `<button type="button" class="mon-val" title="${u.label} trend" onclick="openSeries('${encodeURIComponent(r.path)}','${encodeURIComponent(item.name)}','${r.field}')">${box}</button>`
597
- : `<div class="mon-val">${box}</div>`;
621
+ ? `<button type="button" class="${cls}" title="${u.label} trend" onclick="openSeries('${encodeURIComponent(r.path)}','${encodeURIComponent(item.name)}','${r.field}')">${box}</button>`
622
+ : `<div class="${cls}">${box}</div>`;
598
623
  }
599
624
 
600
625
  function monitorRow(item) {
@@ -603,11 +628,18 @@
603
628
  values = item.state === null ? '' : `<div class="mon-val ${item.state ? 'on' : ''}"><b>${item.state ? 'ON' : 'OFF'}</b><small>Switch input</small></div>`;
604
629
  sub = item.decoded === false ? 'This kind of module is not read yet' : item.live ? (item.since ? `Since ${new Date(item.since).toLocaleTimeString([], { hour:'2-digit', minute:'2-digit' })}` : 'Switch input') : 'No status from its module yet';
605
630
  } else {
606
- values = item.live ? item.readings.map(r => monitorValue(item, r)).join('') : '';
607
- if (item.live && item.capacity) values += `<div class="mon-val"><b>${Math.round(item.capacity * 1000)}</b><i>L</i><small>Capacity</small></div>`;
608
- sub = item.live ? `Instance ${item.instance}${item.thirdParty ? ' · third-party' : ''}` : `Nothing is sending instance ${item.instance} on NMEA 2000`;
631
+ // LIVE while a reading updated in the last 3 minutes; older readings
632
+ // dimmed with their age; "No data received" when nothing has arrived.
633
+ values = item.readings.map(r => monitorValue(item, r)).join('');
634
+ if (item.readings.length && item.capacity) values += `<div class="mon-val${item.live ? '' : ' stale'}"><b>${Math.round(item.capacity * 1000)}</b><i>L</i><small>Capacity</small></div>`;
635
+ const ages = item.readings.map(r => r.ageMs).filter(Number.isFinite);
636
+ const where = `instance ${item.instance}${item.thirdParty ? ' · third-party' : ''}`;
637
+ sub = item.live ? `Instance ${item.instance}${item.thirdParty ? ' · third-party' : ''}`
638
+ : ages.length ? `Updated ${monitorAge(Math.min.apply(null, ages))} · ${where}`
639
+ : `No data received · ${where}`;
609
640
  }
610
- return `<div class="mon-row ${item.live ? '' : 'off'}"><div class="circuit-name"><strong>${esc(item.name)}</strong><small>${esc(sub)}</small></div><div class="mon-values">${values}</div><span class="mon-live">${item.live ? 'LIVE' : 'NOT ON BUS'}</span></div>`;
641
+ const badge = item.live ? 'LIVE' : (item.kind !== 'input' && item.readings.length ? 'NOT LIVE' : 'NO DATA');
642
+ return `<div class="mon-row ${item.live ? '' : 'off'}"><div class="circuit-name"><strong>${esc(item.name)}</strong><small>${esc(sub)}</small></div><div class="mon-values">${values}</div><span class="mon-live">${badge}</span></div>`;
611
643
  }
612
644
 
613
645
  function renderMonitoring() {
@@ -637,7 +669,7 @@
637
669
  const data = await r.json();
638
670
  const chips = () => JSON.stringify(Object.keys(monitor.temperatures).map(k => [k, monitor.temperatures[k].kelvin.toFixed(1)]).concat([monitor.items.length, monitor.trendsEnabled]));
639
671
  const before = chips();
640
- monitor = { items: Array.isArray(data.items) ? data.items : [], temperatures: data.temperatures || {}, trendsEnabled: data.trendsEnabled === true };
672
+ monitor = { items: Array.isArray(data.items) ? data.items : [], temperatures: data.temperatures || {}, trendsEnabled: data.trendsEnabled === true, fetchedAt: Date.now() };
641
673
  if (view === 'monitor' && !category) { renderMonitoring(); renderNav(); }
642
674
  else if (chips() !== before) render(); // only when a chip or the tab changed: a redraw would cut short a slider being moved
643
675
  } catch (_) { /* the next round tries again */ }
@@ -652,7 +684,7 @@
652
684
  }
653
685
 
654
686
  function circuitRow(c) {
655
- const on = stateOf(c), pct = percentOf(c), current = currentLabel(c), dim = !!(c.capabilities && c.capabilities.dimmer), fav = favorites.has(nameKey(c));
687
+ const on = stateOf(c), pct = percentOf(c), current = currentLabel(c), dim = !!(c.capabilities && c.capabilities.dimmer), fav = isFavorite(c);
656
688
  const p = pending.get(nameKey(c));
657
689
  const cats = subCategoryList(c).slice(0,3).map(x => `<span class="tag cat" role="img" tabindex="0" style="--cat:${color(x)}" title="${esc(x)}" aria-label="${esc(x)} category">${icon(x)}</span>`).join('');
658
690
  const disabled = p && p.kind === 'state';
@@ -1060,7 +1092,19 @@
1060
1092
  }
1061
1093
  putSignalK(signalKPath(c, 'brightness'), requested / 100, c, 'brightness', requested, previous);
1062
1094
  };
1063
- window.toggleFavorite = key => { favorites.has(key) ? favorites.delete(key) : favorites.add(key); saveFavorites(); render(); };
1095
+ window.toggleFavorite = key => {
1096
+ const circuit = circuits.find(c => nameKey(c) === key);
1097
+ if (!circuit) return;
1098
+ if (isFavorite(circuit)) {
1099
+ favorites.delete(key);
1100
+ hiddenFavorites.add(key);
1101
+ } else {
1102
+ hiddenFavorites.delete(key);
1103
+ favorites.add(key);
1104
+ }
1105
+ saveFavorites();
1106
+ render();
1107
+ };
1064
1108
 
1065
1109
  // ---- Trends ---------------------------------------------------------------
1066
1110
  // The arrow at the end of a row opens that circuit's current over time, at
@@ -1375,7 +1419,7 @@
1375
1419
  }
1376
1420
 
1377
1421
  function renderStats() {
1378
- const on=activeCount(), total=circuits.length, fav=circuits.filter(c=>favorites.has(nameKey(c))).length;
1422
+ const on=activeCount(), total=circuits.length, fav=circuits.filter(isFavorite).length;
1379
1423
  document.querySelector('#stats').innerHTML = `<div class="stat"><div class="stat-icon">⚡</div><div><strong>${total}</strong><span>Total Circuits</span></div></div><div class="stat"><div class="stat-icon">●</div><div><strong>${on}</strong><span>In Use</span></div></div><div class="stat"><div class="stat-icon">○</div><div><strong>${total-on}</strong><span>Off</span></div></div><div class="stat"><div class="stat-icon">★</div><div><strong>${fav}</strong><span>Favorites</span></div></div>`;
1380
1424
  const n2kReady = status.nmeaReady === true;
1381
1425
  const readyColor = n2kReady ? '#64e4aa' : '#ffcc77';