feat: stacked per-wallet area chart for Avg BTC Acquired/Day
This commit is contained in:
92
index.html
92
index.html
@ -1699,64 +1699,104 @@ function setupSatsCard(cutoff) {
|
|||||||
});
|
});
|
||||||
const sortedDates = Object.keys(allDates).sort();
|
const sortedDates = Object.keys(allDates).sort();
|
||||||
|
|
||||||
/* Per-wallet: forward-fill balance across all dates */
|
const wallets = wm.getWallets();
|
||||||
const dailyBalances = {};
|
/* Per-wallet forward-fill */
|
||||||
|
const walletDailyBalances = {};
|
||||||
selectedAddr.forEach(addr => {
|
selectedAddr.forEach(addr => {
|
||||||
|
walletDailyBalances[addr] = {};
|
||||||
const snaps = addressSnapshots[addr] || [];
|
const snaps = addressSnapshots[addr] || [];
|
||||||
const dated = snaps.map(s => ({
|
const dated = snaps.map(s => ({
|
||||||
dateStr: s.block_timestamp.slice(0, 10),
|
dateStr: s.block_timestamp.slice(0, 10),
|
||||||
btcVal: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(s?.collateral?.cbBTC, 'cbBTC')
|
btcVal: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(s?.collateral?.cbBTC, 'cbBTC')
|
||||||
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
|
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
|
||||||
|
|
||||||
let balance = 0;
|
let balance = 0;
|
||||||
for (const ds of sortedDates) {
|
for (const ds of sortedDates) {
|
||||||
const match = dated.find(d => d.dateStr === ds);
|
const match = dated.find(d => d.dateStr === ds);
|
||||||
if (match) balance = match.btcVal;
|
if (match) balance = match.btcVal;
|
||||||
if (!dailyBalances[ds]) dailyBalances[ds] = 0;
|
walletDailyBalances[addr][ds] = balance;
|
||||||
dailyBalances[ds] += balance;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Sort by date, compute daily delta (acquisition) */
|
/* Per-wallet daily deltas */
|
||||||
const dailyDeltas = {};
|
const walletDailyDeltas = {};
|
||||||
let prevTotal = 0;
|
selectedAddr.forEach(addr => {
|
||||||
sortedDates.forEach(ds => {
|
walletDailyDeltas[addr] = {};
|
||||||
dailyDeltas[ds] = dailyBalances[ds] - prevTotal;
|
let prev = 0;
|
||||||
prevTotal = dailyBalances[ds];
|
for (const ds of sortedDates) {
|
||||||
|
const b = walletDailyBalances[addr][ds] || 0;
|
||||||
|
walletDailyDeltas[addr][ds] = b - prev;
|
||||||
|
prev = b;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Compute total sats acquired and days elapsed since first snapshot */
|
|
||||||
const firstUTC = sortedDates.length > 0 ? Date.UTC(new Date(sortedDates[0]).getUTCFullYear(), new Date(sortedDates[0]).getUTCMonth(), new Date(sortedDates[0]).getUTCDate()) : Date.now();
|
const firstUTC = sortedDates.length > 0 ? Date.UTC(new Date(sortedDates[0]).getUTCFullYear(), new Date(sortedDates[0]).getUTCMonth(), new Date(sortedDates[0]).getUTCDate()) : Date.now();
|
||||||
const lastUTC = sortedDates.length > 0 ? Date.UTC(new Date(sortedDates[sortedDates.length - 1]).getUTCFullYear(), new Date(sortedDates[sortedDates.length - 1]).getUTCMonth(), new Date(sortedDates[sortedDates.length - 1]).getUTCDate()) : Date.now();
|
const lastUTC = sortedDates.length > 0 ? Date.UTC(new Date(sortedDates[sortedDates.length - 1]).getUTCFullYear(), new Date(sortedDates[sortedDates.length - 1]).getUTCMonth(), new Date(sortedDates[sortedDates.length - 1]).getUTCDate()) : Date.now();
|
||||||
const daysElapsed = Math.max(1, Math.ceil((lastUTC - firstUTC) / DAY_MS));
|
const daysElapsed = Math.max(1, Math.ceil((lastUTC - firstUTC) / DAY_MS));
|
||||||
const totalBtc = sortedDates.length > 0 ? dailyBalances[sortedDates[sortedDates.length - 1]] : 0;
|
|
||||||
const totalSats = Math.round(totalBtc * 1e8);
|
|
||||||
const avgSatsPerDay = Math.round(totalSats / daysElapsed);
|
|
||||||
|
|
||||||
/* Build continuous daily series: one data point per day */
|
/* Per-wallet series: running avg sats/day */
|
||||||
const filteredSats = [];
|
const series = selectedAddr.map(addr => {
|
||||||
|
const w = wallets.find(x => x.address === addr);
|
||||||
|
const nickname = w ? (w.nickname || addr.slice(0, 6)) : addr.slice(0, 6);
|
||||||
|
const data = [];
|
||||||
let runningTotalBtc = 0;
|
let runningTotalBtc = 0;
|
||||||
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
|
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
|
||||||
const ds = new Date(t).toISOString().slice(0, 10);
|
const ds = new Date(t).toISOString().slice(0, 10);
|
||||||
runningTotalBtc += dailyDeltas[ds] || 0;
|
runningTotalBtc += walletDailyDeltas[addr][ds] || 0;
|
||||||
const daysSoFar = Math.max(1, Math.ceil((t - firstUTC) / DAY_MS) + 1);
|
const daysSoFar = Math.max(1, Math.ceil((t - firstUTC) / DAY_MS) + 1);
|
||||||
const runningAvgSats = Math.round((runningTotalBtc * 1e8) / daysSoFar);
|
const runningAvgSats = Math.round((runningTotalBtc * 1e8) / daysSoFar);
|
||||||
filteredSats.push([t, runningAvgSats]);
|
if (t >= cutoff) {
|
||||||
|
data.push([t, runningAvgSats]);
|
||||||
}
|
}
|
||||||
const latestAvg = filteredSats.length > 0 ? filteredSats[filteredSats.length - 1][1] : 0;
|
}
|
||||||
document.getElementById('avg-sats-val').innerText = new Intl.NumberFormat('en-US').format(latestAvg);
|
return { name: nickname, data };
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestTotal = sortedDates.length > 0 ? (() => {
|
||||||
|
let s = 0;
|
||||||
|
selectedAddr.forEach(addr => { s += walletDailyBalances[addr][sortedDates[sortedDates.length - 1]] || 0; });
|
||||||
|
return s;
|
||||||
|
})() : 0;
|
||||||
|
const avgSatsPerDay = Math.round((latestTotal * 1e8) / daysElapsed);
|
||||||
|
document.getElementById('avg-sats-val').innerText = new Intl.NumberFormat('en-US').format(avgSatsPerDay);
|
||||||
|
|
||||||
|
const colors = selectedAddr.map(addr => getColorForWallet(addr));
|
||||||
|
const gradientStops = colors.map(c => [
|
||||||
|
{ offset: 0, color: c, opacity: 0.35 },
|
||||||
|
{ offset: 100, color: c, opacity: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
chart: { id: 'instance-sats', type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, sparkline: { enabled: false }, zoom: { enabled: true, type: 'x', autoScaleYaxis: true }, animations: { enabled: false } },
|
chart: { id: 'instance-sats', type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, sparkline: { enabled: false }, zoom: { enabled: true, type: 'x', autoScaleYaxis: true }, animations: { enabled: false }, stacked: true },
|
||||||
series: [{ name: 'Avg Sats/Day', data: filteredSats }],
|
stackType: 'normal',
|
||||||
|
series: series,
|
||||||
dataLabels: { enabled: false },
|
dataLabels: { enabled: false },
|
||||||
colors: [orangeBrandColor],
|
colors: colors,
|
||||||
stroke: { curve: 'smooth', width: 2 },
|
stroke: { curve: 'smooth', width: 2 },
|
||||||
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: [{ offset: 0, color: orangeBrandColor, opacity: 0.16 }, { offset: 100, color: orangeBrandColor, opacity: 0 }] } },
|
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: gradientStops } },
|
||||||
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
|
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
|
||||||
markers: { size: 0, hover: { size: 5 } },
|
markers: { size: 0, hover: { size: 5 } },
|
||||||
xaxis: { type: 'datetime', labels: { show: true, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, datetimeFormatter: { year: 'yyyy', month: 'MMM yy', day: 'dd MMM' } }, axisBorder: { show: false }, axisTicks: { show: false }, minTickInterval: 86400000 },
|
xaxis: { type: 'datetime', labels: { show: true, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, datetimeFormatter: { year: 'yyyy', month: 'MMM yy', day: 'dd MMM' } }, axisBorder: { show: false }, axisTicks: { show: false }, minTickInterval: 86400000 },
|
||||||
yaxis: { opposite: true, labels: { offsetX: -10, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, formatter: (val) => { if (val >= 1e6) return (val / 1e6).toFixed(0) + 'M'; if (val >= 1e3) return (val / 1e3).toFixed(0) + 'k'; return val; } }, axisBorder: { show: false }, axisTicks: { show: false } },
|
yaxis: { opposite: true, labels: { offsetX: -10, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, formatter: (val) => { if (val >= 1e6) return (val / 1e6).toFixed(0) + 'M'; if (val >= 1e3) return (val / 1e3).toFixed(0) + 'k'; return val; } }, axisBorder: { show: false }, axisTicks: { show: false } },
|
||||||
tooltip: { enabled: true, theme: 'dark', shared: false, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) { const rawData = w.config.series[seriesIndex].data[dataPointIndex]; if (!rawData) return ''; const date = new Date(rawData[0]); const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const sats = rawData[1]; const btcVal = (sats / 1e8).toFixed(6); const priceStr = todayBtcPrice ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(todayBtcPrice) : '—'; const dollarDay = todayBtcPrice ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format((sats / 1e8) * todayBtcPrice) : '—'; return '<div class="rounded-lg py-2 px-4 text-left"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter mb-3">' + dateString + '</div><div class="text-orange-400 text-xs font-bold">₿ ' + btcVal + '/day</div><div class="text-white text-sm font-medium mt-1">' + dollarDay + '</div></div>'; } },
|
tooltip: { enabled: true, theme: 'dark', shared: true, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) {
|
||||||
|
const rawData = w.config.series[0].data[dataPointIndex];
|
||||||
|
if (!rawData) return '';
|
||||||
|
const date = new Date(rawData[0]);
|
||||||
|
const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
let html = '<div class="text-center font-medium relative p-1"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter">' + dateString + '</div>';
|
||||||
|
let totalSats = 0;
|
||||||
|
w.config.series.forEach((s, i) => {
|
||||||
|
const pt = s.data[dataPointIndex];
|
||||||
|
if (pt) {
|
||||||
|
totalSats += pt[1];
|
||||||
|
const btcVal = (pt[1] / 1e8).toFixed(6);
|
||||||
|
html += '<div class="flex items-center justify-between text-xs mt-1"><div class="flex items-center gap-1.5"><div class="w-2 h-2 rounded-full" style="background:' + colors[i] + ';"></div><span class="text-gray-300">' + s.name + '</span></div><span class="text-white font-bold">\u20BF ' + btcVal + '</span></div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
html += '<div class="border-t border-gray-600 mt-1 pt-1 flex items-center justify-between text-xs"><span class="text-gray-300">Total</span><span class="text-white font-bold">' + new Intl.NumberFormat('en-US').format(totalSats) + ' sats</span></div>';
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
} },
|
||||||
|
legend: { show: false },
|
||||||
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
|
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
|
||||||
};
|
};
|
||||||
const chart = new ApexCharts(document.querySelector("#chart-container-sats"), options);
|
const chart = new ApexCharts(document.querySelector("#chart-container-sats"), options);
|
||||||
|
|||||||
Reference in New Issue
Block a user