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"
|
"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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue