Skip to content

Commit cec82ed

Browse files
v2.10: drop the canvas layer, six themes, save-as-default
- reverted the canvas smoke overlay: it was complexity you did not ask for. Smoke is back on the ECharts scatter series, one render layer again. Two-tier storage stays, and it is what keeps long windows drawable now — 30d+ data carries 4 samples per round instead of 20. - three new themes: amber (amber-phosphor CRT), mono (black on white, print-friendly), midnight (deep blue, low glare). Six total, cycled with the same button. - 'save as default' stores the theme server-side next to the data, so a fresh browser or a colleague's machine opens on the same theme. Theme ids are whitelisted to lowercase letters; a path-traversal attempt returns 400. Verified: no canvas overlay in the DOM, window switching redraws every time including 300d -> 1h, all six themes apply, save-as-default persists across restarts, no JS errors.
1 parent 06c64fa commit cec82ed

2 files changed

Lines changed: 83 additions & 82 deletions

File tree

static/index.html

Lines changed: 54 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,33 @@
3030
--smoke:rgba(11,114,133,.18); --lossbar:rgba(209,67,63,.5); --glow:0;
3131
--smoke-hot:#064a57; --smoke-mid:#1596ad; --smoke-cool:#b3722f;
3232
}
33+
/* Amber: the old amber-phosphor CRT — warm orange trace on near-black */
34+
html[data-theme="amber"] {
35+
--bg:#0c0a06; --panel:#16120b; --screen:#080602; --line:#2a2115;
36+
--phos:#ffb340; --amber:#ffd28a; --alert:#ff6b4a; --ink:#e8d5b0; --dim:#8a7550;
37+
--grid-axis:#3a2e1c; --grid-split:#1e1810;
38+
--smoke:rgba(255,179,64,.16); --lossbar:rgba(255,107,74,.5); --glow:7;
39+
--smoke-hot:#fff0d0; --smoke-mid:#ffb340; --smoke-cool:#8a6020;
40+
--shadow:0 1px 2px rgba(0,0,0,.6); --shadow-lg:0 10px 30px rgba(0,0,0,.5);
41+
}
42+
/* Mono: high-contrast black on white — for printing and bright rooms */
43+
html[data-theme="mono"] {
44+
--bg:#ffffff; --panel:#fbfbfb; --screen:#ffffff; --line:#d8d8d8;
45+
--phos:#111111; --amber:#666666; --alert:#c00000; --ink:#111111; --dim:#777777;
46+
--grid-axis:#c8c8c8; --grid-split:#ededed;
47+
--smoke:rgba(17,17,17,.22); --lossbar:rgba(192,0,0,.45); --glow:0;
48+
--smoke-hot:#000000; --smoke-mid:#444444; --smoke-cool:#999999;
49+
--shadow:0 1px 2px rgba(0,0,0,.08); --shadow-lg:0 8px 24px rgba(0,0,0,.10);
50+
}
51+
/* Midnight: deep blue, low blue-light glare for night shifts */
52+
html[data-theme="midnight"] {
53+
--bg:#0a0e1a; --panel:#111726; --screen:#070a14; --line:#1e2740;
54+
--phos:#6db3ff; --amber:#ffc857; --alert:#ff5c7a; --ink:#c3d2e8; --dim:#5a6b85;
55+
--grid-axis:#243050; --grid-split:#131a2c;
56+
--smoke:rgba(109,179,255,.16); --lossbar:rgba(255,92,122,.5); --glow:6;
57+
--smoke-hot:#e8f4ff; --smoke-mid:#6db3ff; --smoke-cool:#3a5580;
58+
--shadow:0 1px 2px rgba(0,0,0,.5); --shadow-lg:0 10px 30px rgba(0,0,0,.45);
59+
}
3360
* { box-sizing:border-box; margin:0; }
3461
body { background:var(--bg); color:var(--ink); font-family:var(--mono); padding:18px 22px; }
3562
header { display:flex; align-items:baseline; gap:14px; margin-bottom:14px; flex-wrap:wrap; }
@@ -85,6 +112,7 @@
85112
<div class="mark">pingping ●</div>
86113
<div class="tag">One binary. One graph. Zero guesswork.</div>
87114
<button class="pill" id="themeBtn" title="switch theme" style="margin-left:auto"><span id="themeName">dark</span></button>
115+
<button class="pill" id="themeSave" title="save this theme as the default for everyone on this install">save as default</button>
88116
</header>
89117

90118
<div class="row">
@@ -97,7 +125,7 @@
97125
</div>
98126

