|
30 | 30 | --smoke:rgba(11,114,133,.18); --lossbar:rgba(209,67,63,.5); --glow:0; |
31 | 31 | --smoke-hot:#064a57; --smoke-mid:#1596ad; --smoke-cool:#b3722f; |
32 | 32 | } |
| 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 | + } |
33 | 60 | * { box-sizing:border-box; margin:0; } |
34 | 61 | body { background:var(--bg); color:var(--ink); font-family:var(--mono); padding:18px 22px; } |
35 | 62 | header { display:flex; align-items:baseline; gap:14px; margin-bottom:14px; flex-wrap:wrap; } |
|
85 | 112 | <div class="mark">pingping ●</div> |
86 | 113 | <div class="tag">One binary. One graph. Zero guesswork.</div> |
87 | 114 | <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> |
88 | 116 | </header> |
89 | 117 |
|
90 | 118 | <div class="row"> |
|
97 | 125 | </div> |
98 | 126 |
|
99 | 127 | <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> |
101 | 129 | <div class="readouts"> |
102 | 130 | <div class="ro"><div class="v" id="p50">–</div><div class="l">P50 MS</div></div> |
103 | 131 | <div class="ro"><div class="v" id="p90">–</div><div class="l">P90 MS</div></div> |
|
148 | 176 | <script> |
149 | 177 | const $ = id => document.getElementById(id); |
150 | 178 | const cssv = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim(); |
151 | | -const THEMES = ['dark', 'daylight', 'arctic']; |
| 179 | +const THEMES = ['dark', 'daylight', 'arctic', 'amber', 'mono', 'midnight']; |
152 | 180 | let theme = localStorage.getItem('pp_theme') || 'dark'; |
153 | 181 | 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 | +} |
154 | 190 | function applyTheme() { |
155 | 191 | if (theme === 'dark') delete document.documentElement.dataset.theme; |
156 | 192 | else document.documentElement.dataset.theme = theme; |
|
168 | 204 | const chart = echarts.init($('chart')); |
169 | 205 |
|
170 | 206 |
|
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 | | -} |
246 | 207 |
|
247 | 208 | async function loadTargets() { |
248 | 209 | targets = await (await fetch('/api/targets')).json() || []; |
|
261 | 222 | } |
262 | 223 |
|
263 | 224 | let sel = null; // {from,to} 自定义查询区间(unix 秒) |
264 | | -let lastRounds = []; |
265 | 225 | let renderSeq = 0; |
266 | 226 | let rendering = false; // 一次渲染(含慢速 band 查询)是否仍在进行 |
267 | 227 | async function render() { |
|
370 | 330 | if (i.b) html += ` <span style="color:${cssv('--alert')}">· ◆ burst z=${i.z ? i.z.toFixed(2) : '—'}</span>`; |
371 | 331 | return html; |
372 | 332 | } }, |
| 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] } }, |
373 | 338 | series: [ |
| 339 | + { type: 'scatter', data: smoke, symbolSize: 2.2, silent: true, large: true }, |
374 | 340 | { type: 'line', data: median, showSymbol: false, silent: true, z: 4, |
375 | 341 | lineStyle: { color: phos, width: 7, opacity: 0.10 } }, |
376 | 342 | { type: 'line', data: median, showSymbol: false, |
|
383 | 349 | 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]})` } } |
384 | 350 | ] |
385 | 351 | }, true); |
386 | | - drawSmoke(rounds); // ECharts 画完坐标轴后叠加烟雾层 |
387 | | - lastRounds = rounds; |
388 | 352 | } |
389 | 353 |
|
390 | 354 |
|
|
454 | 418 | applyTheme(); |
455 | 419 | render(); |
456 | 420 | }; |
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()); |
458 | 431 | </script> |
459 | 432 | </body> |
460 | 433 | </html> |
0 commit comments