satscraper (OP)
Legendary

Activity: 1596
Merit: 3012
|
 |
Today at 06:42:52 AM Last edit: Today at 08:54:02 AM by satscraper |
|
Credits: theymos - administrator of bitcointalk.org and creator of its Merit system, the data this script visualizes. LoyceV - creator of loyce.club, the merit-history archive this script reads from. Claude - AI assistant, used to help develop this script. // ==UserScript== // @name BitcoinTalk Merit History Plotter 5.9.0 // @namespace https://bitcointalk.org/ // @version 5.9.0 // @description Plot a bitcointalk.org user's received-merit history in a single chart, switchable between By day / By Month / By last 120 days / By last 240 days / By last 360 days, combining loyce.club's history with any newer days on bitcointalk.org's own merit page (including entries shown as "Today"/"Yesterday" instead of a full date), with the account's real username (from its bitcointalk.org profile) shown alongside its ID // @author satscraper // @match https://bitcointalk.org/* // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @connect loyce.club // @run-at document-idle // ==/UserScript== // // Credits: // theymos - admin of bitcointalk.org's Merit system, the data this script visualizes // LoyceV - creator of loyce.club, the merit-history archive this script reads from // Claude - Anthropic's AI assistant, used to help write this script
(function () { 'use strict';
const BTN_ID = 'merit-plotter-btn';
// ---------- UI ----------
function createButton() { if (document.getElementById(BTN_ID)) return; const btn = document.createElement('button'); btn.id = BTN_ID; btn.textContent = 'Plot Merit History'; btn.style.cssText = [ 'position:fixed', 'bottom:20px', 'right:20px', 'z-index:999999', 'padding:10px 14px', 'background:#f7931a', 'color:#fff', 'border:none', 'border-radius:6px', 'font-size:14px', 'font-family:sans-serif', 'cursor:pointer', 'box-shadow:0 2px 6px rgba(0,0,0,.35)' ].join(';'); btn.addEventListener('click', onButtonClick); document.body.appendChild(btn); }
function guessUserIdFromPage() { // Works when currently viewing a profile: index.php?action=profile;u=NNN const m = location.href.match(/[?;&]u=(\d+)/); return m ? m[1] : ''; }
function onButtonClick() { const guess = guessUserIdFromPage(); const id = prompt( 'Enter the bitcointalk.org User ID (the numeric "u=" value from a profile URL, e.g. 3442679):', guess || '' ); if (id === null) return; // cancelled const trimmed = id.trim(); if (!/^\d+$/.test(trimmed)) { alert('Please enter a numeric user ID, e.g. 3442679'); return; } fetchAndPlot(trimmed); }
// ---------- Data fetching ---------- // Two sources are combined: // 1) loyce.club/Merit/history/<id>.html \u2014 a fuller history, but it's a // periodically-updated third-party scrape so it can lag behind by // several days. // 2) bitcointalk.org's own index.php?action=merit;u=<id> page \u2014 only the // last 120 days, but always current. Same-origin, so a plain fetch() // works with the signed-in session automatically, no CORS/GM grant // needed, and it's also where we read the account's real username.
function fetchLoyceHistory(userId) { return new Promise((resolve, reject) => { const url = `https://loyce.club/Merit/history/${userId}.html`; GM_xmlhttpRequest({ method: 'GET', url, onload: function (res) { if (res.status !== 200) { reject(new Error('loyce.club HTTP ' + res.status)); return; } try { resolve(parseMeritHistory(res.responseText)); } catch (e) { reject(e); } }, onerror: function () { reject(new Error('loyce.club network error')); } }); }); }
function fetchBitcointalkMeritPage(userId) { const url = `https://bitcointalk.org/index.php?action=merit;u=${userId}`; return fetch(url, { credentials: 'same-origin' }) .then(res => { if (!res.ok) throw new Error('bitcointalk.org merit page HTTP ' + res.status); return res.text(); }) .then(html => parseBitcointalkMeritPage(html)); }
function fetchBitcointalkUsername(userId) { const url = `https://bitcointalk.org/index.php?action=profile;u=${userId}`; return fetch(url, { credentials: 'same-origin' }) .then(res => { if (!res.ok) throw new Error('bitcointalk.org profile page HTTP ' + res.status); return res.text(); }) .then(html => { const m = html.match(/<title>[^<]*?View the profile of\s+([^<]+?)\s*<\/title>/i); return m ? m[1].trim() : null; }); }
function startOfNextDay(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 0, 0, 0); }
function mergeReceived(loyceReceived, btReceived) { // loyceReceived is sorted ascending, so the last entry is the most recent // date loyce.club has for this user. const lastLoyceDate = loyceReceived.length ? loyceReceived[loyceReceived.length - 1].date : null; const cutoff = lastLoyceDate ? startOfNextDay(lastLoyceDate) : null;
const appendedRecords = cutoff ? btReceived.filter(r => r.date >= cutoff) : btReceived.slice(); const discardedCount = btReceived.length - appendedRecords.length;
const merged = loyceReceived.concat(appendedRecords).sort((a, b) => a.date - b.date); return { merged, appended: appendedRecords.length, discarded: discardedCount, cutoffDate: cutoff }; }
function fetchAndPlot(userId) { Promise.all([ fetchLoyceHistory(userId).catch(e => { console.warn('[Merit Plotter] loyce.club fetch/parse failed:', e); return { received: [], sent: [], candidateLines: 0, parsedLines: 0, loyceClaimedTotal: 0 }; }), fetchBitcointalkMeritPage(userId).catch(e => { console.warn('[Merit Plotter] bitcointalk.org merit page fetch/parse failed:', e); const empty = []; empty.candidateLines = 0; empty.username = null; return empty; }), fetchBitcointalkUsername(userId).catch(e => { console.warn('[Merit Plotter] bitcointalk.org profile page fetch failed:', e); return null; }) ]).then(([loyceParsed, btReceived, profileUsername]) => { const { merged, appended, discarded, cutoffDate } = mergeReceived(loyceParsed.received, btReceived); const computedSum = merged.reduce((s, r) => s + r.merit, 0); const loyceReceivedSum = loyceParsed.received.reduce((s, r) => s + r.merit, 0);
const diagnostics = { loyceCandidateLines: loyceParsed.candidateLines || 0, loyceParsedLines: loyceParsed.parsedLines || 0, loyceReceivedCount: loyceParsed.received.length, loyceReceivedSum, loyceClaimedTotal: loyceParsed.loyceClaimedTotal || 0, // loyce.club's own running total, independent check loyceLastDate: loyceParsed.received.length ? loyceParsed.received[loyceParsed.received.length - 1].dateText : '(none)', cutoffDate: cutoffDate ? cutoffDate.toDateString() : '(no loyce data, no cutoff applied)', btCandidateLines: btReceived.candidateLines || 0, btReceivedCount: btReceived.length, btReceivedSum: btReceived.reduce((s, r) => s + r.merit, 0), appended, discarded, computedSum }; console.log('[Merit Plotter] diagnostics:', diagnostics); if (diagnostics.loyceReceivedSum !== diagnostics.loyceClaimedTotal && diagnostics.loyceClaimedTotal > 0) { console.warn('[Merit Plotter] Our sum of loyce.club received entries (' + diagnostics.loyceReceivedSum + ') does NOT match loyce.club\u2019s own running total (' + diagnostics.loyceClaimedTotal + ') \u2014 this points to a parsing bug (some lines were skipped), not a data-completeness gap.'); }
if (!merged.length) { alert('No received-merit records were found for that user ID from either source. Double-check the ID, and that you\u2019re logged in to bitcointalk.org.'); return; } const username = profileUsername || btReceived.username || null; openPlotTab(userId, { received: merged, diagnostics, username }); }); }
// ---------- Parsing ---------- // loyce.club history pages are plain numbered lines (rendered via <br>),
const MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
const LINE_RE = /(\d+)\.\s+([A-Za-z]{3}\s+\d{1,2}\s+[A-Za-z]{3}\s+\d{4}\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M\s+\S+)\s*:\s*(-?\d+)\s*\(\s*\u03a3\s*(-?\d+)\s*\)\s*(to|from)\s+(.+?)\s*(?:\(Trust list\)\s*)?\(history\)\s*for\s+([^\n\r]*)/g;
function parseMeritHistory(html) { // Normalize <br>/block-end tags to newlines so each entry is its own line, // regardless of whether the page uses <br>, <div>, <p> or <li> per entry. const normalized = html .replace(/<br\s*\/?>/gi, '\n') .replace(/<\/(p|div|li|tr)>/gi, '\n'); const doc = new DOMParser().parseFromString(normalized, 'text/html'); const text = doc.body ? doc.body.textContent : normalized.replace(/<[^>]+>/g, '');
// Diagnostic: how many lines *look* like a numbered entry (start with // "NNN. Weekday DD Mon YYYY") versus how many LINE_RE actually captured. // A gap between these two numbers means some lines have a format LINE_RE // isn't matching -- i.e. a real parsing bug, not a data-completeness issue. const candidateLines = (text.match(/\d+\.\s+[A-Za-z]{3}\s+\d{1,2}\s+[A-Za-z]{3}\s+\d{4}\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M/g) || []).length;
const received = []; const sent = []; let m; LINE_RE.lastIndex = 0; while ((m = LINE_RE.exec(text)) !== null) { const [, , dateStr, amountStr, sigmaStr, direction, otherUser, subject] = m; const date = parseHistoryDate(dateStr); const amount = parseFloat(amountStr); if (!date || isNaN(amount)) continue; const record = { date, merit: amount, dateText: dateStr.trim(), otherUser: otherUser.trim(), subject: subject.trim(), sigma: parseFloat(sigmaStr) }; if (direction.toLowerCase() === 'from') received.push(record); else sent.push(record); }
received.sort((a, b) => a.date - b.date); sent.sort((a, b) => a.date - b.date);
const loyceClaimedTotal = received.length ? Math.max(...received.map(r => r.sigma)) : 0;
return { received, sent, candidateLines, parsedLines: received.length + sent.length, loyceClaimedTotal }; }
function parseHistoryDate(text) { if (!text) return null; const m = text.match(/[A-Za-z]{3}\s+(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})\s+(AM|PM)/); if (!m) return null; const [, day, monStr, year, hh, mm, ss, ampm] = m; const month = MONTHS[monStr]; if (month === undefined) return null; let hour = parseInt(hh, 10) % 12; if (ampm.toUpperCase() === 'PM') hour += 12; const d = new Date(parseInt(year, 10), month, parseInt(day, 10), hour, parseInt(mm, 10), parseInt(ss, 10)); return isNaN(d.getTime()) ? null : d; }
const BT_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; const BT_LINE_RE = /(?:([A-Za-z]+)\s+(\d{1,2}),\s+(\d{4})|(Today|Yesterday))(?:\s+at)?,?\s+(\d{1,2}):(\d{2}):(\d{2})\s+(AM|PM):\s*(\d+)\s+from\s+(.+?)\s+for\s+([^\n\r]*)/g;
function parseBtDate(monthName, day, year, relativeWord, hh, mm, ss, ampm) { let hour = parseInt(hh, 10) % 12; if (ampm.toUpperCase() === 'PM') hour += 12; const minute = parseInt(mm, 10); const second = parseInt(ss, 10); if (monthName) { const monthIdx = BT_MONTHS.indexOf(monthName); if (monthIdx === -1) return null; const d = new Date(parseInt(year, 10), monthIdx, parseInt(day, 10), hour, minute, second); return isNaN(d.getTime()) ? null : d; } // "Today" / "Yesterday": relative to the actual current date this script // is running on (the date the page itself was generated for). const base = new Date(); if (relativeWord.toLowerCase() === 'yesterday') base.setDate(base.getDate() - 1); const d = new Date(base.getFullYear(), base.getMonth(), base.getDate(), hour, minute, second); return isNaN(d.getTime()) ? null : d; }
function extractReceivedSectionText(text) { const headingRe = /(Received|Sent)\s+in\s+the\s+last\s+\d+\s+days/gi; const marks = []; let hm; while ((hm = headingRe.exec(text)) !== null) { marks.push({ type: hm[1].toLowerCase(), index: hm.index, end: headingRe.lastIndex }); } if (!marks.length) return text; // unknown layout: best effort, scan everything let combined = ''; for (let i = 0; i < marks.length; i++) { if (marks[i].type !== 'received') continue; const start = marks[i].end; const end = i + 1 < marks.length ? marks[i + 1].index : text.length; combined += text.slice(start, end) + '\n'; } return combined; }
function extractUsernameFromMeritPage(html) { // Try the <title> tag first: typically something like // "Bitcoin Forum - Merit for <username>" or similar phrasing. let m = html.match(/<title>([^<]*)<\/title>/i); if (m) { const t = m[1]; const nm = t.match(/merit\s+for\s+(.+?)\s*$/i); if (nm) return nm[1].trim(); } // Fallback: the phrase "Merit for X" appearing anywhere on the page, // e.g. as a heading above the Received/Sent sections. m = html.match(/merit\s+for\s+([^<\r\n]{1,60}?)(?:\s*<|\r|\n)/i); if (m) return m[1].trim(); // Fallback: og:title meta tag. m = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i); if (m) { const name = m[1].replace(/merit\s+for\s+/i, '').trim(); if (name) return name; } return null; }
function parseBitcointalkMeritPage(html) { const normalized = html .replace(/<br\s*\/?>/gi, '\n') .replace(/<\/(p|div|li|tr)>/gi, '\n'); const doc = new DOMParser().parseFromString(normalized, 'text/html'); const text = doc.body ? doc.body.textContent : normalized.replace(/<[^>]+>/g, ''); const scanText = extractReceivedSectionText(text);
// Diagnostic: candidate lines (start with a date-looking prefix, either // absolute or the "Today"/"Yesterday" relative form) vs successfully // parsed ones. const candidateLines = (scanText.match(/(?:[A-Za-z]+\s+\d{1,2},\s+\d{4}|Today|Yesterday)(?:\s+at)?,?\s+\d{1,2}:\d{2}:\d{2}\s+(AM|PM)/g) || []).length;
const records = []; let m; BT_LINE_RE.lastIndex = 0; while ((m = BT_LINE_RE.exec(scanText)) !== null) { const [, monthName, day, year, relativeWord, hh, mm, ss, ampm, amountStr, sender, subject] = m; const date = parseBtDate(monthName, day, year, relativeWord, hh, mm, ss, ampm); const merit = parseFloat(amountStr); if (!date || isNaN(merit)) continue; records.push({ date, merit, otherUser: sender.trim(), subject: subject.trim() }); } records.candidateLines = candidateLines; // stowed on the array for convenience records.username = extractUsernameFromMeritPage(html); return records; }
// ---------- Aggregation ----------
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function aggregateByDay(records) { const map = new Map(); // "YYYY-MM-DD" -> { date, total, count } for (const r of records) { const d = r.date; const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); let entry = map.get(key); if (!entry) { entry = { date: new Date(d.getFullYear(), d.getMonth(), d.getDate()), total: 0, count: 0 }; map.set(key, entry); } entry.total += r.merit; entry.count += 1; } return Array.from(map.values()).sort((a, b) => a.date - b.date); }
function aggregateByMonth(records) { const map = new Map(); // "YYYY-MM" -> { date, total, count } for (const r of records) { const d = r.date; const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); let entry = map.get(key); if (!entry) { entry = { date: new Date(d.getFullYear(), d.getMonth(), 1), total: 0, count: 0 }; map.set(key, entry); } entry.total += r.merit; entry.count += 1; } return Array.from(map.values()).sort((a, b) => a.date - b.date); }
function formatDay(d) { return String(d.getDate()).padStart(2, '0') + ' ' + MONTH_NAMES[d.getMonth()] + ' ' + d.getFullYear(); }
function formatMonth(d) { return MONTH_NAMES[d.getMonth()] + ' ' + d.getFullYear(); }
function toSeries(records, aggregateFn, formatFn) { const buckets = aggregateFn(records); return { labels: buckets.map(b => formatFn(b.date)), perEvent: buckets.map(b => b.total), tooltips: buckets.map(b => b.count + ' merit event' + (b.count > 1 ? 's' : '')) }; }
// ---------- Plot tab ----------
function windowRecords(records, days) { if (days == null) return records; // no filtering const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000); return records.filter(r => r.date >= cutoff); }
const MODE_DEFS = [ { key: 'daily', label: 'By day', days: null, aggregate: aggregateByDay, format: formatDay }, { key: 'monthly', label: 'By Month', days: null, aggregate: aggregateByMonth, format: formatMonth }, { key: 'last120', label: 'By last 120 days', days: 120, aggregate: aggregateByDay, format: formatDay }, { key: 'last240', label: 'By last 240 days', days: 240, aggregate: aggregateByDay, format: formatDay }, { key: 'last360', label: 'By last 360 days', days: 360, aggregate: aggregateByDay, format: formatDay } ];
function buildModes(receivedRecords) { const modes = {}; for (const m of MODE_DEFS) { const recs = windowRecords(receivedRecords, m.days); const series = toSeries(recs, m.aggregate, m.format); modes[m.key] = { label: m.label, labels: series.labels, perEvent: series.perEvent, tooltips: series.tooltips, total: recs.reduce((s, r) => s + r.merit, 0) }; } return modes; }
function openPlotTab(userId, parsed) { const win = window.open('', '_blank'); if (!win) { alert('Popup blocked \u2014 please allow popups for bitcointalk.org and click the button again.'); return; } const modes = buildModes(parsed.received); const payload = { modes, receivedTotal: modes.daily.total, // all-time total, for the headline diagnostics: parsed.diagnostics, username: parsed.username || null }; win.document.open(); win.document.write(buildPlotHtml(userId, payload)); win.document.close(); }
function buildPlotHtml(userId, payload) { const dataJson = JSON.stringify(payload).replace(/</g, '\\u003c'); // avoid premature </script> const displayName = 'user ' + userId + (payload.username ? ' (' + payload.username + ')' : '');
return `<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Merit history \u2014 ${displayName}</title> <style> body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background:#111; color:#eee; margin:0; padding:24px; } h1 { font-size:18px; font-weight:600; margin:0 0 4px; } #wrap { max-width:1000px; margin:0 auto; } .meta { color:#999; font-size:13px; margin-bottom:16px; } details.diag { margin-bottom:16px; font-size:12px; color:#aaa; } details.diag summary { cursor:pointer; color:#f7931a; } details.diag table { border-collapse:collapse; margin-top:8px; } details.diag td { padding:2px 10px 2px 0; } details.diag .warn { color:#ff8080; font-weight:600; } details.diag .ok { color:#7dd87d; } .toggle { margin-bottom:16px; } .toggle button { background:#222; color:#ccc; border:1px solid #444; padding:8px 14px; font-size:13px; cursor:pointer; border-radius:6px; margin-right:8px; } .toggle button.active { background:#f7931a; color:#111; border-color:#f7931a; font-weight:600; } .chart-box { background:#1b1b1b; border-radius:8px; padding:16px 16px 8px; margin-bottom:28px; overflow-x:auto; position:relative; } .total-badge { position:sticky; left:8px; display:inline-block; background:#1b1b1b; border:2px solid #f7931a; border-radius:6px; padding:6px 12px; font-size:13px; font-weight:700; color:#f7931a; margin-bottom:10px; } svg { display:block; } .line-path { fill:none; stroke:#4dabf7; stroke-width:2; } .line-area { fill:rgba(77,171,247,0.15); stroke:none; } .dot { fill:#4dabf7; } .dot:hover { fill:#8ecbff; } .value-label-bg { fill:#222; stroke:#4dabf7; stroke-width:1; } .value-label-text { fill:#eee; font-size:10px; font-weight:600; } .axis-label { fill:#999; font-size:10px; } .grid-line { stroke:#333; stroke-width:1; } a { color:#f7931a; } #errorBox { display:none; white-space:pre-wrap; background:#3a1414; color:#ff8080; border:1px solid #661f1f; border-radius:6px; padding:12px; margin-bottom:20px; font-family:monospace; font-size:12px; } </style> </head> <body> <div id="wrap"> <div id="errorBox"></div> <h1 id="title">Merit received over time \u2014 ${displayName}</h1> <div class="meta"> Total received: <span id="totalReceived"></span> \u2014 sources: <a href="https://loyce.club/Merit/history/${userId}.html" target="_blank" rel="noopener">loyce.club</a> + <a href="https://bitcointalk.org/index.php?action=merit;u=${userId}" target="_blank" rel="noopener">bitcointalk.org merit page</a>, merged & de-duplicated </div> <details class="diag" id="diagBox"><summary>Diagnostics (click to expand)</summary><div id="diagContent"></div></details> <div class="toggle" id="modeToggle"></div> <div class="chart-box"> <div class="total-badge" id="totalBadge"></div> <div id="perEvent"></div> </div> </div> <script> window.onerror = function (msg, src, line, col, err) { const box = document.getElementById('errorBox'); box.style.display = 'block'; box.textContent = 'Script error: ' + msg + ' (line ' + line + ')' + (err && err.stack ? '\\n' + err.stack : ''); return false; };
try { const data = ${dataJson}; document.getElementById('totalReceived').textContent = data.receivedTotal;
if (data.diagnostics) { const d = data.diagnostics; const loyceMismatch = d.loyceClaimedTotal > 0 && d.loyceReceivedSum !== d.loyceClaimedTotal; const rows = [ ['loyce.club \u2014 candidate lines found', d.loyceCandidateLines], ['loyce.club \u2014 lines successfully parsed (sent + received)', d.loyceParsedLines], ['loyce.club \u2014 received entries parsed', d.loyceReceivedCount], ['loyce.club \u2014 our sum of received amounts', d.loyceReceivedSum], ['loyce.club\\'s OWN running total (\u03a3, independent check)', d.loyceClaimedTotal], ['loyce.club \u2014 last date it has for this user', d.loyceLastDate], ['bitcointalk.org merit page \u2014 candidate lines found (Received section)', d.btCandidateLines], ['bitcointalk.org merit page \u2014 received entries parsed', d.btReceivedCount], ['bitcointalk.org merit page \u2014 sum of those entries', d.btReceivedSum], ['cutoff \u2014 only bitcointalk.org entries strictly after this date are kept', d.cutoffDate], ['merge \u2014 new entries appended (after cutoff)', d.appended], ['merge \u2014 entries discarded (on/before loyce.club\\'s last date)', d.discarded], ['FINAL \u2014 computed total across merged data', d.computedSum] ]; let html = '<table>' + rows.map(r => '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>').join('') + '</table>'; if (loyceMismatch) { html += '<p class="warn">MISMATCH: our sum of loyce.club received entries (' + d.loyceReceivedSum + ') does not match loyce.club\\'s own running total (' + d.loyceClaimedTotal + '). That points to a parsing bug on lines loyce.club has but this script is dropping \u2014 ' + 'please report this.</p>'; } else if (d.loyceClaimedTotal > 0) { html += '<p class="ok">Our parsing of loyce.club matches loyce.club\\'s own running total exactly.</p>'; } if (d.btCandidateLines > d.btReceivedCount) { html += '<p class="warn">' + (d.btCandidateLines - d.btReceivedCount) + ' line(s) on the bitcointalk.org ' + 'merit page looked like entries but failed to fully parse \u2014 check the console (F12).</p>'; } document.getElementById('diagContent').innerHTML = html; } else { document.getElementById('diagBox').style.display = 'none'; }
const SVG_NS = 'http://www.w3.org/2000/svg'; const MARGIN = { top: 24, right: 10, bottom: 60, left: 40 }; const CHART_H = 420; const POINT_GAP = 36;
function el(tag, attrs) { const e = document.createElementNS(SVG_NS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); return e; }
function clearAndAppend(container, svg) { container.innerHTML = ''; container.appendChild(svg); }
function niceMax(v) { if (v <= 0) return 1; const mag = Math.pow(10, Math.floor(Math.log10(v))); const norm = v / mag; let step; if (norm <= 1) step = 1; else if (norm <= 2) step = 2; else if (norm <= 5) step = 5; else step = 10; return step * mag; }
function renderLineChart(container, labels, values, tooltips) { const n = Math.max(values.length, 1); const width = Math.max(n * POINT_GAP + MARGIN.left + MARGIN.right, 300); const height = CHART_H; const innerW = width - MARGIN.left - MARGIN.right; const innerH = height - MARGIN.top - MARGIN.bottom; const maxVal = niceMax(Math.max(1, ...values));
const svg = el('svg', { width, height, viewBox: '0 0 ' + width + ' ' + height }); const g = el('g', { transform: 'translate(' + MARGIN.left + ',' + MARGIN.top + ')' }); svg.appendChild(g);
const gridCount = 5; for (let i = 0; i <= gridCount; i++) { const y = innerH - (innerH * i) / gridCount; const val = Math.round((maxVal * i) / gridCount); g.appendChild(el('line', { class: 'grid-line', x1: 0, x2: innerW, y1: y, y2: y })); const t = el('text', { class: 'axis-label', x: -6, y: y + 3, 'text-anchor': 'end' }); t.textContent = val; g.appendChild(t); }
const points = values.map((v, i) => { const x = i * POINT_GAP + POINT_GAP / 2; const y = innerH - (v / maxVal) * innerH; return [x, y]; });
if (points.length) { const linePath = 'M ' + points.map(p => p[0] + ' ' + p[1]).join(' L '); const areaPath = linePath + ' L ' + points[points.length - 1][0] + ' ' + innerH + ' L ' + points[0][0] + ' ' + innerH + ' Z'; g.appendChild(el('path', { class: 'line-area', d: areaPath })); g.appendChild(el('path', { class: 'line-path', d: linePath })); }
points.forEach(([x, y], i) => { const dot = el('circle', { class: 'dot', cx: x, cy: y, r: 3 }); const title = el('title', {}); title.textContent = labels[i] + ': ' + values[i] + ' merit' + (values[i] === 1 ? '' : 's') + (tooltips && tooltips[i] ? ' (' + tooltips[i] + ')' : ''); dot.appendChild(title); g.appendChild(dot); });
values.forEach((v, i) => { const [x, y] = points[i]; const text = String(v); const labelW = Math.max(text.length * 7 + 10, 18); const labelH = 16; let labelY = y - 10 - labelH; if (labelY < 0) labelY = y + 10; const labelX = x - labelW / 2; g.appendChild(el('rect', { class: 'value-label-bg', x: labelX, y: labelY, width: labelW, height: labelH, rx: 3 })); const t = el('text', { class: 'value-label-text', x, y: labelY + labelH - 4, 'text-anchor': 'middle' }); t.textContent = text; g.appendChild(t); });
appendXLabels(g, labels, innerH, n); clearAndAppend(container, svg); }
function appendXLabels(g, labels, innerH, n) { const step = Math.max(1, Math.ceil(n / 25)); labels.forEach((lab, i) => { if (i % step !== 0) return; const x = i * POINT_GAP + POINT_GAP / 2; const t = el('text', { class: 'axis-label', x, y: innerH + 14, 'text-anchor': 'end', transform: 'rotate(-60 ' + x + ' ' + (innerH + 14) + ')' }); t.textContent = lab; g.appendChild(t); }); }
const MODE_KEYS = ['daily', 'monthly', 'last120', 'last240', 'last360']; const modeToggle = document.getElementById('modeToggle'); const totalBadge = document.getElementById('totalBadge'); const chartDiv = document.getElementById('perEvent'); const buttons = {};
MODE_KEYS.forEach(key => { const btn = document.createElement('button'); btn.textContent = data.modes[key].label; btn.addEventListener('click', () => renderMode(key)); modeToggle.appendChild(btn); buttons[key] = btn; });
function renderMode(key) { const mode = data.modes[key]; MODE_KEYS.forEach(k => buttons[k].classList.toggle('active', k === key)); document.getElementById('totalReceived').textContent = mode.total; totalBadge.textContent = 'Total received: ' + mode.total + ' merit' + (mode.total === 1 ? '' : 's'); if (!mode.labels.length) { chartDiv.innerHTML = '<p style="color:#777">No received-merit records in this view.</p>'; return; } renderLineChart(chartDiv, mode.labels, mode.perEvent, mode.tooltips); }
renderMode('daily'); } catch (e) { const box = document.getElementById('errorBox'); box.style.display = 'block'; box.textContent = 'Render error: ' + e.message + '\\n' + (e.stack || ''); } <\/script> </body> </html>`; }
// ---------- init ----------
if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('Plot Merit History\u2026', onButtonClick); } createButton(); })();
If you like what this script does, use Tampermonkey browser extension to run it.
|