99127
<div class="scope">
100-
<div class="crt"><div id="chart"></div><canvas id="smokeCv"></canvas></div>
128+
<div class="crt"><div id="chart"></div></div>
101129
<div class="readouts">
102130
<div class="ro"><div class="v" id="p50"></div><div class="l">P50 MS</div></div>
103131
<div class="ro"><div class="v" id="p90"></div><div class="l">P90 MS</div></div>
@@ -148,9 +176,17 @@
148176
<script>
149177
const $ = id => document.getElementById(id);
150178
const cssv = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
151-
const THEMES = ['dark', 'daylight', 'arctic'];
179+
const THEMES = ['dark', 'daylight', 'arctic', 'amber', 'mono', 'midnight'];
152180
let theme = localStorage.getItem('pp_theme') || 'dark';
153181
if (!THEMES.includes(theme)) theme = 'dark';
182+
// 本地没手动选过 -> 跟随服务端保存的默认主题
183+
if (!localStorage.getItem('pp_theme')) {
184+
fetch('/api/theme').then(r => r.json()).then(d => {
185+
if (d.theme && THEMES.includes(d.theme) && d.theme !== theme) {
186+
theme = d.theme; applyTheme(); render();
187+
}
188+
}).catch(() => {});
189+
}
154190
function applyTheme() {
155191
if (theme === 'dark') delete document.documentElement.dataset.theme;
156192
else document.documentElement.dataset.theme = theme;
@@ -168,81 +204,6 @@
168204
const chart = echarts.init($('chart'));
169205

170206

171-
// 自绘烟雾层:ECharts 的散点图在几十万点就吃不消,因为它给每个点维护状态。
172-
// 烟雾不需要单点命中(tooltip 按轮显示),所以直接写像素缓冲 —— 百万点也是一次遍历。
173-
function drawSmoke(rounds) {
174-
const cv = $('smokeCv'), box = $('chart');
175-
const W = box.clientWidth, H = box.clientHeight;
176-
const dpr = window.devicePixelRatio || 1;
177-
cv.width = W * dpr; cv.height = H * dpr;
178-
cv.style.width = W + 'px'; cv.style.height = H + 'px';
179-
const ctx = cv.getContext('2d');
180-
ctx.clearRect(0, 0, cv.width, cv.height);
181-
if (!rounds.length) return;
182-
183-
// 与 ECharts 网格对齐(grid: left 52, right 46, top 18, bottom 42)
184-
const gl = 52 * dpr, gr = 46 * dpr, gt = 18 * dpr, gb = 42 * dpr;
185-
const plotW = cv.width - gl - gr, plotH = cv.height - gt - gb;
186-
if (plotW <= 0 || plotH <= 0) return;
187-
188-
// 用 ECharts 当前的坐标范围,保证两层严丝合缝
189-
const opt = chart.getOption();
190-
const yAxis = opt.yAxis && opt.yAxis[0];
191-
const t0 = rounds[0].t * 1000, t1 = rounds[rounds.length - 1].t * 1000;
192-
const yMin = yAxis && yAxis.min != null ? yAxis.min : 0;
193-
let yMax = yAxis && yAxis.max != null ? yAxis.max : 0;
194-
if (!yMax) { // 回退:自己算上界
195-
for (const r of rounds) for (const v of (r.ms || [])) if (v > yMax) yMax = v;
196-
yMax *= 1.05;
197-
}
198-
const tSpan = Math.max(1, t1 - t0), ySpan = Math.max(1e-6, yMax - yMin);
199-
200-
// 逐点写 ImageData:比 fillRect 快一个数量级
201-
const img = ctx.createImageData(cv.width, cv.height);
202-
const buf = img.data;
203-
const hot = hexToRgb(cssv('--smoke-hot')), mid = hexToRgb(cssv('--smoke-mid')), cool = hexToRgb(cssv('--smoke-cool'));
204-
205-
for (const r of rounds) {
206-
const ms = r.ms;
207-
if (!ms || !ms.length) continue;
208-
const x = gl + (r.t * 1000 - t0) / tSpan * plotW;
209-
if (x < 0 || x >= cv.width) continue;
210-
// 该轮中位数用于着色:贴线的亮,散开的冷
211-
const srt = ms.length > 2 ? [...ms].sort((a, b) => a - b) : ms;
212-
const med = srt[Math.floor(srt.length / 2)];
213-
for (const v of ms) {
214-
const y = gt + plotH - (v - yMin) / ySpan * plotH;
215-
if (y < 0 || y >= cv.height) continue;
216-
const dev = med ? Math.min(1, Math.abs(v - med) / med / 0.5) : 0;
217-
const c = dev < 0.5 ? lerp(hot, mid, dev * 2) : lerp(mid, cool, (dev - 0.5) * 2);
218-
const a = Math.round((0.5 - dev * 0.4) * 255);
219-
const xi = x | 0, yi = y | 0;
220-
for (let dx = 0; dx < 2; dx++) for (let dy = 0; dy < 2; dy++) { // 2x2 让点可见
221-
const px = xi + dx, py = yi + dy;
222-
if (px >= cv.width || py >= cv.height) continue;
223-
const idx = (py * cv.width + px) * 4;
224-
// 叠加混合:重叠越多越亮,这正是烟雾的质感
225-
buf[idx] = Math.min(255, buf[idx] + c[0] * a / 255);
226-
buf[idx + 1] = Math.min(255, buf[idx + 1] + c[1] * a / 255);
227-
buf[idx + 2] = Math.min(255, buf[idx + 2] + c[2] * a / 255);
228-
buf[idx + 3] = Math.min(255, buf[idx + 3] + a);
229-
}
230-
}
231-
}
232-
ctx.putImageData(img, 0, 0);
233-
}
234-
235-
function hexToRgb(h) {
236-
h = (h || '#7ce38b').trim();
237-
if (h[0] === '#') h = h.slice(1);
238-
if (h.length === 3) h = h.split('').map(c => c + c).join('');
239-
const n = parseInt(h, 16);
240-
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
241-
}
242-
function lerp(a, b, t) {
243-
t = Math.max(0, Math.min(1, t));
244-
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
245-
}
246207

247208
async function loadTargets() {
248209
targets = await (await fetch('/api/targets')).json() || [];
@@ -261,7 +222,6 @@
261222
}
262223

263224
let sel = null; // {from,to} 自定义查询区间(unix 秒)
264-
let lastRounds = [];
265225
let renderSeq = 0;
266226
let rendering = false; // 一次渲染(含慢速 band 查询)是否仍在进行
267227
async function render() {
@@ -370,7 +330,13 @@
370330
if (i.b) html += ` <span style="color:${cssv('--alert')}">· ◆ burst z=${i.z ? i.z.toFixed(2) : '—'}</span>`;
371331
return html;
372332
} },
333+
// 荧光束渐变:贴线的样本 = 炽亮核心,散开的样本渐冷渐暗
334+
visualMap: { show: false, type: 'continuous', dimension: 2, min: 0, max: 0.5,
335+
seriesIndex: 0,
336+
inRange: { color: [cssv('--smoke-hot'), cssv('--smoke-mid'), cssv('--smoke-cool')],
337+
opacity: [0.5, 0.10] } },
373338
series: [
339+
{ type: 'scatter', data: smoke, symbolSize: 2.2, silent: true, large: true },
374340
{ type: 'line', data: median, showSymbol: false, silent: true, z: 4,
375341
lineStyle: { color: phos, width: 7, opacity: 0.10 } },
376342
{ type: 'line', data: median, showSymbol: false,
@@ -383,8 +349,6 @@
383349
tooltip: { formatter: p => `◆ z-score ${p.value[2] ? p.value[2].toFixed(2) : '—'} · loss ${p.value[1].toFixed(0)}% (${p.value[3]}/${p.value[4]})` } }
384350
]
385351
}, true);
386-
drawSmoke(rounds); // ECharts 画完坐标轴后叠加烟雾层
387-
lastRounds = rounds;
388352
}
389353

