|
65 | 65 | footer { margin-top:12px; color:var(--dim); font-size:12px; display:flex; gap:10px; flex-wrap:wrap; } |
66 | 66 | #conclusion { color:var(--ink); } |
67 | 67 | .legend { color:var(--dim); } |
| 68 | + .promos { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-top:16px; } |
| 69 | + @media (max-width:720px) { .promos { grid-template-columns:1fr; } } |
| 70 | + .promo { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:14px 16px; |
| 71 | + box-shadow:var(--shadow); text-decoration:none; display:block; } |
| 72 | + .promo.sponsor { border-style:dashed; } |
| 73 | + .promo.sponsor:hover { border-color:var(--phos); } |
| 74 | + .promo-h { font-family:var(--mono); font-size:10px; letter-spacing:.16em; color:var(--dim); margin-bottom:6px; } |
| 75 | + .promo-b { color:var(--ink); font-size:13px; } |
| 76 | + .share .promo-b { display:flex; gap:14px; align-items:center; flex-wrap:wrap; } |
| 77 | + .share a { color:var(--phos); text-decoration:none; font-weight:600; } |
| 78 | + .share a:hover { text-decoration:underline; } |
| 79 | + .share-links { display:flex; gap:12px; } |
| 80 | + .share-links a { color:var(--dim); font-weight:500; } |
68 | 81 | </style> |
69 | 82 | </head> |
70 | 83 | <body> |
|
93 | 106 | <div class="ro"><div class="v" id="bursts">–</div><div class="l">BURSTS · WINDOW</div></div> |
94 | 107 | <div class="sp"></div> |
95 | 108 | <div class="rng"> |
96 | | - <span>TIME/DIV</span> |
| 109 | + <button id="selReset" style="display:none;color:var(--amber)">✕ selection</button><span>TIME/DIV</span> |
97 | 110 | <button data-m="60">1h</button> |
98 | 111 | <button data-m="360" class="on">6h</button> |
99 | 112 | <button data-m="720">12h</button> |
100 | 113 | <button data-m="1440">24h</button> |
101 | | - <button data-d="7">7d</button> |
102 | | - <button data-d="30">1M</button> |
| 114 | + <button data-m="10080">7d</button> |
| 115 | + <button data-m="43200">30d</button> |
103 | 116 | <button data-d="90">3M</button> |
104 | 117 | <button data-d="180">6M</button> |
105 | 118 | <button data-d="300">ALL</button> |
|
112 | 125 | <span class="legend">Line = per-round median · Smoke = all samples · Red bars = loss% · ◆ = burst</span> |
113 | 126 | </footer> |
114 | 127 |
|
| 128 | +<div class="promos"> |
| 129 | + <a class="promo sponsor" id="sponsorSlot" href="https://github.com/githubflyideas/pingping#sponsor" target="_blank" rel="noopener"> |
| 130 | + <div class="promo-h">SPONSOR</div> |
| 131 | + <div class="promo-b">This slot is for one VPS / IDC / network sponsor. Your GIF here — reach out on GitHub.</div> |
| 132 | + </a> |
| 133 | + <div class="promo share"> |
| 134 | + <div class="promo-h">LIKE PINGPING?</div> |
| 135 | + <div class="promo-b"> |
| 136 | + <a href="https://github.com/githubflyideas/pingping" target="_blank" rel="noopener">★ Star on GitHub</a> |
| 137 | + <span class="share-links" id="shareLinks"></span> |
| 138 | + </div> |
| 139 | + </div> |
| 140 | +</div> |
| 141 | + |
115 | 142 | <script src="/static/echarts.min.js"></script> |
116 | 143 | <script> |
117 | 144 | const $ = id => document.getElementById(id); |
|
151 | 178 | if ((!current || !visible.some(t => t.name === current)) && visible.length) current = visible[0].name; |
152 | 179 | } |
153 | 180 |
|
| 181 | +let sel = null; // {from,to} 框选时间段(unix 秒) |
154 | 182 | let renderSeq = 0; |
155 | 183 | async function render() { |
156 | 184 | const seq = ++renderSeq; |
157 | 185 | await loadTargets(); |
158 | 186 | const t = targets.find(x => x.name === current); |
159 | 187 | if (!t) return; |
160 | 188 | if (days > 0) { await renderBand(t); return; } |
161 | | - const rounds = await (await fetch(`/api/series?target=${encodeURIComponent(current)}&minutes=${minutes}`)).json() || []; |
| 189 | + let url = `/api/series?target=${encodeURIComponent(current)}&maxpts=12000`; |
| 190 | + if (sel) url += `&from=${sel.from}&to=${sel.to}`; |
| 191 | + else url += `&minutes=${minutes}`; |
| 192 | + const rounds = await (await fetch(url)).json() || []; |
162 | 193 | if (seq !== renderSeq) return; // 过期响应:已切走,丢弃 |
163 | 194 |
|
164 | 195 | const smoke = [], median = [], lossBars = [], burstPts = []; |
|
208 | 239 | const phos = cssv('--phos'); |
209 | 240 | chart.setOption({ |
210 | 241 | animation: false, |
211 | | - grid: { left: 52, right: 46, top: 18, bottom: 42 }, |
| 242 | + grid: { left: 52, right: 46, top: 18, bottom: 56 }, |
| 243 | + dataZoom: [ |
| 244 | + { type: 'inside', filterMode: 'none' }, |
| 245 | + { type: 'slider', height: 16, bottom: 8, filterMode: 'none', |
| 246 | + borderColor: cssv('--line'), fillerColor: 'color-mix(in srgb, var(--phos) 12%, transparent)', |
| 247 | + handleStyle: { color: cssv('--phos') }, moveHandleStyle: { color: cssv('--dim') }, |
| 248 | + dataBackground: { lineStyle: { color: cssv('--dim') }, areaStyle: { color: cssv('--grid-split') } }, |
| 249 | + textStyle: { color: cssv('--dim'), fontSize: 9 } } |
| 250 | + ], |
212 | 251 | xAxis: { type: 'time', axisLine: { lineStyle: { color: cssv('--grid-axis') } }, |
213 | 252 | axisLabel: { color: cssv('--dim'), fontSize: 10 }, splitLine: { lineStyle: { color: cssv('--grid-split') } }, |
214 | 253 | axisPointer: { show: true, snap: false, |
|
256 | 295 | }, true); |
257 | 296 | } |
258 | 297 |
|
| 298 | +// 图上拖拽框选一段时间 -> 精确查询那一段(dataZoom 的 datazoom 事件给出可视范围) |
| 299 | +let zoomTimer = null; |
| 300 | +chart.on('dataZoom', () => { |
| 301 | + clearTimeout(zoomTimer); |
| 302 | + zoomTimer = setTimeout(() => { |
| 303 | + const opt = chart.getOption(); |
| 304 | + const dz = opt.dataZoom && opt.dataZoom[0]; |
| 305 | + if (!dz || dz.startValue == null) return; |
| 306 | + const from = Math.floor(dz.startValue / 1000), to = Math.ceil(dz.endValue / 1000); |
| 307 | + if (to - from < 120) return; // 太窄忽略 |
| 308 | + sel = { from, to }; |
| 309 | + $('selReset').style.display = ''; |
| 310 | + render(); |
| 311 | + }, 350); |
| 312 | +}); |
| 313 | + |
259 | 314 | document.querySelectorAll('.rng button[data-m], .rng button[data-d]').forEach(b => b.onclick = () => { |
| 315 | + sel = null; $('selReset').style.display = 'none'; |
260 | 316 | document.querySelectorAll('.rng button[data-m], .rng button[data-d]').forEach(x => x.classList.remove('on')); |
261 | 317 | b.classList.add('on'); |
262 | 318 | if (b.dataset.d) { days = +b.dataset.d; minutes = 0; } |
263 | 319 | else { minutes = +b.dataset.m; days = 0; } |
264 | 320 | render(); |
265 | 321 | }); |
| 322 | +$('selReset').onclick = () => { sel = null; $('selReset').style.display = 'none'; render(); }; |
266 | 323 | document.querySelectorAll('#tfilter button').forEach(b => b.onclick = () => { |
267 | 324 | document.querySelectorAll('#tfilter button').forEach(x => x.classList.remove('on')); |
268 | 325 | b.classList.add('on'); |
269 | 326 | typeFilter = b.dataset.t; |
270 | 327 | render(); |
271 | 328 | }); |
272 | 329 |
|
| 330 | +// 分享的是公开项目主页,不是本地私有面板 |
| 331 | +(function buildShare() { |
| 332 | + const url = 'https://github.com/githubflyideas/pingping'; |
| 333 | + const text = 'pingping — a single-binary link quality oscilloscope. One binary. One graph. Zero guesswork.'; |
| 334 | + const links = { |
| 335 | + X: `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`, |
| 336 | + Facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`, |
| 337 | + Reddit: `https://www.reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent('pingping — modern SmokePing alternative')}`, |
| 338 | + HN: `https://news.ycombinator.com/submitlink?u=${encodeURIComponent(url)}&t=${encodeURIComponent('pingping — single-binary link quality oscilloscope')}` |
| 339 | + }; |
| 340 | + const box = document.getElementById('shareLinks'); |
| 341 | + Object.entries(links).forEach(([k, href]) => { |
| 342 | + const a = document.createElement('a'); |
| 343 | + a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.textContent = k; |
| 344 | + box.appendChild(a); |
| 345 | + }); |
| 346 | + const ig = document.createElement('a'); |
| 347 | + ig.href = '#'; ig.textContent = 'Instagram (copy link)'; |
| 348 | + ig.onclick = e => { e.preventDefault(); navigator.clipboard && navigator.clipboard.writeText(url); |
| 349 | + ig.textContent = 'link copied ✓'; setTimeout(() => ig.textContent = 'Instagram (copy link)', 1500); }; |
| 350 | + box.appendChild(ig); |
| 351 | +})(); |
| 352 | + |
273 | 353 | render(); |
274 | 354 | setInterval(render, 60000); |
275 | 355 | // Long windows: smoke is a hot-data privilege — beyond 24h you get the |
|
0 commit comments