added imbalance and fix gauge sizing

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

305
main.go
View file

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