|
97 | 97 | </div> |
98 | 98 |
|
99 | 99 | <div class="scope"> |
100 | | - <div class="crt"><div id="chart"></div></div> |
| 100 | + <div class="crt"><div id="chart"></div><canvas id="smokeCv"></canvas></div> |
101 | 101 | <div class="readouts"> |
102 | 102 | <div class="ro"><div class="v" id="p50">–</div><div class="l">P50 MS</div></div> |
103 | 103 | <div class="ro"><div class="v" id="p90">–</div><div class="l">P90 MS</div></div> |
|
114 | 114 | <button data-m="10080">7d</button> |
115 | 115 | <button data-m="43200">30d</button> |
116 | 116 | <button data-m="100800">70d</button> |
| 117 | + <button data-m="432000">300d</button> |
117 | 118 | </div> |
118 | 119 | <div class="rng custom"> |
119 | 120 | <input type="datetime-local" id="customFrom" step="60"> |
|
166 | 167 |
|
167 | 168 | const chart = echarts.init($('chart')); |
168 | 169 |
|
| 170 | + |
| 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 | + |
169 | 247 | async function loadTargets() { |
170 | 248 | targets = await (await fetch('/api/targets')).json() || []; |
171 | 249 | const kinds = new Set(targets.map(t => t.type)); |
|
183 | 261 | } |
184 | 262 |
|
185 | 263 | let sel = null; // {from,to} 自定义查询区间(unix 秒) |
| 264 | +let lastRounds = []; |
186 | 265 | let renderSeq = 0; |
187 | 266 | let rendering = false; // 一次渲染(含慢速 band 查询)是否仍在进行 |
188 | 267 | async function render() { |
|
291 | 370 | if (i.b) html += ` <span style="color:${cssv('--alert')}">· ◆ burst z=${i.z ? i.z.toFixed(2) : '—'}</span>`; |
292 | 371 | return html; |
293 | 372 | } }, |
294 | | - // 荧光束渐变:贴线的样本 = 炽亮核心,散开的样本渐冷渐暗 —— 渐变即信息 |
295 | | - visualMap: { show: false, type: 'continuous', dimension: 2, min: 0, max: 0.5, |
296 | | - seriesIndex: 0, |
297 | | - inRange: { color: [cssv('--smoke-hot'), cssv('--smoke-mid'), cssv('--smoke-cool')], |
298 | | - opacity: [0.5, 0.10] } }, |
299 | 373 | series: [ |
300 | | - { type: 'scatter', data: smoke, symbolSize: 2.2, silent: true, large: true }, |
301 | 374 | { type: 'line', data: median, showSymbol: false, silent: true, z: 4, |
302 | 375 | lineStyle: { color: phos, width: 7, opacity: 0.10 } }, |
303 | 376 | { type: 'line', data: median, showSymbol: false, |
|
310 | 383 | 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]})` } } |
311 | 384 | ] |
312 | 385 | }, true); |
| 386 | + drawSmoke(rounds); // ECharts 画完坐标轴后叠加烟雾层 |
| 387 | + lastRounds = rounds; |
313 | 388 | } |
314 | 389 |
|
315 | 390 |
|
|
379 | 454 | applyTheme(); |
380 | 455 | render(); |
381 | 456 | }; |
382 | | -window.addEventListener('resize', () => chart && chart.resize()); |
| 457 | +window.addEventListener('resize', () => { if (chart) { chart.resize(); drawSmoke(lastRounds); } }); |
383 | 458 | </script> |
384 | 459 | </body> |
385 | 460 | </html> |
0 commit comments