added imbalance and fix gauge sizing
This commit is contained in:
parent
5fbc563c36
commit
183a803c3f
305
main.go
305
main.go
|
|
@ -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,45 +263,45 @@ 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
|
||||
SilaRPct float32
|
||||
SilaLKN float32
|
||||
SilaRKN float32
|
||||
SumPercent float32
|
||||
SumKN float32
|
||||
TS time.Time
|
||||
SilaLPct float32
|
||||
SilaRPct float32
|
||||
SilaLKN float32
|
||||
SilaRKN float32
|
||||
SumPercent float32
|
||||
SumKN float32
|
||||
ImbalancePercent float32
|
||||
BiasPercent float32
|
||||
}
|
||||
|
||||
type AppState struct {
|
||||
sync.RWMutex
|
||||
Connected bool
|
||||
SilaL float32
|
||||
SilaR float32
|
||||
SilaLkN float32
|
||||
SilaRkN float32
|
||||
SumPercent float32
|
||||
SumkN float32
|
||||
LastUpdate time.Time
|
||||
DroppedSamples uint64
|
||||
Connected bool
|
||||
SilaL float32
|
||||
SilaR float32
|
||||
SilaLkN float32
|
||||
SilaRkN float32
|
||||
SumPercent float32
|
||||
SumkN float32
|
||||
ImbalancePercent float32
|
||||
BiasPercent float32
|
||||
LastUpdate time.Time
|
||||
DroppedSamples uint64
|
||||
}
|
||||
|
||||
type APIState struct {
|
||||
Connected bool `json:"connected"`
|
||||
SilaL float32 `json:"sila_l"`
|
||||
SilaR float32 `json:"sila_r"`
|
||||
SilaLkN float32 `json:"sila_l_kn"`
|
||||
SilaRkN float32 `json:"sila_r_kn"`
|
||||
SumPercent float32 `json:"sum_percent"`
|
||||
SumkN float32 `json:"sum_kn"`
|
||||
LastUpdate string `json:"last_update"`
|
||||
DroppedSamples uint64 `json:"dropped_samples"`
|
||||
Connected bool `json:"connected"`
|
||||
SilaL float32 `json:"sila_l"`
|
||||
SilaR float32 `json:"sila_r"`
|
||||
SilaLkN float32 `json:"sila_l_kn"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type HistoryPoint struct {
|
||||
|
|
@ -360,15 +360,17 @@ func snapshotState() APIState {
|
|||
}
|
||||
|
||||
return APIState{
|
||||
Connected: state.Connected,
|
||||
SilaL: state.SilaL,
|
||||
SilaR: state.SilaR,
|
||||
SilaLkN: state.SilaLkN,
|
||||
SilaRkN: state.SilaRkN,
|
||||
SumPercent: state.SumPercent,
|
||||
SumkN: state.SumkN,
|
||||
LastUpdate: lastUpdate,
|
||||
DroppedSamples: state.DroppedSamples,
|
||||
Connected: state.Connected,
|
||||
SilaL: state.SilaL,
|
||||
SilaR: state.SilaR,
|
||||
SilaLkN: state.SilaLkN,
|
||||
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,17 +579,21 @@ func startPLCPoller() {
|
|||
state.SilaRkN = rightKN
|
||||
state.SumPercent = sumPercent
|
||||
state.SumkN = sumKN
|
||||
state.ImbalancePercent = imbalance
|
||||
state.BiasPercent = bias
|
||||
state.LastUpdate = now
|
||||
state.Unlock()
|
||||
|
||||
enqueueSample(Sample{
|
||||
TS: now,
|
||||
SilaLPct: silaL,
|
||||
SilaRPct: silaR,
|
||||
SilaLKN: leftKN,
|
||||
SilaRKN: rightKN,
|
||||
SumPercent: sumPercent,
|
||||
SumKN: sumKN,
|
||||
TS: now,
|
||||
SilaLPct: silaL,
|
||||
SilaRPct: silaR,
|
||||
SilaLKN: leftKN,
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue