Generate daily data points for cumulative and breakdown charts

This commit is contained in:
Dione
2026-06-11 07:24:36 +00:00
parent 4f5a28ae84
commit 7a34190956

View File

@ -1241,8 +1241,9 @@ function getOldestTransactionDate() {
}
/* Build per-day cumulative BTC series from addressSnapshots.
Forward-fills each wallet's balance for gap days so multi-wallet
aggregation is continuous. */
Forward-fills each wallet's balance for gap days and generates
a data point for every calendar day (like the sats chart) so the
y-axis uses daily steps. */
function buildCumulativeSeries(addresses) {
const allDates = {};
addresses.forEach(addr => {
@ -1251,26 +1252,42 @@ function buildCumulativeSeries(addresses) {
allDates[snap.block_timestamp.slice(0, 10)] = true;
});
});
const sortedDates = Object.keys(allDates).sort();
const snapshotDates = Object.keys(allDates).sort();
if (snapshotDates.length === 0) return [];
const dayMap = {};
/* Build per-wallet forward-filled balance for every calendar day */
const firstUTC = Date.UTC(new Date(snapshotDates[0]).getUTCFullYear(), new Date(snapshotDates[0]).getUTCMonth(), new Date(snapshotDates[0]).getUTCDate());
const lastUTC = Date.UTC(new Date(snapshotDates[snapshotDates.length - 1]).getUTCFullYear(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCMonth(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCDate());
const DAY_MS = 86400000;
/* Pre-compute per-wallet dated arrays (oldest-first) */
const walletDated = {};
addresses.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
const dated = snaps.map(s => ({
walletDated[addr] = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10),
btcAmt: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(s?.collateral?.cbBTC, 'cbBTC')
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
let balance = 0;
for (const ds of sortedDates) {
const match = dated.find(d => d.dateStr === ds);
if (match) balance = match.btcAmt;
if (!dayMap[ds]) dayMap[ds] = 0;
dayMap[ds] += balance;
}
});
return sortedDates.map(dateStr => [new Date(dateStr).getTime(), dayMap[dateStr]]);
/* Walk every calendar day, forward-fill each wallet, aggregate */
const result = [];
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
const ds = new Date(t).toISOString().slice(0, 10);
let dayTotal = 0;
addresses.forEach(addr => {
const dated = walletDated[addr];
let balance = 0;
for (const d of dated) {
if (d.dateStr <= ds) balance = d.btcAmt;
else break;
}
dayTotal += balance;
});
result.push([t, dayTotal]);
}
return result;
}
function calculateAggregatedSeries() {
@ -1543,7 +1560,7 @@ function getBaseChartOptions(chartId, dataset, color, isBTC) {
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: [{ offset: 0, color: color, opacity: 0.16 }, { offset: 100, color: color, opacity: 0 }] } },
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
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 } },
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, min: (min) => isBTC ? min * 0.98 : min * 0.95, max: (max) => isBTC ? max * 1.02 : max * 1.05, labels: { offsetX: -10, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, formatter: function(val) { if (isBTC) return (val / 1000).toFixed(0) + 'k'; return val.toFixed(4); } }, 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 valFormatted = isBTC ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(rawData[1]) : rawData[1].toFixed(6) + ' BTC'; return '<div class="text-center font-medium relative p-1"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter">' + dateString + '</div><div class="text-white text-sm font-bold mt-0.5">' + valFormatted + '</div></div>'; } },
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
@ -1670,7 +1687,7 @@ function setupSatsCard(cutoff) {
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: [{ offset: 0, color: orangeBrandColor, opacity: 0.16 }, { offset: 100, color: orangeBrandColor, opacity: 0 }] } },
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
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: '1day' },
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 } },
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>'; } },
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
@ -1688,34 +1705,55 @@ function setupBreakdownCard(cutoff) {
const snaps = addressSnapshots[addr] || [];
snaps.forEach(snap => { allDates[snap.block_timestamp.slice(0, 10)] = true; });
});
const sortedDates = Object.keys(allDates).sort();
const snapshotDates = Object.keys(allDates).sort();
if (snapshotDates.length === 0) {
const opts = {
chart: { id: 'instance-breakdown', type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, animations: { enabled: false } },
series: [{ name: 'Total Holdings', data: [] }, { name: 'On Aave', data: [] }],
colors: [orangeBrandColor, blueBrandColor]
};
const chart = new ApexCharts(document.querySelector("#chart-container-breakdown"), opts);
chart.render();
window.breakdownChart = chart;
return;
}
const dayMap = {};
const firstUTC = Date.UTC(new Date(snapshotDates[0]).getUTCFullYear(), new Date(snapshotDates[0]).getUTCMonth(), new Date(snapshotDates[0]).getUTCDate());
const lastUTC = Date.UTC(new Date(snapshotDates[snapshotDates.length - 1]).getUTCFullYear(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCMonth(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCDate());
const DAY_MS = 86400000;
/* Pre-compute per-wallet dated arrays (oldest-first) */
const walletDated = {};
selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
const dated = snaps.map(s => ({
walletDated[addr] = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10),
cold: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC'),
collateral: getTokenAmount(s?.collateral?.cbBTC, 'cbBTC')
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
let cold = 0, coll = 0;
for (const ds of sortedDates) {
const match = dated.find(d => d.dateStr === ds);
if (match) { cold = match.cold; coll = match.collateral; }
if (!dayMap[ds]) dayMap[ds] = { cold: 0, collateral: 0 };
dayMap[ds].cold += cold;
dayMap[ds].collateral += coll;
}
});
const entries = sortedDates;
const totalSeries = entries.map(dateStr => {
return [new Date(dateStr).getTime(), dayMap[dateStr].cold + dayMap[dateStr].collateral];
}).filter(d => d[0] >= cutoff);
const aaveSeries = entries.map(dateStr => {
return [new Date(dateStr).getTime(), dayMap[dateStr].collateral];
}).filter(d => d[0] >= cutoff);
/* Walk every calendar day, forward-fill each wallet, aggregate */
const totalSeries = [];
const aaveSeries = [];
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
const ds = new Date(t).toISOString().slice(0, 10);
let dayCold = 0, dayColl = 0;
selectedAddr.forEach(addr => {
const dated = walletDated[addr];
let c = 0, co = 0;
for (const d of dated) {
if (d.dateStr <= ds) { c = d.cold; co = d.collateral; }
else break;
}
dayCold += c;
dayColl += co;
});
if (t >= cutoff) {
totalSeries.push([t, dayCold + dayColl]);
aaveSeries.push([t, dayColl]);
}
}
const opts = {
chart: { id: 'instance-breakdown', type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, sparkline: { enabled: false }, zoom: { enabled: true, type: 'x', autoScaleYaxis: true }, animations: { enabled: false } },
@ -1729,7 +1767,7 @@ function setupBreakdownCard(cutoff) {
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: [[{ offset: 0, color: orangeBrandColor, opacity: 0.16 }, { offset: 100, color: orangeBrandColor, opacity: 0 }], [{ offset: 0, color: blueBrandColor, opacity: 0.16 }, { offset: 100, color: blueBrandColor, opacity: 0 }]] } },
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
markers: { size: 0, strokeColors: [orangeBrandColor, blueBrandColor], strokeWidth: 3, 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 } },
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) => val.toFixed(4) }, axisBorder: { show: false }, axisTicks: { show: false } },
tooltip: { enabled: true, theme: 'dark', shared: true, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) { const item = totalSeries[dataPointIndex]; if (!item) return ''; const date = new Date(item[0]); const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const total = item[1]; const aave = aaveSeries[dataPointIndex]?.[1] || 0; const delta = total - aave; 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="flex items-center justify-between mb-2"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color:' + orangeBrandColor + ';"></div><span class="text-gray-300 text-xs">Total</span></div><span class="text-orange-400 text-sm font-bold">' + total.toFixed(6) + '</span></div><div class="flex items-center justify-between mb-2"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color:' + blueBrandColor + ';"></div><span class="text-gray-300 text-xs">On Aave</span></div><span class="text-blue-400 text-sm font-bold">' + aave.toFixed(6) + '</span></div><div class="flex items-center justify-between"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color: #22c55e;"></div><span class="text-gray-300 text-xs">Delta</span></div><span class="text-green-400 text-sm font-bold">' + delta.toFixed(6) + '</span></div></div>'; } },
legend: { position: 'top', horizontalAlign: 'left', fontSize: '10px', fontFamily: 'sans-serif', labels: { colors: '#4B5563' }, markers: { width: 8, height: 8, radius: 12, offsetX: -4 }, itemMargin: { horizontal: 8, vertical: 0 } },