added imbalance and fix gauge sizing

This commit is contained in:
Gamer 2026-04-16 19:02:59 +02:00
parent 5fbc563c36
commit 183a803c3f

223
main.go
View file

@ -7,10 +7,10 @@ import (
"fmt"
"html/template"
"log"
"math"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
@ -108,7 +108,7 @@ func defaultConfig() Config {
Press: PressConfig{MAX_TONNAGE: 64},
UI: UIConfig{
Title: "Force Monitor",
Subtitle: "Siemens S7-1215C • Live monitoring • PLC values in % • kN calculated from MAX_TONNAGE",
Subtitle: "Siemens S7-1215C • Piezo peak/stroke input • PLC values in % • kN calculated from MAX_TONNAGE",
LeftLabel: "LEVI STEBER",
RightLabel: "DESNI STEBER",
UnitForce: "kN",
@ -263,12 +263,6 @@ func loadOrCreateConfig(configPath string) (Config, error) {
return cfg, nil
}
type Measurement struct {
Time string `json:"time"`
SilaL float32 `json:"sila_l"`
SilaR float32 `json:"sila_r"`
}
type Sample struct {
TS time.Time
SilaLPct float32
@ -277,6 +271,8 @@ type Sample struct {
SilaRKN float32
SumPercent float32
SumKN float32
ImbalancePercent float32
BiasPercent float32
}
type AppState struct {
@ -288,6 +284,8 @@ type AppState struct {
SilaRkN float32
SumPercent float32
SumkN float32
ImbalancePercent float32
BiasPercent float32
LastUpdate time.Time
DroppedSamples uint64
}
@ -300,6 +298,8 @@ type APIState struct {
SilaRkN float32 `json:"sila_r_kn"`
SumPercent float32 `json:"sum_percent"`
SumkN float32 `json:"sum_kn"`
ImbalancePercent float32 `json:"imbalance_percent"`
BiasPercent float32 `json:"bias_percent"`
LastUpdate string `json:"last_update"`
DroppedSamples uint64 `json:"dropped_samples"`
}
@ -367,6 +367,8 @@ func snapshotState() APIState {
SilaRkN: state.SilaRkN,
SumPercent: state.SumPercent,
SumkN: state.SumkN,
ImbalancePercent: state.ImbalancePercent,
BiasPercent: state.BiasPercent,
LastUpdate: lastUpdate,
DroppedSamples: state.DroppedSamples,
}
@ -423,11 +425,12 @@ CREATE TABLE IF NOT EXISTS samples (
sila_l_kn REAL NOT NULL,
sila_r_kn REAL NOT NULL,
sum_pct REAL NOT NULL,
sum_kn REAL NOT NULL
sum_kn REAL NOT NULL,
imbalance_pct REAL NOT NULL,
bias_pct REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts);
`
if _, err := database.Exec(schema); err != nil {
_ = database.Close()
return nil, fmt.Errorf("create schema: %w", err)
@ -453,7 +456,12 @@ func startDBWriter(database *sql.DB) {
return
}
stmt, err := tx.Prepare(`INSERT INTO samples (ts, sila_l_pct, sila_r_pct, sila_l_kn, sila_r_kn, sum_pct, sum_kn) VALUES (?, ?, ?, ?, ?, ?, ?)`)
stmt, err := tx.Prepare(`
INSERT INTO samples (
ts, sila_l_pct, sila_r_pct, sila_l_kn, sila_r_kn,
sum_pct, sum_kn, imbalance_pct, bias_pct
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
_ = tx.Rollback()
log.Printf("db prepare failed: %v", err)
@ -470,6 +478,8 @@ func startDBWriter(database *sql.DB) {
s.SilaRKN,
s.SumPercent,
s.SumKN,
s.ImbalancePercent,
s.BiasPercent,
)
if err != nil {
ok = false
@ -557,6 +567,8 @@ func startPLCPoller() {
silaR := helper.GetRealAt(buf, 4)
leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR)
imbalance := float32(math.Abs(float64(silaL - silaR)))
bias := silaL - silaR
now := time.Now()
state.Lock()
@ -567,6 +579,8 @@ func startPLCPoller() {
state.SilaRkN = rightKN
state.SumPercent = sumPercent
state.SumkN = sumKN
state.ImbalancePercent = imbalance
state.BiasPercent = bias
state.LastUpdate = now
state.Unlock()
@ -578,6 +592,8 @@ func startPLCPoller() {
SilaRKN: rightKN,
SumPercent: sumPercent,
SumKN: sumKN,
ImbalancePercent: imbalance,
BiasPercent: bias,
})
time.Sleep(time.Duration(cfg.PLC.PollMs) * time.Millisecond)
@ -607,6 +623,14 @@ func parseWindow(raw string) (time.Duration, string, error) {
return d, s, nil
}
func formatHistoryLabel(t time.Time, window time.Duration) string {
local := t.Local()
if window >= 12*time.Hour {
return local.Format("02.01 15:04")
}
return local.Format("15:04:05.000")
}
func queryHistory(window time.Duration) ([]HistoryPoint, error) {
cutoff := time.Now().Add(-window).UTC().Format(time.RFC3339Nano)
@ -628,7 +652,7 @@ func queryHistory(window time.Duration) ([]HistoryPoint, error) {
continue
}
points = append(points, HistoryPoint{
Time: t.Local().Format("15:04:05.000"),
Time: formatHistoryLabel(t, window),
SilaL: float32(l),
SilaR: float32(r),
})
@ -641,15 +665,14 @@ func queryHistory(window time.Duration) ([]HistoryPoint, error) {
return points, nil
}
sampled := downsamplePoints(points, cfg.DB.MaxChartPoints)
sort.Slice(sampled, func(i, j int) bool { return sampled[i].Time < sampled[j].Time })
return sampled, nil
return downsamplePoints(points, cfg.DB.MaxChartPoints), nil
}
func downsamplePoints(points []HistoryPoint, max int) []HistoryPoint {
if len(points) <= max || max < 3 {
return points
}
out := make([]HistoryPoint, 0, max)
step := float64(len(points)-1) / float64(max-1)
used := make(map[int]struct{}, max)
@ -666,9 +689,10 @@ func downsamplePoints(points []HistoryPoint, max int) []HistoryPoint {
out = append(out, points[idx])
}
if out[len(out)-1].Time != points[len(points)-1].Time {
out[len(out)-1] = points[len(points)-1]
if len(out) == 0 {
return points
}
out[len(out)-1] = points[len(points)-1]
return out
}
@ -782,11 +806,10 @@ const uiHTML = `<!DOCTYPE html>
--bg1: #050816;
--bg2: #0b1224;
--panel: rgba(255,255,255,0.055);
--line: rgba(255,255,255,0.09);
--muted: #a1a1aa;
}
* { box-sizing: border-box; }
body {
font-family: 'Inter', system-ui, sans-serif;
background:
@ -795,33 +818,40 @@ const uiHTML = `<!DOCTYPE html>
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
color: #f4f4f5;
}
.title { font-family: 'Space Grotesk', sans-serif; }
.glass {
background: var(--panel);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.soft-glow-green { box-shadow: 0 0 0 1px rgba(34,197,94,0.28), 0 0 38px rgba(34,197,94,0.08); }
.soft-glow-yellow { box-shadow: 0 0 0 1px rgba(234,179,8,0.28), 0 0 38px rgba(234,179,8,0.08); }
.soft-glow-red { box-shadow: 0 0 0 1px rgba(239,68,68,0.28), 0 0 38px rgba(239,68,68,0.08); }
.gauge-container {
position: relative;
width: 100%;
max-width: 420px;
height: 340px;
max-width: 500px;
height: 390px;
margin: 0 auto;
}
.gauge-canvas {
width: 100%;
height: 100%;
display: block;
}
.window-btn.active {
border-color: rgba(34,211,238,0.9);
color: white;
background: rgba(34,211,238,0.14);
box-shadow: 0 0 0 1px rgba(34,211,238,0.18) inset;
}
.chart-wrap {
width: min(92vw, 1800px);
margin: 0 auto;
@ -843,6 +873,7 @@ const uiHTML = `<!DOCTYPE html>
<p class="text-zinc-400 mt-2 text-base md:text-lg">{{.Subtitle}}</p>
<p class="text-zinc-500 mt-1 text-sm font-mono">MAX_TONNAGE = {{printf "%.1f" .MaxTonnage}} {{.UnitForce}}</p>
</div>
<div class="glass border border-white/10 px-6 py-4 rounded-3xl flex flex-col md:flex-row md:items-center gap-4 md:gap-8 w-fit">
<div class="flex items-center gap-3">
<div id="dot" class="w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20"></div>
@ -858,24 +889,35 @@ const uiHTML = `<!DOCTYPE html>
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
<div>
<div class="text-zinc-400 text-sm uppercase tracking-[0.25em]">TOTAL FORCE</div>
<div class="text-zinc-400 text-sm uppercase tracking-[0.25em]">TOTAL PEAK FORCE</div>
<div class="mt-2 flex items-end gap-4">
<div class="text-5xl md:text-6xl font-mono font-bold text-emerald-300 leading-none" id="sum-kn">0.0</div>
<div class="text-2xl text-emerald-400 mb-1">{{.UnitForce}}</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 min-w-[280px]">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 min-w-[320px]">
<div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
<div class="text-zinc-500 text-xs uppercase tracking-widest">TOTAL %</div>
<div class="text-3xl font-mono font-bold text-sky-200 mt-1"><span id="sum-percent">0.0</span> {{.UnitPct}}</div>
</div>
<div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
<div class="text-zinc-500 text-xs uppercase tracking-widest">LIMITS</div>
<div class="text-sm font-mono text-zinc-300 mt-2">W {{printf "%.0f" .WarningPercent}}{{.UnitPct}} C {{printf "%.0f" .CriticalPercent}}{{.UnitPct}}</div>
<div class="text-zinc-500 text-xs uppercase tracking-widest">IMBALANCE</div>
<div class="text-3xl font-mono font-bold text-amber-200 mt-1"><span id="imbalance-pct">0.0</span> {{.UnitPct}}</div>
<div class="text-xs text-zinc-500 mt-2 font-mono">abs(L - R)</div>
</div>
<div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
<div class="text-zinc-500 text-xs uppercase tracking-widest">BIAS</div>
<div class="text-3xl font-mono font-bold text-violet-200 mt-1"><span id="bias-pct">0.0</span> {{.UnitPct}}</div>
<div class="text-xs text-zinc-500 mt-2 font-mono">L - R</div>
</div>
<div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
<div class="text-zinc-500 text-xs uppercase tracking-widest">DB</div>
<div class="text-sm font-mono text-zinc-300 mt-2">SQLite WAL non-blocking writer</div>
<div class="text-sm font-mono text-zinc-300 mt-2">SQLite WAL</div>
<div class="text-xs text-zinc-500 mt-2 font-mono">non-blocking writer</div>
</div>
</div>
</div>
@ -887,16 +929,18 @@ const uiHTML = `<!DOCTYPE html>
<div class="flex items-center gap-4">
<div id="led-l" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
<div>
<h2 class="text-2xl md:text-3xl font-bold tracking-wider">{{.LeftLabel}}</h2>
<h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.LeftLabel}}</h2>
<div id="state-l" class="text-sm text-zinc-400 mt-1">NORMAL</div>
</div>
</div>
<div id="digital-l" class="text-right">
<div class="percent text-5xl md:text-6xl font-mono font-bold text-sky-100 leading-none">0.0</div>
<div class="percent text-5xl md:text-6xl xl:text-7xl font-mono font-bold text-sky-100 leading-none">0.0</div>
<div class="text-xl text-sky-400 mt-1">{{.UnitPct}}</div>
<div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 {{.UnitForce}}</div>
</div>
</div>
<div class="gauge-container">
<canvas id="gaugeL" class="gauge-canvas"></canvas>
</div>
@ -907,16 +951,18 @@ const uiHTML = `<!DOCTYPE html>
<div class="flex items-center gap-4">
<div id="led-r" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
<div>
<h2 class="text-2xl md:text-3xl font-bold tracking-wider">{{.RightLabel}}</h2>
<h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.RightLabel}}</h2>
<div id="state-r" class="text-sm text-zinc-400 mt-1">NORMAL</div>
</div>
</div>
<div id="digital-r" class="text-right">
<div class="percent text-5xl md:text-6xl font-mono font-bold text-violet-100 leading-none">0.0</div>
<div class="percent text-5xl md:text-6xl xl:text-7xl font-mono font-bold text-violet-100 leading-none">0.0</div>
<div class="text-xl text-violet-400 mt-1">{{.UnitPct}}</div>
<div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 {{.UnitForce}}</div>
</div>
</div>
<div class="gauge-container">
<canvas id="gaugeR" class="gauge-canvas"></canvas>
</div>
@ -927,9 +973,10 @@ const uiHTML = `<!DOCTYPE html>
<div class="glass border border-white/10 rounded-3xl p-5 md:p-7">
<div class="flex flex-col xl:flex-row gap-5 xl:items-center xl:justify-between mb-5">
<div>
<h2 class="text-2xl md:text-3xl font-semibold">Live Trend</h2>
<div class="text-zinc-400 mt-1 text-sm md:text-base">Chart reads selected history range directly from SQLite</div>
<h2 class="text-2xl md:text-3xl font-semibold">Peak Trend</h2>
<div class="text-zinc-400 mt-1 text-sm md:text-base">Piezo peak/stroke history from SQLite</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<button class="window-btn active px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="30s">30s</button>
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="1m">1m</button>
@ -944,6 +991,7 @@ const uiHTML = `<!DOCTYPE html>
</div>
</div>
</div>
<div class="h-[52vh] min-h-[420px] max-h-[760px]">
<canvas id="lineChart"></canvas>
</div>
@ -1052,40 +1100,42 @@ const uiHTML = `<!DOCTYPE html>
}
}
function drawGauge(canvasId, percentValue, knValue, zone, sideAccent) {
function drawGauge(canvasId, percentValue, knValue, sideAccent) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const { ctx, w, h } = prepCanvas(canvas);
const cx = w / 2;
const cy = h * 0.64;
const radius = Math.min(w, h) * 0.35;
const trackWidth = Math.max(16, radius * 0.16);
const cy = h * 0.55;
const radius = Math.min(w, h) * 0.37;
const trackWidth = Math.max(18, radius * 0.16);
const value = clamp(Number(percentValue) || 0, 0, GAUGE_MAX_PERCENT);
const valueAngle = valueToAngle(value);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, radius + 20, 0, Math.PI * 2);
ctx.arc(cx, cy, radius + 24, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.015)';
ctx.shadowColor = 'rgba(0,0,0,0.45)';
ctx.shadowBlur = 28;
ctx.shadowBlur = 30;
ctx.fill();
ctx.restore();
drawArc(ctx, cx, cy, radius, START_ANGLE, END_ANGLE, 'rgba(255,255,255,0.06)', trackWidth + 10, 0);
drawColoredBand(ctx, cx, cy, radius, trackWidth);
drawArc(ctx, cx, cy, radius, valueAngle, END_ANGLE, 'rgba(9,9,11,0.58)', trackWidth - 1, 0);
drawArc(ctx, cx, cy, radius, START_ANGLE, valueAngle, 'rgba(255,255,255,0.04)', trackWidth - 1, 8);
drawArc(ctx, cx, cy, radius, valueAngle, END_ANGLE, 'rgba(9,9,11,0.60)', trackWidth - 1, 0);
drawArc(ctx, cx, cy, radius, START_ANGLE, valueAngle, 'rgba(255,255,255,0.04)', trackWidth - 1, 10);
for (let v = 0; v <= GAUGE_MAX_PERCENT + 0.0001; v += 5) {
const a = valueToAngle(v);
const isMajor = Math.abs(v % 10) < 0.0001;
const isThreshold = Math.abs(v - WARNING_PERCENT) < 0.0001 || Math.abs(v - CRITICAL_PERCENT) < 0.0001;
const r1 = isThreshold ? radius * 0.67 : isMajor ? radius * 0.73 : radius * 0.80;
const r2 = radius * 0.96;
const r1 = isThreshold ? radius * 0.66 : isMajor ? radius * 0.72 : radius * 0.80;
const r2 = radius * 0.97;
const p1 = polar(cx, cy, r1, a);
const p2 = polar(cx, cy, r2, a);
@ -1095,36 +1145,33 @@ const uiHTML = `<!DOCTYPE html>
if (isThreshold) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 3;
ctx.lineWidth = 3.2;
} else if (isMajor) {
ctx.strokeStyle = 'rgba(255,255,255,0.82)';
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255,255,255,0.86)';
ctx.lineWidth = 2.2;
} else {
ctx.strokeStyle = 'rgba(161,161,170,0.72)';
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(161,161,170,0.74)';
ctx.lineWidth = 1.1;
}
ctx.stroke();
}
const labels = [];
for (let v = 0; v <= GAUGE_MAX_PERCENT; v += 20) labels.push(v);
if (labels[labels.length - 1] !== GAUGE_MAX_PERCENT) labels.push(GAUGE_MAX_PERCENT);
ctx.fillStyle = 'rgba(244,244,245,0.92)';
ctx.font = '600 13px Inter, sans-serif';
const labels = [0, 20, 40, 60, 80, 100, 120, 130];
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(244,244,245,0.96)';
ctx.font = '700 18px Inter, sans-serif';
for (const v of labels) {
const a = valueToAngle(v);
const p = polar(cx, cy, radius * 1.11, a);
ctx.fillText(String(Math.round(v)), p.x, p.y);
const p = polar(cx, cy, radius * 1.13, a);
ctx.fillText(String(v), p.x, p.y);
}
const tip = polar(cx, cy, radius * 0.84, valueAngle);
const left = polar(cx, cy, 7, valueAngle + Math.PI / 2);
const right = polar(cx, cy, 7, valueAngle - Math.PI / 2);
const tail = polar(cx, cy, radius * 0.18, valueAngle + Math.PI);
const tip = polar(cx, cy, radius * 0.86, valueAngle);
const left = polar(cx, cy, 8, valueAngle + Math.PI / 2);
const right = polar(cx, cy, 8, valueAngle - Math.PI / 2);
const tail = polar(cx, cy, radius * 0.20, valueAngle + Math.PI);
ctx.save();
ctx.beginPath();
@ -1140,7 +1187,7 @@ const uiHTML = `<!DOCTYPE html>
ctx.restore();
ctx.beginPath();
ctx.arc(cx, cy, 13, 0, Math.PI * 2);
ctx.arc(cx, cy, 14, 0, Math.PI * 2);
ctx.fillStyle = '#101114';
ctx.fill();
ctx.lineWidth = 3;
@ -1153,32 +1200,27 @@ const uiHTML = `<!DOCTYPE html>
ctx.fill();
ctx.beginPath();
ctx.arc(cx, cy + 6, radius * 0.35, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(9,9,11,0.84)';
ctx.arc(cx, cy + 8, radius * 0.36, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(9,9,11,0.85)';
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffffff';
ctx.font = '700 34px Space Grotesk, Inter, sans-serif';
ctx.fillText(value.toFixed(1), cx, cy - 6);
ctx.font = '700 48px Space Grotesk, Inter, sans-serif';
ctx.fillText(value.toFixed(1), cx, cy - 8);
ctx.fillStyle = sideAccent;
ctx.font = '700 13px Inter, sans-serif';
ctx.fillText(UNIT_PCT, cx, cy + 22);
ctx.font = '700 18px Inter, sans-serif';
ctx.fillText(UNIT_PCT, cx, cy + 26);
ctx.fillStyle = '#a1a1aa';
ctx.font = '600 12px Inter, sans-serif';
ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 42);
}
function zoneColor(zone, left) {
if (zone === 'critical') return '#ef4444';
if (zone === 'warning') return '#eab308';
return left ? '#22d3ee' : '#c084fc';
ctx.font = '600 16px Inter, sans-serif';
ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 52);
}
function getZone(percentValue) {
@ -1190,6 +1232,7 @@ const uiHTML = `<!DOCTYPE html>
function setStatusConnected(connected) {
const dot = document.getElementById('dot');
const text = document.getElementById('status-text');
if (connected) {
dot.className = 'w-4 h-4 rounded-full bg-emerald-400 ring-4 ring-emerald-400/20';
text.textContent = 'Connected';
@ -1231,23 +1274,30 @@ const uiHTML = `<!DOCTYPE html>
if (!isoString) return 'Last update: --:--:--.---';
const d = new Date(isoString);
if (isNaN(d.getTime())) return 'Last update: --:--:--.---';
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const ms = String(d.getMilliseconds()).padStart(3, '0');
return 'Last update: ' + hh + ':' + mm + ':' + ss + '.' + ms;
}
function updateAlarmBanner(leftPercent, rightPercent) {
const banner = document.getElementById('alarm-banner');
const text = document.getElementById('alarm-text');
const leftCritical = leftPercent >= CRITICAL_PERCENT;
const rightCritical = rightPercent >= CRITICAL_PERCENT;
if (leftCritical || rightCritical) {
if (leftCritical && rightCritical) text.textContent = 'CRITICAL FORCE ALARM ACTIVE LEFT + RIGHT';
else if (leftCritical) text.textContent = 'CRITICAL FORCE ALARM ACTIVE LEFT';
else text.textContent = 'CRITICAL FORCE ALARM ACTIVE RIGHT';
if (leftCritical && rightCritical) {
text.textContent = 'CRITICAL FORCE ALARM ACTIVE LEFT + RIGHT';
} else if (leftCritical) {
text.textContent = 'CRITICAL FORCE ALARM ACTIVE LEFT';
} else {
text.textContent = 'CRITICAL FORCE ALARM ACTIVE RIGHT';
}
banner.classList.remove('hidden');
} else {
banner.classList.add('hidden');
@ -1256,12 +1306,14 @@ const uiHTML = `<!DOCTYPE html>
function redrawGauges() {
if (!latestData) return;
const leftPercent = Number(latestData.sila_l) || 0;
const rightPercent = Number(latestData.sila_r) || 0;
const leftKN = Number(latestData.sila_l_kn) || 0;
const rightKN = Number(latestData.sila_r_kn) || 0;
drawGauge('gaugeL', leftPercent, leftKN, getZone(leftPercent), '#22d3ee');
drawGauge('gaugeR', rightPercent, rightKN, getZone(rightPercent), '#c084fc');
drawGauge('gaugeL', leftPercent, leftKN, '#22d3ee');
drawGauge('gaugeR', rightPercent, rightKN, '#c084fc');
}
async function fetchLiveData() {
@ -1276,16 +1328,21 @@ const uiHTML = `<!DOCTYPE html>
const rightKN = Number(d.sila_r_kn) || 0;
const sumPercent = Number(d.sum_percent) || 0;
const sumKN = Number(d.sum_kn) || 0;
const imbalance = Number(d.imbalance_percent) || 0;
const bias = Number(d.bias_percent) || 0;
setStatusConnected(!!d.connected);
document.querySelector('#digital-l .percent').textContent = leftPercent.toFixed(1);
document.querySelector('#digital-l .kn').textContent = leftKN.toFixed(1) + ' ' + UNIT_FORCE;
document.querySelector('#digital-r .percent').textContent = rightPercent.toFixed(1);
document.querySelector('#digital-r .kn').textContent = rightKN.toFixed(1) + ' ' + UNIT_FORCE;
document.getElementById('sum-percent').textContent = sumPercent.toFixed(1);
document.getElementById('sum-kn').textContent = sumKN.toFixed(1);
document.getElementById('imbalance-pct').textContent = imbalance.toFixed(1);
document.getElementById('bias-pct').textContent = bias.toFixed(1);
document.getElementById('last-update').textContent = formatLastUpdate(d.last_update);
document.getElementById('dropped-samples').textContent = String(d.dropped_samples || 0);
@ -1302,9 +1359,11 @@ const uiHTML = `<!DOCTYPE html>
async function fetchHistory() {
if (historyBusy) return;
historyBusy = true;
try {
const res = await fetch('/api/history?window=' + encodeURIComponent(currentWindow), { cache: 'no-store' });
if (!res.ok) throw new Error('History request failed');
const d = await res.json();
const pts = Array.isArray(d.points) ? d.points : [];
const labels = pts.map(p => p.time);
@ -1355,7 +1414,7 @@ const uiHTML = `<!DOCTYPE html>
labels: [],
datasets: [
{
label: 'Sila L %',
label: 'Levi peak %',
borderColor: '#22d3ee',
backgroundColor: 'rgba(34,211,238,0.10)',
borderWidth: 3,
@ -1364,7 +1423,7 @@ const uiHTML = `<!DOCTYPE html>
data: []
},
{
label: 'Sila R %',
label: 'Desni peak %',
borderColor: '#c084fc',
backgroundColor: 'rgba(192,132,252,0.10)',
borderWidth: 3,