390354

@@ -454,7 +418,16 @@
454418
applyTheme();
455419
render();
456420
};
457-
window.addEventListener('resize', () => { if (chart) { chart.resize(); drawSmoke(lastRounds); } });
421+
$('themeSave').onclick = async () => {
422+
const btn = $('themeSave'), old = btn.textContent;
423+
try {
424+
await fetch('/api/theme', { method: 'POST', body: theme });
425+
btn.textContent = 'saved ✓';
426+
} catch { btn.textContent = 'failed'; }
427+
setTimeout(() => { btn.textContent = old; }, 1500);
428+
};
429+
430+
window.addEventListener('resize', () => chart && chart.resize());
458431
</script>
459432
</body>
460433
</html>

web.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ import (
66
"embed"
77
"encoding/hex"
88
"encoding/json"
9+
"io"
910
"log"
1011
"net/http"
12+
"os"
13+
"path/filepath"
1114
"strconv"
15+
"strings"
1216
"sync"
1317
"time"
1418
)
@@ -115,6 +119,31 @@ func serveWeb(cfg *Config, store *Store, users map[string]string) error {
115119
writeJSON(w, map[string]bool{"ok": true})
116120
})
117121

122+
// Default theme, stored next to the data so it survives restarts and follows the
123+
// install rather than one browser. GET returns it; POST saves it.
124+
themeFile := filepath.Join(cfg.DataDir, "theme")
125+
mux.HandleFunc("/api/theme", func(w http.ResponseWriter, r *http.Request) {
126+
if r.Method == http.MethodPost {
127+
body, _ := io.ReadAll(io.LimitReader(r.Body, 32))
128+
name := strings.TrimSpace(string(body))
129+
for _, c := range name {
130+
if !(c >= 'a' && c <= 'z') { // whitelist: theme ids are lowercase letters
131+
http.Error(w, `{"error":"bad theme"}`, http.StatusBadRequest)
132+
return
133+
}
134+
}
135+
if name == "" {
136+
http.Error(w, `{"error":"bad theme"}`, http.StatusBadRequest)
137+
return
138+
}
139+
os.WriteFile(themeFile, []byte(name), 0o644)
140+
writeJSON(w, map[string]string{"theme": name})
141+
return
142+
}
143+
b, _ := os.ReadFile(themeFile)
144+
writeJSON(w, map[string]string{"theme": strings.TrimSpace(string(b))})
145+
})
146+
118147
mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) {
119148
writeJSON(w, map[string]string{"version": version})
120149
})
@@ -173,7 +202,6 @@ func serveWeb(cfg *Config, store *Store, users map[string]string) error {
173202
writeJSON(w, store.ReadRange(name, from, to))
174203
}))
175204

176-
177205
return http.ListenAndServe(cfg.Listen, mux)
178206
}
179207

0 commit comments

Comments
 (0)