claude-usage-dashboard 1.3.4 → 1.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +2 -20
- package/package.json +40 -40
- package/public/css/style.css +265 -265
- package/public/index.html +108 -108
- package/public/js/api.js +16 -16
- package/public/js/app.js +273 -273
- package/public/js/charts/cache-efficiency.js +29 -29
- package/public/js/charts/cost-comparison.js +39 -39
- package/public/js/charts/model-distribution.js +56 -56
- package/public/js/charts/project-distribution.js +103 -103
- package/public/js/charts/session-stats.js +117 -117
- package/public/js/charts/token-trend.js +357 -357
- package/public/js/components/date-picker.js +35 -35
- package/public/js/components/plan-selector.js +57 -57
- package/server/aggregator.js +147 -147
- package/server/index.js +33 -33
- package/server/parser.js +109 -109
- package/server/pricing.js +52 -52
- package/server/routes/api.js +127 -127
|
@@ -1,357 +1,357 @@
|
|
|
1
|
-
// d3 is loaded as a global via <script> tag in index.html
|
|
2
|
-
|
|
3
|
-
export function renderTokenTrend(container, data, opts = {}) {
|
|
4
|
-
const showDollars = opts.yAxis === 'dollars';
|
|
5
|
-
const el = d3.select(container);
|
|
6
|
-
el.selectAll('*').remove();
|
|
7
|
-
|
|
8
|
-
if (!data.buckets || data.buckets.length === 0) {
|
|
9
|
-
el.append('p').style('color', '#64748b').text('No data for selected range');
|
|
10
|
-
return;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const margin = { top: 20, right: 30, bottom: 60, left: 60 };
|
|
14
|
-
const width = container.clientWidth - margin.left - margin.right;
|
|
15
|
-
const height = 250 - margin.top - margin.bottom;
|
|
16
|
-
|
|
17
|
-
const svg = el.append('svg')
|
|
18
|
-
.attr('width', width + margin.left + margin.right)
|
|
19
|
-
.attr('height', height + margin.top + margin.bottom)
|
|
20
|
-
.append('g')
|
|
21
|
-
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
22
|
-
|
|
23
|
-
const emptyBucket = (time) => ({ time, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, estimated_cost_usd: 0 });
|
|
24
|
-
|
|
25
|
-
// Fill in missing time slots so blank periods are visible
|
|
26
|
-
const bucketMap = new Map(data.buckets.map(b => [b.time, b]));
|
|
27
|
-
let allKeys;
|
|
28
|
-
if (data.granularity === 'hourly') {
|
|
29
|
-
allKeys = [];
|
|
30
|
-
const first = data.buckets[0].time; // e.g. "2026-03-15T08:00"
|
|
31
|
-
const last = data.buckets[data.buckets.length - 1].time;
|
|
32
|
-
const pad = n => String(n).padStart(2, '0');
|
|
33
|
-
const cur = new Date(first.replace('T', ' ').replace(/:00$/, ':00:00'));
|
|
34
|
-
const end = new Date(last.replace('T', ' ').replace(/:00$/, ':00:00'));
|
|
35
|
-
while (cur <= end) {
|
|
36
|
-
const key = `${cur.getFullYear()}-${pad(cur.getMonth() + 1)}-${pad(cur.getDate())}T${pad(cur.getHours())}:00`;
|
|
37
|
-
allKeys.push(key);
|
|
38
|
-
cur.setHours(cur.getHours() + 1);
|
|
39
|
-
}
|
|
40
|
-
} else if (data.granularity === 'daily') {
|
|
41
|
-
allKeys = [];
|
|
42
|
-
const pad = n => String(n).padStart(2, '0');
|
|
43
|
-
const cur = new Date(data.buckets[0].time + 'T00:00:00');
|
|
44
|
-
const end = new Date(data.buckets[data.buckets.length - 1].time + 'T00:00:00');
|
|
45
|
-
while (cur <= end) {
|
|
46
|
-
allKeys.push(`${cur.getFullYear()}-${pad(cur.getMonth() + 1)}-${pad(cur.getDate())}`);
|
|
47
|
-
cur.setDate(cur.getDate() + 1);
|
|
48
|
-
}
|
|
49
|
-
} else {
|
|
50
|
-
allKeys = data.buckets.map(b => b.time);
|
|
51
|
-
}
|
|
52
|
-
const buckets = allKeys.map(k => bucketMap.get(k) || emptyBucket(k));
|
|
53
|
-
|
|
54
|
-
const x = d3.scaleBand()
|
|
55
|
-
.domain(buckets.map(d => d.time))
|
|
56
|
-
.range([0, width])
|
|
57
|
-
.padding(0.1);
|
|
58
|
-
|
|
59
|
-
// Helper to get total height for each bucket
|
|
60
|
-
const totalOf = d => d.input_tokens + d.output_tokens + (d.cache_read_tokens || 0) + (d.cache_creation_tokens || 0);
|
|
61
|
-
const costOf = d => d.estimated_cost_usd || 0;
|
|
62
|
-
const valueOf = showDollars ? costOf : totalOf;
|
|
63
|
-
|
|
64
|
-
const maxVal = d3.max(buckets, valueOf) || 1;
|
|
65
|
-
const y = d3.scaleLinear().domain([0, maxVal * 1.1]).range([height, 0]);
|
|
66
|
-
|
|
67
|
-
const maxTicks = data.granularity === 'hourly' ? 12 : 10;
|
|
68
|
-
const tickVals = x.domain().filter((_, i) => i % Math.ceil(buckets.length / maxTicks) === 0);
|
|
69
|
-
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
70
|
-
const formatTick = (t) => {
|
|
71
|
-
// Hourly: "2026-03-15T08:00" → "Mar 15 8AM"
|
|
72
|
-
const h = t.match(/^\d{4}-(\d{2})-(\d{2})T(\d{2}):00$/);
|
|
73
|
-
if (h) {
|
|
74
|
-
const hr = parseInt(h[3], 10);
|
|
75
|
-
const ampm = hr === 0 ? '12AM' : hr < 12 ? `${hr}AM` : hr === 12 ? '12PM' : `${hr - 12}PM`;
|
|
76
|
-
return `${months[parseInt(h[1], 10) - 1]} ${parseInt(h[2], 10)} ${ampm}`;
|
|
77
|
-
}
|
|
78
|
-
// Daily: "2026-03-08" → "Mar 8"
|
|
79
|
-
const m = t.match(/^\d{4}-(\d{2})-(\d{2})$/);
|
|
80
|
-
if (m) {
|
|
81
|
-
return `${months[parseInt(m[1], 10) - 1]} ${parseInt(m[2], 10)}`;
|
|
82
|
-
}
|
|
83
|
-
return t;
|
|
84
|
-
};
|
|
85
|
-
const xAxis = svg.append('g')
|
|
86
|
-
.attr('transform', `translate(0,${height})`)
|
|
87
|
-
.call(d3.axisBottom(x).tickValues(tickVals).tickFormat(formatTick));
|
|
88
|
-
xAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px')
|
|
89
|
-
.attr('transform', 'rotate(-45)').attr('text-anchor', 'end');
|
|
90
|
-
xAxis.selectAll('line, path').style('stroke', '#334155');
|
|
91
|
-
|
|
92
|
-
const yAxisFmt = showDollars ? (v => `$${v < 1 ? v.toFixed(2) : d3.format('.2s')(v)}`) : d3.format('.2s');
|
|
93
|
-
const yAxis = svg.append('g').call(d3.axisLeft(y).ticks(5).tickFormat(yAxisFmt));
|
|
94
|
-
yAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px');
|
|
95
|
-
yAxis.selectAll('line, path').style('stroke', '#334155');
|
|
96
|
-
|
|
97
|
-
if (showDollars) {
|
|
98
|
-
// Single bar per bucket showing cost
|
|
99
|
-
svg.selectAll('.bar-cost')
|
|
100
|
-
.data(buckets)
|
|
101
|
-
.enter().append('rect')
|
|
102
|
-
.attr('x', d => x(d.time))
|
|
103
|
-
.attr('y', d => y(costOf(d)))
|
|
104
|
-
.attr('width', x.bandwidth())
|
|
105
|
-
.attr('height', d => height - y(costOf(d)))
|
|
106
|
-
.attr('fill', '#fbbf24')
|
|
107
|
-
.attr('opacity', 0.7);
|
|
108
|
-
} else {
|
|
109
|
-
// Stack order (bottom to top): cache_read, cache_creation, input, output
|
|
110
|
-
// Cache read (bottom)
|
|
111
|
-
svg.selectAll('.bar-cache-read')
|
|
112
|
-
.data(buckets)
|
|
113
|
-
.enter().append('rect')
|
|
114
|
-
.attr('x', d => x(d.time))
|
|
115
|
-
.attr('y', d => y(d.cache_read_tokens || 0))
|
|
116
|
-
.attr('width', x.bandwidth())
|
|
117
|
-
.attr('height', d => height - y(d.cache_read_tokens || 0))
|
|
118
|
-
.attr('fill', '#4ade80')
|
|
119
|
-
.attr('opacity', 0.6);
|
|
120
|
-
|
|
121
|
-
// Cache creation (on top of cache read)
|
|
122
|
-
const cacheBase = d => (d.cache_read_tokens || 0);
|
|
123
|
-
svg.selectAll('.bar-cache-creation')
|
|
124
|
-
.data(buckets)
|
|
125
|
-
.enter().append('rect')
|
|
126
|
-
.attr('x', d => x(d.time))
|
|
127
|
-
.attr('y', d => y(cacheBase(d) + (d.cache_creation_tokens || 0)))
|
|
128
|
-
.attr('width', x.bandwidth())
|
|
129
|
-
.attr('height', d => y(cacheBase(d)) - y(cacheBase(d) + (d.cache_creation_tokens || 0)))
|
|
130
|
-
.attr('fill', '#f59e0b')
|
|
131
|
-
.attr('opacity', 0.6);
|
|
132
|
-
|
|
133
|
-
// Input (on top of cache)
|
|
134
|
-
const inputBase = d => cacheBase(d) + (d.cache_creation_tokens || 0);
|
|
135
|
-
svg.selectAll('.bar-input')
|
|
136
|
-
.data(buckets)
|
|
137
|
-
.enter().append('rect')
|
|
138
|
-
.attr('x', d => x(d.time))
|
|
139
|
-
.attr('y', d => y(inputBase(d) + d.input_tokens))
|
|
140
|
-
.attr('width', x.bandwidth())
|
|
141
|
-
.attr('height', d => y(inputBase(d)) - y(inputBase(d) + d.input_tokens))
|
|
142
|
-
.attr('fill', '#3b82f6')
|
|
143
|
-
.attr('opacity', 0.7);
|
|
144
|
-
|
|
145
|
-
// Output (top)
|
|
146
|
-
const outputBase = d => inputBase(d) + d.input_tokens;
|
|
147
|
-
svg.selectAll('.bar-output')
|
|
148
|
-
.data(buckets)
|
|
149
|
-
.enter().append('rect')
|
|
150
|
-
.attr('x', d => x(d.time))
|
|
151
|
-
.attr('y', d => y(outputBase(d) + d.output_tokens))
|
|
152
|
-
.attr('width', x.bandwidth())
|
|
153
|
-
.attr('height', d => y(outputBase(d)) - y(outputBase(d) + d.output_tokens))
|
|
154
|
-
.attr('fill', '#f97316')
|
|
155
|
-
.attr('opacity', 0.7);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Tooltip — remove any stale ones first
|
|
159
|
-
d3.selectAll('.d3-tooltip-token-trend').remove();
|
|
160
|
-
const tooltip = d3.select('body').append('div').attr('class', 'd3-tooltip d3-tooltip-token-trend').style('display', 'none');
|
|
161
|
-
|
|
162
|
-
svg.selectAll('rect')
|
|
163
|
-
.on('mouseover', (event, d) => {
|
|
164
|
-
const total = d.input_tokens + d.output_tokens + (d.cache_read_tokens || 0) + (d.cache_creation_tokens || 0);
|
|
165
|
-
const cost = d.estimated_cost_usd || 0;
|
|
166
|
-
tooltip.style('display', 'block')
|
|
167
|
-
.html(`<strong>${d.time}</strong><br>Total: ${d3.format(',')(total)} tokens <span style="color:#f59e0b;font-weight:600">$${cost.toFixed(2)}</span><br><span style="color:#4ade80">Cache Read: ${d3.format(',')(d.cache_read_tokens || 0)}</span><br><span style="color:#f59e0b">Cache Write: ${d3.format(',')(d.cache_creation_tokens || 0)}</span><br><span style="color:#60a5fa">Input: ${d3.format(',')(d.input_tokens)}</span><br><span style="color:#f97316">Output: ${d3.format(',')(d.output_tokens)}</span>`);
|
|
168
|
-
})
|
|
169
|
-
.on('mousemove', (event) => {
|
|
170
|
-
tooltip.style('left', (event.pageX + 10) + 'px').style('top', (event.pageY - 10) + 'px');
|
|
171
|
-
})
|
|
172
|
-
.on('mouseout', () => tooltip.style('display', 'none'));
|
|
173
|
-
|
|
174
|
-
// Aggregated totals for the selected period
|
|
175
|
-
const fmt = d3.format(',');
|
|
176
|
-
const fmtShort = (n) => {
|
|
177
|
-
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
|
|
178
|
-
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
179
|
-
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
|
180
|
-
return n.toString();
|
|
181
|
-
};
|
|
182
|
-
// Use server-computed totals (from raw records) for accuracy;
|
|
183
|
-
// fall back to summing buckets if data.total is unavailable
|
|
184
|
-
const t = data.total || {};
|
|
185
|
-
const totals = {
|
|
186
|
-
cacheRead: t.cache_read_tokens ?? buckets.reduce((s, d) => s + (d.cache_read_tokens || 0), 0),
|
|
187
|
-
cacheWrite: t.cache_creation_tokens ?? buckets.reduce((s, d) => s + (d.cache_creation_tokens || 0), 0),
|
|
188
|
-
input: t.input_tokens ?? buckets.reduce((s, d) => s + d.input_tokens, 0),
|
|
189
|
-
output: t.output_tokens ?? buckets.reduce((s, d) => s + d.output_tokens, 0),
|
|
190
|
-
cost: t.estimated_api_cost_usd ?? buckets.reduce((s, d) => s + (d.estimated_cost_usd || 0), 0),
|
|
191
|
-
};
|
|
192
|
-
totals.all = totals.cacheRead + totals.cacheWrite + totals.input + totals.output;
|
|
193
|
-
|
|
194
|
-
const summary = el.append('div')
|
|
195
|
-
.style('margin-top', '10px')
|
|
196
|
-
.style('padding', '10px 14px')
|
|
197
|
-
.style('background', '#1e293b')
|
|
198
|
-
.style('border-radius', '6px')
|
|
199
|
-
.style('font-size', '12px');
|
|
200
|
-
|
|
201
|
-
// Top row: total tokens + cost inline
|
|
202
|
-
const topRow = summary.append('div')
|
|
203
|
-
.style('margin-bottom', '8px');
|
|
204
|
-
topRow.append('span')
|
|
205
|
-
.style('color', '#e2e8f0')
|
|
206
|
-
.style('font-size', '13px')
|
|
207
|
-
.html(`Period Total: <strong title="${fmt(totals.all)} tokens">${fmtShort(totals.all)}</strong> tokens`)
|
|
208
|
-
.append('span')
|
|
209
|
-
.style('color', '#fbbf24')
|
|
210
|
-
.style('font-weight', '600')
|
|
211
|
-
.style('margin-left', '12px')
|
|
212
|
-
.html(`$${totals.cost.toFixed(2)}`);
|
|
213
|
-
|
|
214
|
-
// Segment breakdown with legend dots
|
|
215
|
-
const segments = [
|
|
216
|
-
{ label: 'Cache Read', value: totals.cacheRead, color: '#4ade80' },
|
|
217
|
-
{ label: 'Cache Write', value: totals.cacheWrite, color: '#f59e0b' },
|
|
218
|
-
{ label: 'Input', value: totals.input, color: '#60a5fa' },
|
|
219
|
-
{ label: 'Output', value: totals.output, color: '#f97316' },
|
|
220
|
-
];
|
|
221
|
-
const segRow = summary.append('div')
|
|
222
|
-
.style('display', 'flex')
|
|
223
|
-
.style('flex-wrap', 'wrap')
|
|
224
|
-
.style('gap', '6px 20px')
|
|
225
|
-
.style('margin-bottom', '8px');
|
|
226
|
-
for (const s of segments) {
|
|
227
|
-
segRow.append('span')
|
|
228
|
-
.style('color', s.color)
|
|
229
|
-
.style('font-size', '11px')
|
|
230
|
-
.html(`● ${s.label}: <strong title="${fmt(s.value)} tokens">${fmtShort(s.value)}</strong>`);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Avg / Min / Max stats per bucket
|
|
234
|
-
const bucketVals = buckets.map(valueOf);
|
|
235
|
-
const nonZero = bucketVals.filter(v => v > 0);
|
|
236
|
-
const avg = nonZero.length > 0 ? nonZero.reduce((a, b) => a + b, 0) / nonZero.length : 0;
|
|
237
|
-
const min = nonZero.length > 0 ? Math.min(...nonZero) : 0;
|
|
238
|
-
const max = nonZero.length > 0 ? Math.max(...nonZero) : 0;
|
|
239
|
-
|
|
240
|
-
const fmtStat = showDollars
|
|
241
|
-
? (v => `$${v.toFixed(2)}`)
|
|
242
|
-
: (v => fmtShort(Math.round(v)));
|
|
243
|
-
const fmtStatTitle = showDollars
|
|
244
|
-
? (v => `$${v.toFixed(4)}`)
|
|
245
|
-
: (v => `${fmt(Math.round(v))} tokens`);
|
|
246
|
-
|
|
247
|
-
const granLabel = { hourly: 'hour', daily: 'day', weekly: 'week', monthly: 'month' }[data.granularity] || 'bucket';
|
|
248
|
-
const statsRow = summary.append('div')
|
|
249
|
-
.style('display', 'flex')
|
|
250
|
-
.style('flex-wrap', 'wrap')
|
|
251
|
-
.style('gap', '6px 20px')
|
|
252
|
-
.style('font-size', '11px')
|
|
253
|
-
.style('color', '#94a3b8');
|
|
254
|
-
|
|
255
|
-
statsRow.append('span').html(`Avg/${granLabel}: <strong title="${fmtStatTitle(avg)}" style="color:#e2e8f0">${fmtStat(avg)}</strong>`);
|
|
256
|
-
statsRow.append('span').html(`Min: <strong title="${fmtStatTitle(min)}" style="color:#e2e8f0">${fmtStat(min)}</strong>`);
|
|
257
|
-
statsRow.append('span').html(`Max: <strong title="${fmtStatTitle(max)}" style="color:#e2e8f0">${fmtStat(max)}</strong>`);
|
|
258
|
-
statsRow.append('span').html(`Active ${granLabel}s: <strong style="color:#e2e8f0">${nonZero.length}</strong> / ${buckets.length}`);
|
|
259
|
-
|
|
260
|
-
if (data.granularity === 'hourly') {
|
|
261
|
-
const days = new Set(buckets.map(b => b.time.slice(0, 10)));
|
|
262
|
-
const avgHours = (nonZero.length / days.size).toFixed(1);
|
|
263
|
-
statsRow.append('span').html(`Avg hours/day: <strong style="color:#e2e8f0">${avgHours}</strong>`);
|
|
264
|
-
} else if (data.granularity === 'daily') {
|
|
265
|
-
const weeks = Math.max(1, buckets.length / 7);
|
|
266
|
-
const avgDays = (nonZero.length / weeks).toFixed(1);
|
|
267
|
-
statsRow.append('span').html(`Avg days/week: <strong style="color:#e2e8f0">${avgDays}</strong>`);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Most active hours heatmap — aggregate by hour-of-day across the entire range
|
|
271
|
-
const hourAgg = new Array(24).fill(0);
|
|
272
|
-
const hourCount = new Array(24).fill(0);
|
|
273
|
-
for (const b of buckets) {
|
|
274
|
-
// Extract hour from hourly buckets (e.g. "2026-03-15T08:00") or from daily/other granularities skip
|
|
275
|
-
const hm = b.time.match(/T(\d{2}):00$/);
|
|
276
|
-
if (hm) {
|
|
277
|
-
const hr = parseInt(hm[1], 10);
|
|
278
|
-
hourAgg[hr] += valueOf(b);
|
|
279
|
-
if (valueOf(b) > 0) hourCount[hr]++;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
const hasHourlyData = hourAgg.some(v => v > 0);
|
|
283
|
-
if (hasHourlyData) {
|
|
284
|
-
const peakHour = hourAgg.indexOf(Math.max(...hourAgg));
|
|
285
|
-
const maxHourVal = Math.max(...hourAgg);
|
|
286
|
-
|
|
287
|
-
// Find contiguous active ranges
|
|
288
|
-
const activeHours = hourAgg.map((v, i) => ({ hour: i, val: v })).filter(h => h.val > 0);
|
|
289
|
-
const ranges = [];
|
|
290
|
-
if (activeHours.length > 0) {
|
|
291
|
-
let start = activeHours[0].hour;
|
|
292
|
-
let prev = start;
|
|
293
|
-
for (let i = 1; i < activeHours.length; i++) {
|
|
294
|
-
if (activeHours[i].hour === prev + 1) {
|
|
295
|
-
prev = activeHours[i].hour;
|
|
296
|
-
} else {
|
|
297
|
-
ranges.push([start, prev]);
|
|
298
|
-
start = activeHours[i].hour;
|
|
299
|
-
prev = start;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
ranges.push([start, prev]);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const fmtHr = h => {
|
|
306
|
-
if (h === 0) return '12AM';
|
|
307
|
-
if (h < 12) return `${h}AM`;
|
|
308
|
-
if (h === 12) return '12PM';
|
|
309
|
-
return `${h - 12}PM`;
|
|
310
|
-
};
|
|
311
|
-
|
|
312
|
-
const activeRangeStr = ranges.map(([s, e]) => s === e ? fmtHr(s) : `${fmtHr(s)}-${fmtHr((e + 1) % 24)}`).join(', ');
|
|
313
|
-
|
|
314
|
-
const hoursRow = summary.append('div')
|
|
315
|
-
.style('margin-top', '8px')
|
|
316
|
-
.style('font-size', '11px')
|
|
317
|
-
.style('color', '#94a3b8');
|
|
318
|
-
|
|
319
|
-
hoursRow.append('div')
|
|
320
|
-
.style('margin-bottom', '4px')
|
|
321
|
-
.html(`Peak hour: <strong style="color:#e2e8f0">${fmtHr(peakHour)}</strong> Active: <strong style="color:#e2e8f0">${activeRangeStr}</strong>`);
|
|
322
|
-
|
|
323
|
-
// Mini hour heatmap bar
|
|
324
|
-
const heatmap = hoursRow.append('div')
|
|
325
|
-
.style('display', 'flex')
|
|
326
|
-
.style('gap', '1px')
|
|
327
|
-
.style('align-items', 'end')
|
|
328
|
-
.style('height', '20px');
|
|
329
|
-
|
|
330
|
-
for (let h = 0; h < 24; h++) {
|
|
331
|
-
const pct = maxHourVal > 0 ? hourAgg[h] / maxHourVal : 0;
|
|
332
|
-
const color = pct === 0 ? '#1e293b'
|
|
333
|
-
: pct < 0.33 ? '#334155'
|
|
334
|
-
: pct < 0.66 ? '#3b82f6'
|
|
335
|
-
: '#60a5fa';
|
|
336
|
-
heatmap.append('div')
|
|
337
|
-
.attr('title', `${fmtHr(h)}: ${showDollars ? '$' + hourAgg[h].toFixed(2) : fmtShort(Math.round(hourAgg[h]))}`)
|
|
338
|
-
.style('flex', '1')
|
|
339
|
-
.style('height', `${Math.max(2, pct * 100)}%`)
|
|
340
|
-
.style('background', color)
|
|
341
|
-
.style('border-radius', '1px');
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Hour labels under heatmap
|
|
345
|
-
const labels = hoursRow.append('div')
|
|
346
|
-
.style('display', 'flex')
|
|
347
|
-
.style('gap', '1px');
|
|
348
|
-
for (let h = 0; h < 24; h++) {
|
|
349
|
-
labels.append('div')
|
|
350
|
-
.style('flex', '1')
|
|
351
|
-
.style('text-align', 'center')
|
|
352
|
-
.style('font-size', '8px')
|
|
353
|
-
.style('color', '#64748b')
|
|
354
|
-
.text(h % 3 === 0 ? fmtHr(h) : '');
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
1
|
+
// d3 is loaded as a global via <script> tag in index.html
|
|
2
|
+
|
|
3
|
+
export function renderTokenTrend(container, data, opts = {}) {
|
|
4
|
+
const showDollars = opts.yAxis === 'dollars';
|
|
5
|
+
const el = d3.select(container);
|
|
6
|
+
el.selectAll('*').remove();
|
|
7
|
+
|
|
8
|
+
if (!data.buckets || data.buckets.length === 0) {
|
|
9
|
+
el.append('p').style('color', '#64748b').text('No data for selected range');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const margin = { top: 20, right: 30, bottom: 60, left: 60 };
|
|
14
|
+
const width = container.clientWidth - margin.left - margin.right;
|
|
15
|
+
const height = 250 - margin.top - margin.bottom;
|
|
16
|
+
|
|
17
|
+
const svg = el.append('svg')
|
|
18
|
+
.attr('width', width + margin.left + margin.right)
|
|
19
|
+
.attr('height', height + margin.top + margin.bottom)
|
|
20
|
+
.append('g')
|
|
21
|
+
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
22
|
+
|
|
23
|
+
const emptyBucket = (time) => ({ time, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, estimated_cost_usd: 0 });
|
|
24
|
+
|
|
25
|
+
// Fill in missing time slots so blank periods are visible
|
|
26
|
+
const bucketMap = new Map(data.buckets.map(b => [b.time, b]));
|
|
27
|
+
let allKeys;
|
|
28
|
+
if (data.granularity === 'hourly') {
|
|
29
|
+
allKeys = [];
|
|
30
|
+
const first = data.buckets[0].time; // e.g. "2026-03-15T08:00"
|
|
31
|
+
const last = data.buckets[data.buckets.length - 1].time;
|
|
32
|
+
const pad = n => String(n).padStart(2, '0');
|
|
33
|
+
const cur = new Date(first.replace('T', ' ').replace(/:00$/, ':00:00'));
|
|
34
|
+
const end = new Date(last.replace('T', ' ').replace(/:00$/, ':00:00'));
|
|
35
|
+
while (cur <= end) {
|
|
36
|
+
const key = `${cur.getFullYear()}-${pad(cur.getMonth() + 1)}-${pad(cur.getDate())}T${pad(cur.getHours())}:00`;
|
|
37
|
+
allKeys.push(key);
|
|
38
|
+
cur.setHours(cur.getHours() + 1);
|
|
39
|
+
}
|
|
40
|
+
} else if (data.granularity === 'daily') {
|
|
41
|
+
allKeys = [];
|
|
42
|
+
const pad = n => String(n).padStart(2, '0');
|
|
43
|
+
const cur = new Date(data.buckets[0].time + 'T00:00:00');
|
|
44
|
+
const end = new Date(data.buckets[data.buckets.length - 1].time + 'T00:00:00');
|
|
45
|
+
while (cur <= end) {
|
|
46
|
+
allKeys.push(`${cur.getFullYear()}-${pad(cur.getMonth() + 1)}-${pad(cur.getDate())}`);
|
|
47
|
+
cur.setDate(cur.getDate() + 1);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
allKeys = data.buckets.map(b => b.time);
|
|
51
|
+
}
|
|
52
|
+
const buckets = allKeys.map(k => bucketMap.get(k) || emptyBucket(k));
|
|
53
|
+
|
|
54
|
+
const x = d3.scaleBand()
|
|
55
|
+
.domain(buckets.map(d => d.time))
|
|
56
|
+
.range([0, width])
|
|
57
|
+
.padding(0.1);
|
|
58
|
+
|
|
59
|
+
// Helper to get total height for each bucket
|
|
60
|
+
const totalOf = d => d.input_tokens + d.output_tokens + (d.cache_read_tokens || 0) + (d.cache_creation_tokens || 0);
|
|
61
|
+
const costOf = d => d.estimated_cost_usd || 0;
|
|
62
|
+
const valueOf = showDollars ? costOf : totalOf;
|
|
63
|
+
|
|
64
|
+
const maxVal = d3.max(buckets, valueOf) || 1;
|
|
65
|
+
const y = d3.scaleLinear().domain([0, maxVal * 1.1]).range([height, 0]);
|
|
66
|
+
|
|
67
|
+
const maxTicks = data.granularity === 'hourly' ? 12 : 10;
|
|
68
|
+
const tickVals = x.domain().filter((_, i) => i % Math.ceil(buckets.length / maxTicks) === 0);
|
|
69
|
+
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
70
|
+
const formatTick = (t) => {
|
|
71
|
+
// Hourly: "2026-03-15T08:00" → "Mar 15 8AM"
|
|
72
|
+
const h = t.match(/^\d{4}-(\d{2})-(\d{2})T(\d{2}):00$/);
|
|
73
|
+
if (h) {
|
|
74
|
+
const hr = parseInt(h[3], 10);
|
|
75
|
+
const ampm = hr === 0 ? '12AM' : hr < 12 ? `${hr}AM` : hr === 12 ? '12PM' : `${hr - 12}PM`;
|
|
76
|
+
return `${months[parseInt(h[1], 10) - 1]} ${parseInt(h[2], 10)} ${ampm}`;
|
|
77
|
+
}
|
|
78
|
+
// Daily: "2026-03-08" → "Mar 8"
|
|
79
|
+
const m = t.match(/^\d{4}-(\d{2})-(\d{2})$/);
|
|
80
|
+
if (m) {
|
|
81
|
+
return `${months[parseInt(m[1], 10) - 1]} ${parseInt(m[2], 10)}`;
|
|
82
|
+
}
|
|
83
|
+
return t;
|
|
84
|
+
};
|
|
85
|
+
const xAxis = svg.append('g')
|
|
86
|
+
.attr('transform', `translate(0,${height})`)
|
|
87
|
+
.call(d3.axisBottom(x).tickValues(tickVals).tickFormat(formatTick));
|
|
88
|
+
xAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px')
|
|
89
|
+
.attr('transform', 'rotate(-45)').attr('text-anchor', 'end');
|
|
90
|
+
xAxis.selectAll('line, path').style('stroke', '#334155');
|
|
91
|
+
|
|
92
|
+
const yAxisFmt = showDollars ? (v => `$${v < 1 ? v.toFixed(2) : d3.format('.2s')(v)}`) : d3.format('.2s');
|
|
93
|
+
const yAxis = svg.append('g').call(d3.axisLeft(y).ticks(5).tickFormat(yAxisFmt));
|
|
94
|
+
yAxis.selectAll('text').style('fill', '#64748b').style('font-size', '10px');
|
|
95
|
+
yAxis.selectAll('line, path').style('stroke', '#334155');
|
|
96
|
+
|
|
97
|
+
if (showDollars) {
|
|
98
|
+
// Single bar per bucket showing cost
|
|
99
|
+
svg.selectAll('.bar-cost')
|
|
100
|
+
.data(buckets)
|
|
101
|
+
.enter().append('rect')
|
|
102
|
+
.attr('x', d => x(d.time))
|
|
103
|
+
.attr('y', d => y(costOf(d)))
|
|
104
|
+
.attr('width', x.bandwidth())
|
|
105
|
+
.attr('height', d => height - y(costOf(d)))
|
|
106
|
+
.attr('fill', '#fbbf24')
|
|
107
|
+
.attr('opacity', 0.7);
|
|
108
|
+
} else {
|
|
109
|
+
// Stack order (bottom to top): cache_read, cache_creation, input, output
|
|
110
|
+
// Cache read (bottom)
|
|
111
|
+
svg.selectAll('.bar-cache-read')
|
|
112
|
+
.data(buckets)
|
|
113
|
+
.enter().append('rect')
|
|
114
|
+
.attr('x', d => x(d.time))
|
|
115
|
+
.attr('y', d => y(d.cache_read_tokens || 0))
|
|
116
|
+
.attr('width', x.bandwidth())
|
|
117
|
+
.attr('height', d => height - y(d.cache_read_tokens || 0))
|
|
118
|
+
.attr('fill', '#4ade80')
|
|
119
|
+
.attr('opacity', 0.6);
|
|
120
|
+
|
|
121
|
+
// Cache creation (on top of cache read)
|
|
122
|
+
const cacheBase = d => (d.cache_read_tokens || 0);
|
|
123
|
+
svg.selectAll('.bar-cache-creation')
|
|
124
|
+
.data(buckets)
|
|
125
|
+
.enter().append('rect')
|
|
126
|
+
.attr('x', d => x(d.time))
|
|
127
|
+
.attr('y', d => y(cacheBase(d) + (d.cache_creation_tokens || 0)))
|
|
128
|
+
.attr('width', x.bandwidth())
|
|
129
|
+
.attr('height', d => y(cacheBase(d)) - y(cacheBase(d) + (d.cache_creation_tokens || 0)))
|
|
130
|
+
.attr('fill', '#f59e0b')
|
|
131
|
+
.attr('opacity', 0.6);
|
|
132
|
+
|
|
133
|
+
// Input (on top of cache)
|
|
134
|
+
const inputBase = d => cacheBase(d) + (d.cache_creation_tokens || 0);
|
|
135
|
+
svg.selectAll('.bar-input')
|
|
136
|
+
.data(buckets)
|
|
137
|
+
.enter().append('rect')
|
|
138
|
+
.attr('x', d => x(d.time))
|
|
139
|
+
.attr('y', d => y(inputBase(d) + d.input_tokens))
|
|
140
|
+
.attr('width', x.bandwidth())
|
|
141
|
+
.attr('height', d => y(inputBase(d)) - y(inputBase(d) + d.input_tokens))
|
|
142
|
+
.attr('fill', '#3b82f6')
|
|
143
|
+
.attr('opacity', 0.7);
|
|
144
|
+
|
|
145
|
+
// Output (top)
|
|
146
|
+
const outputBase = d => inputBase(d) + d.input_tokens;
|
|
147
|
+
svg.selectAll('.bar-output')
|
|
148
|
+
.data(buckets)
|
|
149
|
+
.enter().append('rect')
|
|
150
|
+
.attr('x', d => x(d.time))
|
|
151
|
+
.attr('y', d => y(outputBase(d) + d.output_tokens))
|
|
152
|
+
.attr('width', x.bandwidth())
|
|
153
|
+
.attr('height', d => y(outputBase(d)) - y(outputBase(d) + d.output_tokens))
|
|
154
|
+
.attr('fill', '#f97316')
|
|
155
|
+
.attr('opacity', 0.7);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Tooltip — remove any stale ones first
|
|
159
|
+
d3.selectAll('.d3-tooltip-token-trend').remove();
|
|
160
|
+
const tooltip = d3.select('body').append('div').attr('class', 'd3-tooltip d3-tooltip-token-trend').style('display', 'none');
|
|
161
|
+
|
|
162
|
+
svg.selectAll('rect')
|
|
163
|
+
.on('mouseover', (event, d) => {
|
|
164
|
+
const total = d.input_tokens + d.output_tokens + (d.cache_read_tokens || 0) + (d.cache_creation_tokens || 0);
|
|
165
|
+
const cost = d.estimated_cost_usd || 0;
|
|
166
|
+
tooltip.style('display', 'block')
|
|
167
|
+
.html(`<strong>${d.time}</strong><br>Total: ${d3.format(',')(total)} tokens <span style="color:#f59e0b;font-weight:600">$${cost.toFixed(2)}</span><br><span style="color:#4ade80">Cache Read: ${d3.format(',')(d.cache_read_tokens || 0)}</span><br><span style="color:#f59e0b">Cache Write: ${d3.format(',')(d.cache_creation_tokens || 0)}</span><br><span style="color:#60a5fa">Input: ${d3.format(',')(d.input_tokens)}</span><br><span style="color:#f97316">Output: ${d3.format(',')(d.output_tokens)}</span>`);
|
|
168
|
+
})
|
|
169
|
+
.on('mousemove', (event) => {
|
|
170
|
+
tooltip.style('left', (event.pageX + 10) + 'px').style('top', (event.pageY - 10) + 'px');
|
|
171
|
+
})
|
|
172
|
+
.on('mouseout', () => tooltip.style('display', 'none'));
|
|
173
|
+
|
|
174
|
+
// Aggregated totals for the selected period
|
|
175
|
+
const fmt = d3.format(',');
|
|
176
|
+
const fmtShort = (n) => {
|
|
177
|
+
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
|
|
178
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
179
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
|
180
|
+
return n.toString();
|
|
181
|
+
};
|
|
182
|
+
// Use server-computed totals (from raw records) for accuracy;
|
|
183
|
+
// fall back to summing buckets if data.total is unavailable
|
|
184
|
+
const t = data.total || {};
|
|
185
|
+
const totals = {
|
|
186
|
+
cacheRead: t.cache_read_tokens ?? buckets.reduce((s, d) => s + (d.cache_read_tokens || 0), 0),
|
|
187
|
+
cacheWrite: t.cache_creation_tokens ?? buckets.reduce((s, d) => s + (d.cache_creation_tokens || 0), 0),
|
|
188
|
+
input: t.input_tokens ?? buckets.reduce((s, d) => s + d.input_tokens, 0),
|
|
189
|
+
output: t.output_tokens ?? buckets.reduce((s, d) => s + d.output_tokens, 0),
|
|
190
|
+
cost: t.estimated_api_cost_usd ?? buckets.reduce((s, d) => s + (d.estimated_cost_usd || 0), 0),
|
|
191
|
+
};
|
|
192
|
+
totals.all = totals.cacheRead + totals.cacheWrite + totals.input + totals.output;
|
|
193
|
+
|
|
194
|
+
const summary = el.append('div')
|
|
195
|
+
.style('margin-top', '10px')
|
|
196
|
+
.style('padding', '10px 14px')
|
|
197
|
+
.style('background', '#1e293b')
|
|
198
|
+
.style('border-radius', '6px')
|
|
199
|
+
.style('font-size', '12px');
|
|
200
|
+
|
|
201
|
+
// Top row: total tokens + cost inline
|
|
202
|
+
const topRow = summary.append('div')
|
|
203
|
+
.style('margin-bottom', '8px');
|
|
204
|
+
topRow.append('span')
|
|
205
|
+
.style('color', '#e2e8f0')
|
|
206
|
+
.style('font-size', '13px')
|
|
207
|
+
.html(`Period Total: <strong title="${fmt(totals.all)} tokens">${fmtShort(totals.all)}</strong> tokens`)
|
|
208
|
+
.append('span')
|
|
209
|
+
.style('color', '#fbbf24')
|
|
210
|
+
.style('font-weight', '600')
|
|
211
|
+
.style('margin-left', '12px')
|
|
212
|
+
.html(`$${totals.cost.toFixed(2)}`);
|
|
213
|
+
|
|
214
|
+
// Segment breakdown with legend dots
|
|
215
|
+
const segments = [
|
|
216
|
+
{ label: 'Cache Read', value: totals.cacheRead, color: '#4ade80' },
|
|
217
|
+
{ label: 'Cache Write', value: totals.cacheWrite, color: '#f59e0b' },
|
|
218
|
+
{ label: 'Input', value: totals.input, color: '#60a5fa' },
|
|
219
|
+
{ label: 'Output', value: totals.output, color: '#f97316' },
|
|
220
|
+
];
|
|
221
|
+
const segRow = summary.append('div')
|
|
222
|
+
.style('display', 'flex')
|
|
223
|
+
.style('flex-wrap', 'wrap')
|
|
224
|
+
.style('gap', '6px 20px')
|
|
225
|
+
.style('margin-bottom', '8px');
|
|
226
|
+
for (const s of segments) {
|
|
227
|
+
segRow.append('span')
|
|
228
|
+
.style('color', s.color)
|
|
229
|
+
.style('font-size', '11px')
|
|
230
|
+
.html(`● ${s.label}: <strong title="${fmt(s.value)} tokens">${fmtShort(s.value)}</strong>`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Avg / Min / Max stats per bucket
|
|
234
|
+
const bucketVals = buckets.map(valueOf);
|
|
235
|
+
const nonZero = bucketVals.filter(v => v > 0);
|
|
236
|
+
const avg = nonZero.length > 0 ? nonZero.reduce((a, b) => a + b, 0) / nonZero.length : 0;
|
|
237
|
+
const min = nonZero.length > 0 ? Math.min(...nonZero) : 0;
|
|
238
|
+
const max = nonZero.length > 0 ? Math.max(...nonZero) : 0;
|
|
239
|
+
|
|
240
|
+
const fmtStat = showDollars
|
|
241
|
+
? (v => `$${v.toFixed(2)}`)
|
|
242
|
+
: (v => fmtShort(Math.round(v)));
|
|
243
|
+
const fmtStatTitle = showDollars
|
|
244
|
+
? (v => `$${v.toFixed(4)}`)
|
|
245
|
+
: (v => `${fmt(Math.round(v))} tokens`);
|
|
246
|
+
|
|
247
|
+
const granLabel = { hourly: 'hour', daily: 'day', weekly: 'week', monthly: 'month' }[data.granularity] || 'bucket';
|
|
248
|
+
const statsRow = summary.append('div')
|
|
249
|
+
.style('display', 'flex')
|
|
250
|
+
.style('flex-wrap', 'wrap')
|
|
251
|
+
.style('gap', '6px 20px')
|
|
252
|
+
.style('font-size', '11px')
|
|
253
|
+
.style('color', '#94a3b8');
|
|
254
|
+
|
|
255
|
+
statsRow.append('span').html(`Avg/${granLabel}: <strong title="${fmtStatTitle(avg)}" style="color:#e2e8f0">${fmtStat(avg)}</strong>`);
|
|
256
|
+
statsRow.append('span').html(`Min: <strong title="${fmtStatTitle(min)}" style="color:#e2e8f0">${fmtStat(min)}</strong>`);
|
|
257
|
+
statsRow.append('span').html(`Max: <strong title="${fmtStatTitle(max)}" style="color:#e2e8f0">${fmtStat(max)}</strong>`);
|
|
258
|
+
statsRow.append('span').html(`Active ${granLabel}s: <strong style="color:#e2e8f0">${nonZero.length}</strong> / ${buckets.length}`);
|
|
259
|
+
|
|
260
|
+
if (data.granularity === 'hourly') {
|
|
261
|
+
const days = new Set(buckets.map(b => b.time.slice(0, 10)));
|
|
262
|
+
const avgHours = (nonZero.length / days.size).toFixed(1);
|
|
263
|
+
statsRow.append('span').html(`Avg hours/day: <strong style="color:#e2e8f0">${avgHours}</strong>`);
|
|
264
|
+
} else if (data.granularity === 'daily') {
|
|
265
|
+
const weeks = Math.max(1, buckets.length / 7);
|
|
266
|
+
const avgDays = (nonZero.length / weeks).toFixed(1);
|
|
267
|
+
statsRow.append('span').html(`Avg days/week: <strong style="color:#e2e8f0">${avgDays}</strong>`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Most active hours heatmap — aggregate by hour-of-day across the entire range
|
|
271
|
+
const hourAgg = new Array(24).fill(0);
|
|
272
|
+
const hourCount = new Array(24).fill(0);
|
|
273
|
+
for (const b of buckets) {
|
|
274
|
+
// Extract hour from hourly buckets (e.g. "2026-03-15T08:00") or from daily/other granularities skip
|
|
275
|
+
const hm = b.time.match(/T(\d{2}):00$/);
|
|
276
|
+
if (hm) {
|
|
277
|
+
const hr = parseInt(hm[1], 10);
|
|
278
|
+
hourAgg[hr] += valueOf(b);
|
|
279
|
+
if (valueOf(b) > 0) hourCount[hr]++;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const hasHourlyData = hourAgg.some(v => v > 0);
|
|
283
|
+
if (hasHourlyData) {
|
|
284
|
+
const peakHour = hourAgg.indexOf(Math.max(...hourAgg));
|
|
285
|
+
const maxHourVal = Math.max(...hourAgg);
|
|
286
|
+
|
|
287
|
+
// Find contiguous active ranges
|
|
288
|
+
const activeHours = hourAgg.map((v, i) => ({ hour: i, val: v })).filter(h => h.val > 0);
|
|
289
|
+
const ranges = [];
|
|
290
|
+
if (activeHours.length > 0) {
|
|
291
|
+
let start = activeHours[0].hour;
|
|
292
|
+
let prev = start;
|
|
293
|
+
for (let i = 1; i < activeHours.length; i++) {
|
|
294
|
+
if (activeHours[i].hour === prev + 1) {
|
|
295
|
+
prev = activeHours[i].hour;
|
|
296
|
+
} else {
|
|
297
|
+
ranges.push([start, prev]);
|
|
298
|
+
start = activeHours[i].hour;
|
|
299
|
+
prev = start;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
ranges.push([start, prev]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const fmtHr = h => {
|
|
306
|
+
if (h === 0) return '12AM';
|
|
307
|
+
if (h < 12) return `${h}AM`;
|
|
308
|
+
if (h === 12) return '12PM';
|
|
309
|
+
return `${h - 12}PM`;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const activeRangeStr = ranges.map(([s, e]) => s === e ? fmtHr(s) : `${fmtHr(s)}-${fmtHr((e + 1) % 24)}`).join(', ');
|
|
313
|
+
|
|
314
|
+
const hoursRow = summary.append('div')
|
|
315
|
+
.style('margin-top', '8px')
|
|
316
|
+
.style('font-size', '11px')
|
|
317
|
+
.style('color', '#94a3b8');
|
|
318
|
+
|
|
319
|
+
hoursRow.append('div')
|
|
320
|
+
.style('margin-bottom', '4px')
|
|
321
|
+
.html(`Peak hour: <strong style="color:#e2e8f0">${fmtHr(peakHour)}</strong> Active: <strong style="color:#e2e8f0">${activeRangeStr}</strong>`);
|
|
322
|
+
|
|
323
|
+
// Mini hour heatmap bar
|
|
324
|
+
const heatmap = hoursRow.append('div')
|
|
325
|
+
.style('display', 'flex')
|
|
326
|
+
.style('gap', '1px')
|
|
327
|
+
.style('align-items', 'end')
|
|
328
|
+
.style('height', '20px');
|
|
329
|
+
|
|
330
|
+
for (let h = 0; h < 24; h++) {
|
|
331
|
+
const pct = maxHourVal > 0 ? hourAgg[h] / maxHourVal : 0;
|
|
332
|
+
const color = pct === 0 ? '#1e293b'
|
|
333
|
+
: pct < 0.33 ? '#334155'
|
|
334
|
+
: pct < 0.66 ? '#3b82f6'
|
|
335
|
+
: '#60a5fa';
|
|
336
|
+
heatmap.append('div')
|
|
337
|
+
.attr('title', `${fmtHr(h)}: ${showDollars ? '$' + hourAgg[h].toFixed(2) : fmtShort(Math.round(hourAgg[h]))}`)
|
|
338
|
+
.style('flex', '1')
|
|
339
|
+
.style('height', `${Math.max(2, pct * 100)}%`)
|
|
340
|
+
.style('background', color)
|
|
341
|
+
.style('border-radius', '1px');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Hour labels under heatmap
|
|
345
|
+
const labels = hoursRow.append('div')
|
|
346
|
+
.style('display', 'flex')
|
|
347
|
+
.style('gap', '1px');
|
|
348
|
+
for (let h = 0; h < 24; h++) {
|
|
349
|
+
labels.append('div')
|
|
350
|
+
.style('flex', '1')
|
|
351
|
+
.style('text-align', 'center')
|
|
352
|
+
.style('font-size', '8px')
|
|
353
|
+
.style('color', '#64748b')
|
|
354
|
+
.text(h % 3 === 0 ? fmtHr(h) : '');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|