PageSourceSearch

https://btcminergpt.ai/fee-gauge.js?v=4260

js btcminergpt.ai collected 2026-10-05 12:39:49 UTC 24,481 bytes, 532 lines download raw bytes

1/* ============================================================
2   Zoltar Speaks Rebalancer — Mempool Fee Gauge + Block Context
3   Real-time sat/vB data from mempool.space
4   ============================================================ */
5
6(function () {
7  'use strict';
8
9  const FEE_API = 'https://mempool.space/api/v1/fees/recommended';
10  const BLOCKS_API = 'https://mempool.space/api/v1/blocks';
11  const REFRESH_MS = 1800000; // 30 minutes
12  const NUM_BLOCKS = 6;
13  var isFetchingFees = false;  // re-entry guard
14
15  /* ---- Tier definitions ---- */
16  const TIERS = [
17    { max: 2,   label: 'OPTIMAL',    color: '#39ff9e', bg: 'rgba(57,255,158,.12)' },
18    { max: 5,   label: 'LOW',        color: '#00e0c4', bg: 'rgba(0,224,196,.12)' },
19    { max: 10,  label: 'MODERATE',   color: '#06b6d4', bg: 'rgba(6,182,212,.12)' },
20    { max: 20,  label: 'ELEVATED',   color: '#f5a623', bg: 'rgba(245,166,35,.12)' },
21    { max: 35,  label: 'HIGH',       color: '#ff8c00', bg: 'rgba(255,140,0,.12)' },
22    { max: 50,  label: 'VERY HIGH',  color: '#ff6b35', bg: 'rgba(255,107,53,.12)' },
23    { max: 75,  label: 'INTENSE',    color: '#ff4444', bg: 'rgba(255,68,68,.12)' },
24    { max: 100, label: 'SEVERE',     color: '#e62020', bg: 'rgba(230,32,32,.12)' },
25    { max: 120, label: 'CRITICAL',   color: '#d40000', bg: 'rgba(212,0,0,.12)' },
26    { max: Infinity, label: 'EXTREME', color: '#cc0000', bg: 'rgba(204,0,0,.15)' }
27  ];
28
29  function getTier(satVb) {
30    var v = Math.max(1, satVb);
31    for (var i = 0; i < TIERS.length; i++) {
32      if (v <= TIERS[i].max) return { num: i + 1, ...TIERS[i] };
33    }
34    return { num: 10, ...TIERS[9] };
35  }
36
37  /* ---- Logarithmic scale: 1→-90° … 200→+90° ---- */
38  var LOG_MIN = Math.log(1);
39  var LOG_MAX = Math.log(200);
40  function feeToAngle(satVb) {
41    var clamped = Math.min(Math.max(satVb, 1), 200);
42    var pct = (Math.log(clamped) - LOG_MIN) / (LOG_MAX - LOG_MIN);
43    return -90 + pct * 180;
44  }
45
46  /* ---- SVG arc helper ---- */
47  function arcPath(cx, cy, r, startDeg, endDeg) {
48    var s = (startDeg - 90) * Math.PI / 180;
49    var e = (endDeg - 90)   * Math.PI / 180;
50    var x1 = cx + r * Math.cos(s), y1 = cy + r * Math.sin(s);
51    var x2 = cx + r * Math.cos(e), y2 = cy + r * Math.sin(e);
52    var large = (endDeg - startDeg > 180) ? 1 : 0;
53    return 'M ' + x1 + ' ' + y1 + ' A ' + r + ' ' + r + ' 0 ' + large + ' 1 ' + x2 + ' ' + y2;
54  }
55
56  function valToAngle(v) {
57    var c = Math.min(Math.max(v, 1), 200);
58    return -90 + ((Math.log(c) - LOG_MIN) / (LOG_MAX - LOG_MIN)) * 180;
59  }
60
61  /* ---- Build gauge SVG ---- */
62  function buildGaugeSVG() {
63    var cx = 150, cy = 145, r = 115;
64    var segments = '';
65    var thresholds = [1, 2, 5, 10, 20, 35, 50, 75, 100, 120, 200];
66    for (var i = 0; i < TIERS.length; i++) {
67      var startDeg = valToAngle(thresholds[i]);
68      var endDeg   = valToAngle(thresholds[i + 1]);
69      segments += '<path d="' + arcPath(cx, cy, r, startDeg, endDeg) + '" '
70                + 'fill="none" stroke="' + TIERS[i].color + '" stroke-width="20" '
71                + 'stroke-linecap="round" opacity="0.8"/>';
72    }
73
74    var tickVals = [1, 2, 3, 5, 7, 10, 15, 20, 30, 50, 75, 100, 150, 200];
75    var majorTicks = [1, 2, 5, 10, 20, 50, 100, 200];
76    var ticks = '';
77    for (var ti = 0; ti < tickVals.length; ti++) {
78      var tv = tickVals[ti];
79      var isMajor = majorTicks.indexOf(tv) !== -1;
80      var deg = (valToAngle(tv) - 90) * Math.PI / 180;
81      var inner = isMajor ? r - 32 : r - 24;
82      var outer = r - 12;
83      ticks += '<line x1="' + (cx + inner * Math.cos(deg)) + '" y1="' + (cy + inner * Math.sin(deg)) + '" '
84             + 'x2="' + (cx + outer * Math.cos(deg)) + '" y2="' + (cy + outer * Math.sin(deg)) + '" '
85             + 'stroke="rgba(255,255,255,' + (isMajor ? '0.6' : '0.25') + ')" stroke-width="' + (isMajor ? '2' : '1') + '"/>';
86    }
87
88    var labelVals = [1, 2, 5, 10, 20, 50, 100, 200];
89    var labels = '';
90    labelVals.forEach(function (v) {
91      var deg = (valToAngle(v) - 90) * Math.PI / 180;
92      var lr = r - 44;
93      labels += '<text x="' + (cx + lr * Math.cos(deg)) + '" y="' + (cy + lr * Math.sin(deg) + 4) + '" '
94              + 'fill="rgba(255,255,255,0.6)" font-size="9" text-anchor="middle" font-family="\'Press Start 2P\', monospace">'
95              + v + '</text>';
96    });
97
98    // Needle — triangle pointer for visibility
99    return '<svg class="fee-gauge-svg" viewBox="0 0 300 180" xmlns="http://www.w3.org/2000/svg">'
100         + '<defs>'
101         + '<filter id="needleGlow"><feGaussianBlur stdDeviation="4" result="g"/>'
102         + '<feMerge><feMergeNode in="g"/><feMergeNode in="SourceGraphic"/></feMerge></filter>'
103         + '<linearGradient id="needleGrad" x1="0" y1="0" x2="0" y2="1">'
104         + '<stop offset="0%" stop-color="#ffffff"/>'
105         + '<stop offset="100%" stop-color="#ff6b35"/>'
106         + '</linearGradient>'
107         + '</defs>'
108         + segments + ticks + labels
109         // Needle: a tapered polygon for real visibility
110         + '<g id="feeNeedle" filter="url(#needleGlow)">'
111         + '<polygon points="' + cx + ',' + (cy - r + 24) + ' ' + (cx - 5) + ',' + cy + ' ' + (cx + 5) + ',' + cy + '" '
112         + 'fill="url(#needleGrad)" opacity="0.95"/>'
113         + '<circle cx="' + cx + '" cy="' + cy + '" r="8" fill="#1a1a2e" stroke="#ff6b35" stroke-width="2.5"/>'
114         + '<circle cx="' + cx + '" cy="' + cy + '" r="3" fill="#ffffff"/>'
115         + '</g>'
116         + '</svg>';
117  }
118
119  /* ==== BLOCK FEE CONTEXT CHART ==== */
120  function fmtSats(n) {
121    if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
122    if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
123    return String(Math.round(n));
124  }
125  function fmtK(n) {
126    return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
127  }
128  function timeAgo(ts) {
129    var s = Math.max(0, Math.floor(Date.now() / 1000) - ts);
130    if (s < 60) return 'just now';
131    if (s < 3600) return Math.floor(s / 60) + 'm ago';
132    return Math.floor(s / 3600) + 'h ago';
133  }
134
135  function buildBlockChart(blocks) {
136    if (!blocks || blocks.length === 0) return '';
137
138    var data = blocks.slice(0, NUM_BLOCKS).reverse();
139
140    var W = 720, H = 385;
141    var pad = { top: 58, right: 72, bottom: 82, left: 92 };
142    var plotW = W - pad.left - pad.right;
143    var plotH = H - pad.top - pad.bottom;
144
145    var fees = data.map(function (b) {
146      return (b.extras && b.extras.totalFees ? b.extras.totalFees : 0);
147    });
148    var txCounts = data.map(function (b) { return b.tx_count || 0; });
149    var heights = data.map(function (b) { return b.height; });
150    var avgRates = data.map(function (b) {
151      return b.extras && b.extras.avgFeeRate ? Math.round(b.extras.avgFeeRate) : 0;
152    });
153    var timestamps = data.map(function (b) { return b.timestamp || 0; });
154
155    var maxFee = Math.max.apply(null, fees) * 1.35 || 100000;
156    var maxTx  = Math.max.apply(null, txCounts) * 1.35 || 1;
157    var gap  = plotW / data.length;
158    var barW = gap * 0.4;
159
160    // Helper: dark pill behind a text label
161    function pill(cx, cy, text, fontSize) {
162      var charW = fontSize * 0.72;
163      var pw = text.length * charW + 8;
164      var ph = fontSize + 6;
165      return '<rect x="' + (cx - pw / 2) + '" y="' + (cy - ph + 2) + '" width="' + pw + '" height="' + ph + '" rx="4" fill="rgba(10,10,30,0.85)"/>';
166    }
167
168    var svg = '<svg class="block-chart-svg" viewBox="0 0 ' + W + ' ' + H + '" xmlns="http://www.w3.org/2000/svg">';
169
170    // Defs
171    svg += '<defs><linearGradient id="barGrad" x1="0" y1="0" x2="0" y2="1">'
172         + '<stop offset="0%" stop-color="#39ff9e" stop-opacity="0.9"/>'
173         + '<stop offset="100%" stop-color="#06b6d4" stop-opacity="0.5"/>'
174         + '</linearGradient>'
175         + '<filter id="whiteGlow"><feDropShadow dx="0" dy="0" stdDeviation="2" flood-color="#fff" flood-opacity="0.7"/></filter>'
176         + '</defs>';
177
178    // Grid lines + axes
179    var gridSteps = 4;
180    for (var g = 0; g <= gridSteps; g++) {
181      var gy = pad.top + plotH - (g / gridSteps) * plotH;
182      svg += '<line x1="' + pad.left + '" y1="' + gy + '" x2="' + (W - pad.right) + '" y2="' + gy + '" '
183           + 'stroke="rgba(255,255,255,' + (g === 0 ? '0.15' : '0.06') + ')" stroke-width="1"/>';
184      svg += '<text x="' + (pad.left - 10) + '" y="' + (gy + 4) + '" fill="rgba(57,255,158,0.6)" '
185           + 'font-size="9" text-anchor="end" font-family="\'Press Start 2P\', monospace">'
186           + fmtSats(maxFee * g / gridSteps) + '</text>';
187      svg += '<text x="' + (W - pad.right + 10) + '" y="' + (gy + 4) + '" fill="rgba(255,165,0,0.6)" '
188           + 'font-size="9" text-anchor="start" font-family="\'Press Start 2P\', monospace">'
189           + fmtK(Math.round(maxTx * g / gridSteps)) + '</text>';
190    }
191
192    // Axis titles
193    svg += '<text x="' + pad.left + '" y="' + (pad.top - 18) + '" fill="rgba(57,255,158,0.7)" '
194         + 'font-size="9" text-anchor="start" font-family="\'Press Start 2P\', monospace">FEES (sats)</text>';
195    svg += '<text x="' + (W - pad.right) + '" y="' + (pad.top - 18) + '" fill="rgba(255,165,0,0.7)" '
196         + 'font-size="9" text-anchor="end" font-family="\'Press Start 2P\', monospace">TX COUNT</text>';
197
198    // Pre-compute positions
199    var barTops = [], dotYs = [];
200    for (var p = 0; p < data.length; p++) {
201      var bH = Math.max(2, (fees[p] / maxFee) * plotH);
202      barTops.push(pad.top + plotH - bH);
203      dotYs.push(pad.top + plotH - (txCounts[p] / maxTx) * plotH);
204    }
205
206    // --- TX layer first (renders underneath) ---
207
208    // TX line + area fill
209    var linePoints = [];
210    for (var j = 0; j < data.length; j++) {
211      linePoints.push((pad.left + j * gap + gap / 2) + ',' + dotYs[j]);
212    }
213    var areaStr = linePoints.join(' ')
214      + ' ' + (pad.left + (data.length - 1) * gap + gap / 2) + ',' + (pad.top + plotH)
215      + ' ' + (pad.left + gap / 2) + ',' + (pad.top + plotH);
216    svg += '<polygon points="' + areaStr + '" fill="rgba(255,165,0,0.05)"/>';
217    svg += '<polyline points="' + linePoints.join(' ') + '" '
218         + 'fill="none" stroke="rgba(255,165,0,0.12)" stroke-width="8" stroke-linejoin="round" stroke-linecap="round"/>';
219    svg += '<polyline points="' + linePoints.join(' ') + '" '
220         + 'fill="none" stroke="#ffa500" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" opacity="0.9"/>';
221
222    // TX dots + labels — pinned to top band of chart
223    for (var d = 0; d < data.length; d++) {
224      var dx = pad.left + d * gap + gap / 2;
225      var dy = dotYs[d];
226      var txStr = fmtK(txCounts[d]);
227      var txY = pad.top + 12;
228
229      svg += '<circle cx="' + dx + '" cy="' + dy + '" r="6" fill="#1a1a2e" stroke="#ffa500" stroke-width="2.5"/>';
230      svg += '<line x1="' + dx + '" y1="' + (txY + 4) + '" x2="' + dx + '" y2="' + (dy - 8) + '" stroke="rgba(255,165,0,0.2)" stroke-width="1" stroke-dasharray="3,3"/>';
231      svg += pill(dx, txY, txStr, 10);
232      svg += '<text x="' + dx + '" y="' + txY + '" fill="#ffa500" '
233           + 'font-size="10" text-anchor="middle" font-family="\'Press Start 2P\', monospace" font-weight="bold">'
234           + txStr + '</text>';
235    }
236
237    // --- Fee layer on top (renders over TX labels) ---
238
239    // Bars + fee labels
240    for (var i = 0; i < data.length; i++) {
241      var x = pad.left + i * gap + (gap - barW) / 2;
242      var barH = Math.max(2, (fees[i] / maxFee) * plotH);
243      var y = barTops[i];
244      var tier = getTier(avgRates[i] || 1);
245
246      svg += '<rect x="' + x + '" y="' + y + '" width="' + barW + '" height="' + barH + '" '
247           + 'rx="4" fill="' + tier.color + '" opacity="0.7">'
248           + '<animate attributeName="height" from="0" to="' + barH + '" dur="0.5s" fill="freeze"/>'
249           + '<animate attributeName="y" from="' + (pad.top + plotH) + '" to="' + y + '" dur="0.5s" fill="freeze"/>'
250           + '</rect>';
251
252      // Fee label with dark pill
253      var feeStr = fmtSats(fees[i]);
254      var feeLblY = y - 8;
255      svg += pill(x + barW / 2, feeLblY, feeStr, 10);
256      svg += '<text x="' + (x + barW / 2) + '" y="' + feeLblY + '" fill="' + tier.color + '" '
257           + 'font-size="10" text-anchor="middle" font-family="\'Press Start 2P\', monospace" font-weight="bold" filter="url(#whiteGlow)">'
258           + feeStr + '</text>';
259
260      // X-axis: Block height
261      svg += '<text x="' + (x + barW / 2) + '" y="' + (pad.top + plotH + 18) + '" fill="rgba(255,255,255,0.65)" '
262           + 'font-size="9" text-anchor="middle" font-family="\'Press Start 2P\', monospace">'
263           + heights[i] + '</text>';
264
265      // X-axis row 2: avg rate
266      svg += '<text x="' + (x + barW / 2) + '" y="' + (pad.top + plotH + 36) + '" fill="' + tier.color + '" '
267           + 'font-size="8" text-anchor="middle" font-family="\'Press Start 2P\', monospace" opacity="0.85">'
268           + avgRates[i] + ' sat/vB</text>';
269
270      // X-axis row 3: time ago
271      svg += '<text x="' + (x + barW / 2) + '" y="' + (pad.top + plotH + 52) + '" fill="rgba(255,255,255,0.4)" '
272           + 'font-size="7" text-anchor="middle" font-family="\'Press Start 2P\', monospace">'
273           + timeAgo(timestamps[i]) + '</text>';
274    }
275
276    // Legend
277    var legendY = pad.top + plotH + 68;
278    svg += '<rect x="' + (W / 2 - 130) + '" y="' + (legendY - 7) + '" width="14" height="10" rx="2" fill="#39ff9e" opacity="0.7"/>';
279    svg += '<text x="' + (W / 2 - 112) + '" y="' + (legendY + 2) + '" fill="rgba(255,255,255,0.5)" '
280         + 'font-size="8" font-family="\'Press Start 2P\', monospace">Fees (sats)</text>';
281    svg += '<line x1="' + (W / 2 + 16) + '" y1="' + (legendY - 2) + '" '
282         + 'x2="' + (W / 2 + 38) + '" y2="' + (legendY - 2) + '" stroke="#ffa500" stroke-width="2.5"/>';
283    svg += '<circle cx="' + (W / 2 + 27) + '" cy="' + (legendY - 2) + '" r="3.5" fill="#1a1a2e" stroke="#ffa500" stroke-width="1.5"/>';
284    svg += '<text x="' + (W / 2 + 44) + '" y="' + (legendY + 2) + '" fill="rgba(255,255,255,0.5)" '
285         + 'font-size="8" font-family="\'Press Start 2P\', monospace">Tx Count</text>';
286
287    svg += '</svg>';
288    return svg;
289  }
290
291  /* ---- Apply data-color attributes via JS (CSP-safe, no inline style= needed) ---- */
292  function applyColors(root) {
293    root.querySelectorAll('[data-c]').forEach(function(e) { e.style.color = e.getAttribute('data-c'); });
294    root.querySelectorAll('[data-bg]').forEach(function(e) { e.style.background = e.getAttribute('data-bg'); });
295    root.querySelectorAll('[data-bc]').forEach(function(e) { e.style.borderColor = e.getAttribute('data-bc'); });
296    root.querySelectorAll('[data-bs]').forEach(function(e) { e.style.boxShadow = e.getAttribute('data-bs'); });
297  }
298
299  /* ---- Build insight summary from block data ---- */
300  function buildInsight(blocks) {
301    if (!blocks || blocks.length < 2) return '';
302    var data = blocks.slice(0, NUM_BLOCKS).reverse();
303
304    var fees = data.map(function (b) { return b.extras && b.extras.totalFees ? b.extras.totalFees : 0; });
305    var txs  = data.map(function (b) { return b.tx_count || 0; });
306    var rates = data.map(function (b) { return b.extras && b.extras.avgFeeRate ? b.extras.avgFeeRate : 0; });
307
308    var avgTx  = txs.reduce(function (a, b) { return a + b; }, 0) / txs.length;
309    var avgRate = rates.reduce(function (a, b) { return a + b; }, 0) / rates.length;
310    var latestRate = rates[rates.length - 1];
311    var feeTrend = fees[fees.length - 1] > fees[0] * 1.1 ? 'rising' : fees[fees.length - 1] < fees[0] * 0.9 ? 'falling' : 'stable';
312    var txTrend  = txs[txs.length - 1] > txs[0] * 1.1 ? 'rising' : txs[txs.length - 1] < txs[0] * 0.9 ? 'falling' : 'stable';
313
314    var arrows = { rising: '\u2191', falling: '\u2193', stable: '\u2192' };
315    var trendColors = { rising: '#ff6b35', falling: '#39ff9e', stable: '#06b6d4' };
316
317    var signal, signalColor, signalIcon;
318    if (latestRate <= 5 && feeTrend !== 'rising') {
319      signal = 'CLEAR TO TRANSACT';
320      signalColor = '#39ff9e';
321      signalIcon = '\u2713';
322    } else if (latestRate <= 15) {
323      signal = 'MODERATE ACTIVITY';
324      signalColor = '#06b6d4';
325      signalIcon = '\u25CF';
326    } else if (latestRate <= 35) {
327      signal = 'ELEVATED \u2014 Consider Waiting';
328      signalColor = '#f5a623';
329      signalIcon = '\u25B2';
330    } else {
331      signal = 'HIGH CONGESTION \u2014 Delay';
332      signalColor = '#ff4444';
333      signalIcon = '\u26A0';
334    }
335
336    return '<div class="block-insight">'
337         + '<div class="block-insight-signal" data-c="' + signalColor + '" data-bc="' + signalColor + '40" data-bg="' + signalColor + '08">'
338         + '<span class="block-insight-icon" data-bg="' + signalColor + '20">' + signalIcon + '</span>'
339         + signal
340         + '</div>'
341         + '<div class="block-insight-stats">'
342         + '<div class="block-insight-stat">'
343         + '<span class="block-insight-stat-label">Avg Fee Rate</span>'
344         + '<span class="block-insight-stat-value" data-c="' + getTier(avgRate).color + '">' + avgRate.toFixed(1) + ' <small>sat/vB</small></span>'
345         + '</div>'
346         + '<div class="block-insight-stat">'
347         + '<span class="block-insight-stat-label">Fee Trend</span>'
348         + '<span class="block-insight-stat-value" data-c="' + trendColors[feeTrend] + '">' + arrows[feeTrend] + ' ' + feeTrend + '</span>'
349         + '</div>'
350         + '<div class="block-insight-stat">'
351         + '<span class="block-insight-stat-label">Tx Volume</span>'
352         + '<span class="block-insight-stat-value" data-c="' + trendColors[txTrend] + '">' + arrows[txTrend] + ' ' + txTrend + '</span>'
353         + '</div>'
354         + '<div class="block-insight-stat">'
355         + '<span class="block-insight-stat-label">Avg Txs/Block</span>'
356         + '<span class="block-insight-stat-value" data-c="#ffa500">' + fmtK(Math.round(avgTx)) + '</span>'
357         + '</div>'
358         + '</div></div>';
359  }
360
361  /* ---- Active tab state ---- */
362  var activeKey = 'fastestFee';
363  var latestData = null;
364
365  var KEY_LABELS = {
366    fastestFee:  'Next Block',
367    halfHourFee: '~30 min',
368    hourFee:     '~60 min',
369    economyFee:  'Economy',
370    minimumFee:  'Minimum'
371  };
372
373  /* ---- Render gauge ---- */
374  function render(data) {
375    latestData = data;
376    var sat = data[activeKey] || 1;
377    var tier = getTier(sat);
378    var angle = feeToAngle(sat);
379
380    // Needle
381    var needle = document.getElementById('feeNeedle');
382    if (needle) needle.style.transform = 'rotate(' + angle + 'deg)';
383
384    // Center readout
385    var readout = document.getElementById('feeReadout');
386    if (readout) {
387      readout.textContent = sat + ' sat/vB';
388      readout.style.color = tier.color;
389      readout.style.textShadow = '0 0 20px ' + tier.color + ',0 0 40px ' + tier.color + '80';
390    }
391
392    // Tier badge — add context when fees are very low
393    var badge = document.getElementById('feeTierBadge');
394    if (badge) {
395      var badgeText = 'TIER ' + tier.num + ' \u2014 ' + tier.label;
396      if (sat <= 2) badgeText += ' \u26A1 Send Now!';
397      badge.textContent = badgeText;
398      badge.style.color = tier.color;
399      badge.style.borderColor = tier.color;
400      badge.style.background = tier.bg;
401      badge.style.boxShadow = '0 0 20px ' + tier.color + '40, inset 0 0 10px ' + tier.color + '20';
402    }
403
404    // Stats row — highlight differences even when fees are similar
405    var stats = document.getElementById('feeStatsRow');
406    if (stats) {
407      var allSame = data.fastestFee === data.halfHourFee && data.halfHourFee === data.hourFee
408                 && data.hourFee === data.economyFee && data.economyFee === data.minimumFee;
409      var html = '';
410      if (allSame) {
411        html += '<div class="fee-stat-uniform">All priority levels: <strong data-c="' + tier.color + '">' + sat + ' sat/vB</strong> \u2014 Mempool is nearly empty</div>';
412      }
413      ['fastestFee', 'halfHourFee', 'hourFee', 'economyFee', 'minimumFee'].forEach(function (k) {
414        var v = data[k] || '\u2014';
415        var t = getTier(data[k] || 1);
416        var isActive = k === activeKey ? ' fee-stat-active' : '';
417        html += '<button class="fee-stat-btn' + isActive + '" data-feekey="' + k + '">'
418             +  '<span class="fee-stat-label">' + KEY_LABELS[k] + '</span>'
419             +  '<span class="fee-stat-value" data-c="' + t.color + '">' + v + ' <small>sat/vB</small></span>'
420             +  '</button>';
421      });
422      stats.innerHTML = html;
423      applyColors(stats);
424    }
425  }
426
427  /* ---- Render block chart ---- */
428  function renderBlockChart(blocks) {
429    var el = document.getElementById('blockChartArea');
430    if (!el) return;
431    el.innerHTML =
432      '<div class="block-chart-title">TRANSACTIONS vs. FEES — Last ' + NUM_BLOCKS + ' Blocks</div>'
433    + buildBlockChart(blocks)
434    + buildInsight(blocks);    applyColors(el);  }
435
436  /* ---- Fetch fees + blocks ---- */
437  function fetchAll() {
438    if (isFetchingFees) {
439      console.log('Fee gauge already fetching, skipping duplicate call');
440      return;
441    }
442    isFetchingFees = true;
443    var controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
444    var opts = controller ? { signal: controller.signal }
444 : {};
445    var timeout = controller ? setTimeout(function () { controller.abort(); }, 12000) : null;
446
447    Promise.all([
448      fetch(FEE_API, opts).then(function (r) { return r.ok ? r.json() : null; }),
449      fetch(BLOCKS_API, opts).then(function (r) { return r.ok ? r.json() : null; })
450    ])
451    .then(function (results) {
452      var feeData = results[0];
453      var blocksData = results[1];
454      if (feeData) render(feeData);
455      if (blocksData) renderBlockChart(blocksData);
456    })
457    .catch(function () {
458      var readout = document.getElementById('feeReadout');
459      if (readout) readout.textContent = 'Awaiting data…';
460    })
461    .finally(function () {
462      if (timeout) clearTimeout(timeout);
463      isFetchingFees = false;
464    });
465  }
466
467  /* ---- Init ---- */
468  function init() {
469    var container = document.getElementById('feeGaugeWidget');
470    if (!container) return;
471
472    container.innerHTML =
473      '<!-- Zoltar Oracle Background -->'
474    + '<div class="zoltar-bg" aria-hidden="true">'
475    + '  <div class="zoltar-crystal-ball"></div>'
476    + '  <div class="zoltar-wisp zoltar-wisp-1"></div>'
477    + '  <div class="zoltar-wisp zoltar-wisp-2"></div>'
478    + '  <div class="zoltar-wisp zoltar-wisp-3"></div>'
479    + '  <div class="zoltar-ray zoltar-ray-1"></div>'
480    + '  <div class="zoltar-ray zoltar-ray-2"></div>'
481    + '  <div class="zoltar-ray zoltar-ray-3"></div>'
482    + '  <div class="zoltar-ray zoltar-ray-4"></div>'
483    + '  <div class="zoltar-spark zoltar-spark-1"></div>'
484    + '  <div class="zoltar-spark zoltar-spark-2"></div>'
485    + '  <div class="zoltar-spark zoltar-spark-3"></div>'
486    + '  <div class="zoltar-spark zoltar-spark-4"></div>'
487    + '  <div class="zoltar-spark zoltar-spark-5"></div>'
488    + '  <div class="zoltar-spark zoltar-spark-6"></div>'
489    + '  <div class="zoltar-spark zoltar-spark-7"></div>'
490    + '  <div class="zoltar-spark zoltar-spark-8"></div>'
491    + '</div>'
492    + '<div class="fee-gauge-header">'
493    + '  <div class="fee-gauge-title">🔮 ZOLTAR SPEAKS REBALANCER</div>'
494    + '  <div class="fee-gauge-subtitle">Mempool Fee Data | Live Feed</div>'
495    + '</div>'
496    + '<div class="fee-gauge-body">'
497    + '  <div class="fee-gauge-dial">'
498    +      buildGaugeSVG()
499    + '    <div class="fee-gauge-readout" id="feeReadout">Loading…</div>'
500    + '  </div>'
501    + '  <div class="fee-tier-badge" id="feeTierBadge">—</div>'
502    + '</div>'
503    + '<div class="fee-stats-row" id="feeStatsRow"></div>'
504    + '<div class="block-chart-area" id="blockChartArea"></div>'
505    + '<div class="fee-gauge-footer">'
506    + '  <svg class="fee-gauge-footer-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'
507    + '    <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>'
508    + '  </svg>'
509    + '  <span>Data Source: mempool.space | Updated every 30 min</span>'
510    + '</div>';
511
512    // Delegate clicks on stat buttons
513    var statsRow = document.getElementById('feeStatsRow');
514    if (statsRow) {
515      statsRow.addEventListener('click', function (e) {
516        var btn = e.target.closest('.fee-stat-btn');
517        if (!btn) return;
518        activeKey = btn.getAttribute('data-feekey');
519        if (latestData) render(latestData);
520      });
521    }
522
523    fetchAll();
524    setInterval(fetchAll, REFRESH_MS);
525  }
526
527  if (document.readyState === 'loading') {
528    document.addEventListener('DOMContentLoaded', init);
529  } else {
530    init();
531  }
532})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.