claude-usage-dashboard 1.4.0 → 1.4.2

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.
@@ -1,29 +1,29 @@
1
- export function renderCacheEfficiency(container, data) {
2
- container.innerHTML = '';
3
-
4
- const items = [
5
- { label: 'Cache Read', value: data.cache_read_rate, color: '#4ade80', tokens: data.cache_read_tokens },
6
- { label: 'Cache Creation', value: data.cache_creation_rate, color: '#f59e0b', tokens: data.cache_creation_tokens },
7
- { label: 'No Cache', value: data.no_cache_rate, color: '#ef4444', tokens: data.non_cached_input_tokens },
8
- ];
9
-
10
- for (const item of items) {
11
- const row = document.createElement('div');
12
- row.style.marginBottom = '12px';
13
-
14
- const header = document.createElement('div');
15
- header.style.cssText = 'display:flex;justify-content:space-between;font-size:11px;color:#94a3b8;margin-bottom:4px';
16
- header.innerHTML = `<span>${item.label}</span><span>${(item.value * 100).toFixed(1)}%</span>`;
17
-
18
- const barBg = document.createElement('div');
19
- barBg.style.cssText = 'height:8px;background:#334155;border-radius:4px;overflow:hidden';
20
-
21
- const barFill = document.createElement('div');
22
- barFill.style.cssText = `width:${item.value * 100}%;height:100%;background:${item.color};border-radius:4px;transition:width 0.5s`;
23
-
24
- barBg.appendChild(barFill);
25
- row.appendChild(header);
26
- row.appendChild(barBg);
27
- container.appendChild(row);
28
- }
29
- }
1
+ export function renderCacheEfficiency(container, data) {
2
+ container.innerHTML = '';
3
+
4
+ const items = [
5
+ { label: 'Cache Read', value: data.cache_read_rate, color: '#4ade80', tokens: data.cache_read_tokens },
6
+ { label: 'Cache Creation', value: data.cache_creation_rate, color: '#f59e0b', tokens: data.cache_creation_tokens },
7
+ { label: 'No Cache', value: data.no_cache_rate, color: '#ef4444', tokens: data.non_cached_input_tokens },
8
+ ];
9
+
10
+ for (const item of items) {
11
+ const row = document.createElement('div');
12
+ row.style.marginBottom = '12px';
13
+
14
+ const header = document.createElement('div');
15
+ header.style.cssText = 'display:flex;justify-content:space-between;font-size:11px;color:#94a3b8;margin-bottom:4px';
16
+ header.innerHTML = `<span>${item.label}</span><span>${(item.value * 100).toFixed(1)}%</span>`;
17
+
18
+ const barBg = document.createElement('div');
19
+ barBg.style.cssText = 'height:8px;background:#334155;border-radius:4px;overflow:hidden';
20
+
21
+ const barFill = document.createElement('div');
22
+ barFill.style.cssText = `width:${item.value * 100}%;height:100%;background:${item.color};border-radius:4px;transition:width 0.5s`;
23
+
24
+ barBg.appendChild(barFill);
25
+ row.appendChild(header);
26
+ row.appendChild(barBg);
27
+ container.appendChild(row);
28
+ }
29
+ }
@@ -1,39 +1,39 @@
1
- export function renderCostComparison(container, data) {
2
- const el = d3.select(container);
3
- el.selectAll('*').remove();
4
-
5
- const margin = { top: 10, right: 20, bottom: 40, left: 50 };
6
- const width = container.clientWidth - margin.left - margin.right;
7
- const height = 180 - margin.top - margin.bottom;
8
-
9
- const svg = el.append('svg')
10
- .attr('width', width + margin.left + margin.right)
11
- .attr('height', height + margin.top + margin.bottom)
12
- .append('g')
13
- .attr('transform', `translate(${margin.left},${margin.top})`);
14
-
15
- const bars = [
16
- { label: 'Subscription', value: data.subscription_cost_usd, color: '#3b82f6' },
17
- { label: 'API Cost', value: data.api_equivalent_cost_usd, color: '#f59e0b' },
18
- ];
19
-
20
- const x = d3.scaleBand().domain(bars.map(d => d.label)).range([0, width]).padding(0.4);
21
- const y = d3.scaleLinear().domain([0, d3.max(bars, d => d.value) * 1.2]).range([height, 0]);
22
-
23
- svg.append('g').attr('transform', `translate(0,${height})`)
24
- .call(d3.axisBottom(x))
25
- .selectAll('text').style('fill', '#94a3b8').style('font-size', '11px');
26
- svg.append('g').call(d3.axisLeft(y).ticks(4).tickFormat(d => `$${d}`))
27
- .selectAll('text').style('fill', '#64748b').style('font-size', '10px');
28
-
29
- svg.selectAll('.bar').data(bars).enter().append('rect')
30
- .attr('x', d => x(d.label)).attr('y', d => y(d.value))
31
- .attr('width', x.bandwidth()).attr('height', d => height - y(d.value))
32
- .attr('fill', d => d.color).attr('rx', 4);
33
-
34
- svg.selectAll('.label').data(bars).enter().append('text')
35
- .attr('x', d => x(d.label) + x.bandwidth() / 2).attr('y', d => y(d.value) - 5)
36
- .attr('text-anchor', 'middle')
37
- .style('fill', '#f8fafc').style('font-size', '12px').style('font-weight', '600')
38
- .text(d => `$${d.value.toFixed(2)}`);
39
- }
1
+ export function renderCostComparison(container, data) {
2
+ const el = d3.select(container);
3
+ el.selectAll('*').remove();
4
+
5
+ const margin = { top: 10, right: 20, bottom: 40, left: 50 };
6
+ const width = container.clientWidth - margin.left - margin.right;
7
+ const height = 180 - margin.top - margin.bottom;
8
+
9
+ const svg = el.append('svg')
10
+ .attr('width', width + margin.left + margin.right)
11
+ .attr('height', height + margin.top + margin.bottom)
12
+ .append('g')
13
+ .attr('transform', `translate(${margin.left},${margin.top})`);
14
+
15
+ const bars = [
16
+ { label: 'Subscription', value: data.subscription_cost_usd, color: '#3b82f6' },
17
+ { label: 'API Cost', value: data.api_equivalent_cost_usd, color: '#f59e0b' },
18
+ ];
19
+
20
+ const x = d3.scaleBand().domain(bars.map(d => d.label)).range([0, width]).padding(0.4);
21
+ const y = d3.scaleLinear().domain([0, d3.max(bars, d => d.value) * 1.2]).range([height, 0]);
22
+
23
+ svg.append('g').attr('transform', `translate(0,${height})`)
24
+ .call(d3.axisBottom(x))
25
+ .selectAll('text').style('fill', '#94a3b8').style('font-size', '11px');
26
+ svg.append('g').call(d3.axisLeft(y).ticks(4).tickFormat(d => `$${d}`))
27
+ .selectAll('text').style('fill', '#64748b').style('font-size', '10px');
28
+
29
+ svg.selectAll('.bar').data(bars).enter().append('rect')
30
+ .attr('x', d => x(d.label)).attr('y', d => y(d.value))
31
+ .attr('width', x.bandwidth()).attr('height', d => height - y(d.value))
32
+ .attr('fill', d => d.color).attr('rx', 4);
33
+
34
+ svg.selectAll('.label').data(bars).enter().append('text')
35
+ .attr('x', d => x(d.label) + x.bandwidth() / 2).attr('y', d => y(d.value) - 5)
36
+ .attr('text-anchor', 'middle')
37
+ .style('fill', '#f8fafc').style('font-size', '12px').style('font-weight', '600')
38
+ .text(d => `$${d.value.toFixed(2)}`);
39
+ }
@@ -1,56 +1,56 @@
1
- const MODEL_COLORS = {
2
- 'claude-sonnet-4-6': '#3b82f6',
3
- 'claude-opus-4-6': '#8b5cf6',
4
- 'claude-haiku-4-5': '#f59e0b',
5
- };
6
-
7
- const MODEL_DISPLAY = {
8
- 'claude-opus-4-6': 'opus 4.6',
9
- 'claude-sonnet-4-6': 'sonnet 4.6',
10
- 'claude-haiku-4-5': 'haiku 4.5',
11
- 'claude-haiku-4-5-20251001': 'haiku 4.5',
12
- };
13
-
14
- export function renderModelDistribution(container, data) {
15
- const el = d3.select(container);
16
- el.selectAll('*').remove();
17
-
18
- if (!data.models || data.models.length === 0) {
19
- el.append('p').style('color', '#64748b').text('No data');
20
- return;
21
- }
22
-
23
- const containerWidth = container.clientWidth;
24
- const size = Math.min(containerWidth * 0.45, 200);
25
- const radius = size / 2;
26
- const innerRadius = radius * 0.55;
27
-
28
- const isNarrow = containerWidth < 280;
29
- const wrapper = el.append('div')
30
- .style('display', 'flex')
31
- .style('flex-direction', isNarrow ? 'column' : 'row')
32
- .style('align-items', 'center')
33
- .style('gap', isNarrow ? '8px' : '20px');
34
-
35
- const svg = wrapper.append('svg')
36
- .attr('width', size).attr('height', size)
37
- .style('flex-shrink', '0')
38
- .append('g').attr('transform', `translate(${size / 2},${size / 2})`);
39
-
40
- const total = d3.sum(data.models, d => d.total_tokens);
41
- const pie = d3.pie().value(d => d.total_tokens).sort(null);
42
- const arc = d3.arc().innerRadius(innerRadius).outerRadius(radius);
43
-
44
- svg.selectAll('path').data(pie(data.models)).enter().append('path')
45
- .attr('d', arc).attr('fill', d => MODEL_COLORS[d.data.id] || '#64748b')
46
- .attr('stroke', '#1e293b').attr('stroke-width', 2);
47
-
48
- const legend = wrapper.append('div');
49
- data.models.forEach(m => {
50
- const pct = ((m.total_tokens / total) * 100).toFixed(1);
51
- const color = MODEL_COLORS[m.id] || '#64748b';
52
- const shortName = MODEL_DISPLAY[m.id] || m.id.replace('claude-', '').replace(/-(\d+)-(\d+)/, ' $1.$2');
53
- legend.append('div').style('font-size', '11px').style('color', '#94a3b8').style('margin-bottom', '4px')
54
- .html(`<span style="color:${color}">●</span> ${shortName} — ${pct}%`);
55
- });
56
- }
1
+ const MODEL_COLORS = {
2
+ 'claude-sonnet-4-6': '#3b82f6',
3
+ 'claude-opus-4-6': '#8b5cf6',
4
+ 'claude-haiku-4-5': '#f59e0b',
5
+ };
6
+
7
+ const MODEL_DISPLAY = {
8
+ 'claude-opus-4-6': 'opus 4.6',
9
+ 'claude-sonnet-4-6': 'sonnet 4.6',
10
+ 'claude-haiku-4-5': 'haiku 4.5',
11
+ 'claude-haiku-4-5-20251001': 'haiku 4.5',
12
+ };
13
+
14
+ export function renderModelDistribution(container, data) {
15
+ const el = d3.select(container);
16
+ el.selectAll('*').remove();
17
+
18
+ if (!data.models || data.models.length === 0) {
19
+ el.append('p').style('color', '#64748b').text('No data');
20
+ return;
21
+ }
22
+
23
+ const containerWidth = container.clientWidth;
24
+ const size = Math.min(containerWidth * 0.45, 200);
25
+ const radius = size / 2;
26
+ const innerRadius = radius * 0.55;
27
+
28
+ const isNarrow = containerWidth < 280;
29
+ const wrapper = el.append('div')
30
+ .style('display', 'flex')
31
+ .style('flex-direction', isNarrow ? 'column' : 'row')
32
+ .style('align-items', 'center')
33
+ .style('gap', isNarrow ? '8px' : '20px');
34
+
35
+ const svg = wrapper.append('svg')
36
+ .attr('width', size).attr('height', size)
37
+ .style('flex-shrink', '0')
38
+ .append('g').attr('transform', `translate(${size / 2},${size / 2})`);
39
+
40
+ const total = d3.sum(data.models, d => d.total_tokens);
41
+ const pie = d3.pie().value(d => d.total_tokens).sort(null);
42
+ const arc = d3.arc().innerRadius(innerRadius).outerRadius(radius);
43
+
44
+ svg.selectAll('path').data(pie(data.models)).enter().append('path')
45
+ .attr('d', arc).attr('fill', d => MODEL_COLORS[d.data.id] || '#64748b')
46
+ .attr('stroke', '#1e293b').attr('stroke-width', 2);
47
+
48
+ const legend = wrapper.append('div');
49
+ data.models.forEach(m => {
50
+ const pct = ((m.total_tokens / total) * 100).toFixed(1);
51
+ const color = MODEL_COLORS[m.id] || '#64748b';
52
+ const shortName = MODEL_DISPLAY[m.id] || m.id.replace('claude-', '').replace(/-(\d+)-(\d+)/, ' $1.$2');
53
+ legend.append('div').style('font-size', '11px').style('color', '#94a3b8').style('margin-bottom', '4px')
54
+ .html(`<span style="color:${color}">●</span> ${shortName} — ${pct}%`);
55
+ });
56
+ }
@@ -1,103 +1,103 @@
1
- const COLORS = ['#3b82f6', '#8b5cf6', '#f59e0b', '#4ade80', '#ef4444', '#ec4899', '#06b6d4'];
2
-
3
- function fmt(n) {
4
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
5
- if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
6
- return n.toString();
7
- }
8
-
9
- export function renderProjectDistribution(container, data) {
10
- const el = d3.select(container);
11
- el.selectAll('*').remove();
12
-
13
- if (!data.projects || data.projects.length === 0) {
14
- el.append('p').style('color', '#64748b').text('No data');
15
- return;
16
- }
17
-
18
- // Measure longest project name to set left margin dynamically
19
- const tempSvg = el.append('svg').style('position', 'absolute').style('visibility', 'hidden');
20
- const tempText = tempSvg.append('text').style('font-size', '12px');
21
- let maxLabelWidth = 120;
22
- for (const p of data.projects) {
23
- tempText.text(p.name);
24
- maxLabelWidth = Math.max(maxLabelWidth, tempText.node().getComputedTextLength());
25
- }
26
- tempSvg.remove();
27
- const leftMargin = Math.ceil(maxLabelWidth) + 16;
28
-
29
- const margin = { top: 10, right: 360, bottom: 10, left: leftMargin };
30
- const barHeight = 24;
31
- const gap = 8;
32
- const height = data.projects.length * (barHeight + gap) + margin.top + margin.bottom;
33
- const width = container.clientWidth - margin.left - margin.right;
34
-
35
- const svg = el.append('svg')
36
- .attr('width', width + margin.left + margin.right)
37
- .attr('height', height)
38
- .append('g').attr('transform', `translate(${margin.left},${margin.top})`);
39
-
40
- const x = d3.scaleLinear().domain([0, d3.max(data.projects, d => d.total_tokens)]).range([0, width]);
41
- const y = d3.scaleBand().domain(data.projects.map(d => d.name)).range([0, height - margin.top - margin.bottom]).padding(0.25);
42
-
43
- // Project name labels
44
- svg.selectAll('.project-label').data(data.projects).enter().append('text')
45
- .attr('x', -8).attr('y', d => y(d.name) + y.bandwidth() / 2)
46
- .attr('text-anchor', 'end').attr('dominant-baseline', 'middle')
47
- .style('fill', '#e2e8f0').style('font-size', '12px').text(d => d.name);
48
-
49
- // Stacked bars: cache_read + cache_creation + input + output
50
- const cr = d => d.cache_read_tokens || 0;
51
- const cc = d => d.cache_creation_tokens || 0;
52
-
53
- svg.selectAll('.bar-cache-read').data(data.projects).enter().append('rect')
54
- .attr('x', 0).attr('y', d => y(d.name))
55
- .attr('width', d => x(cr(d))).attr('height', y.bandwidth())
56
- .attr('fill', '#4ade80').attr('opacity', 0.6).attr('rx', 3);
57
-
58
- svg.selectAll('.bar-cache-creation').data(data.projects).enter().append('rect')
59
- .attr('x', d => x(cr(d))).attr('y', d => y(d.name))
60
- .attr('width', d => x(cr(d) + cc(d)) - x(cr(d))).attr('height', y.bandwidth())
61
- .attr('fill', '#f59e0b').attr('opacity', 0.6);
62
-
63
- svg.selectAll('.bar-input').data(data.projects).enter().append('rect')
64
- .attr('x', d => x(cr(d) + cc(d))).attr('y', d => y(d.name))
65
- .attr('width', d => x(cr(d) + cc(d) + d.total_input_tokens) - x(cr(d) + cc(d))).attr('height', y.bandwidth())
66
- .attr('fill', '#3b82f6').attr('opacity', 0.7);
67
-
68
- svg.selectAll('.bar-output').data(data.projects).enter().append('rect')
69
- .attr('x', d => x(cr(d) + cc(d) + d.total_input_tokens)).attr('y', d => y(d.name))
70
- .attr('width', d => x(d.total_tokens) - x(cr(d) + cc(d) + d.total_input_tokens)).attr('height', y.bandwidth())
71
- .attr('fill', '#f97316').attr('opacity', 0.7);
72
-
73
- // Right-side label: tokens + cost
74
- svg.selectAll('.detail-label').data(data.projects).enter().append('text')
75
- .attr('x', d => x(d.total_tokens) + 8).attr('y', d => y(d.name) + y.bandwidth() / 2)
76
- .attr('dominant-baseline', 'middle')
77
- .style('font-size', '11px')
78
- .html(d => {
79
- // Use tspans for colored segments
80
- return '';
81
- })
82
- .each(function(d) {
83
- const text = d3.select(this);
84
- text.append('tspan').style('fill', '#f8fafc').style('font-weight', '600').text(fmt(d.total_tokens));
85
- text.append('tspan').style('fill', '#64748b').text(' (');
86
- text.append('tspan').style('fill', '#4ade80').text(`cr:${fmt(d.cache_read_tokens || 0)}`);
87
- text.append('tspan').style('fill', '#64748b').text('/');
88
- text.append('tspan').style('fill', '#f59e0b').text(`cw:${fmt(d.cache_creation_tokens || 0)}`);
89
- text.append('tspan').style('fill', '#64748b').text('/');
90
- text.append('tspan').style('fill', '#60a5fa').text(`in:${fmt(d.total_input_tokens)}`);
91
- text.append('tspan').style('fill', '#64748b').text('/');
92
- text.append('tspan').style('fill', '#f97316').text(`out:${fmt(d.total_output_tokens)}`);
93
- text.append('tspan').style('fill', '#64748b').text(') ');
94
- text.append('tspan').style('fill', '#f59e0b').style('font-weight', '600').text(`$${d.estimated_cost_usd.toFixed(2)}`);
95
- });
96
-
97
- // Legend
98
- const legend = el.append('div').style('display', 'flex').style('gap', '16px').style('margin-top', '8px');
99
- legend.append('span').style('font-size', '11px').style('color', '#4ade80').html('● Cache Read');
100
- legend.append('span').style('font-size', '11px').style('color', '#f59e0b').html('● Cache Write');
101
- legend.append('span').style('font-size', '11px').style('color', '#60a5fa').html('● Input');
102
- legend.append('span').style('font-size', '11px').style('color', '#f97316').html('● Output');
103
- }
1
+ const COLORS = ['#3b82f6', '#8b5cf6', '#f59e0b', '#4ade80', '#ef4444', '#ec4899', '#06b6d4'];
2
+
3
+ function fmt(n) {
4
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
5
+ if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
6
+ return n.toString();
7
+ }
8
+
9
+ export function renderProjectDistribution(container, data) {
10
+ const el = d3.select(container);
11
+ el.selectAll('*').remove();
12
+
13
+ if (!data.projects || data.projects.length === 0) {
14
+ el.append('p').style('color', '#64748b').text('No data');
15
+ return;
16
+ }
17
+
18
+ // Measure longest project name to set left margin dynamically
19
+ const tempSvg = el.append('svg').style('position', 'absolute').style('visibility', 'hidden');
20
+ const tempText = tempSvg.append('text').style('font-size', '12px');
21
+ let maxLabelWidth = 120;
22
+ for (const p of data.projects) {
23
+ tempText.text(p.name);
24
+ maxLabelWidth = Math.max(maxLabelWidth, tempText.node().getComputedTextLength());
25
+ }
26
+ tempSvg.remove();
27
+ const leftMargin = Math.ceil(maxLabelWidth) + 16;
28
+
29
+ const margin = { top: 10, right: 360, bottom: 10, left: leftMargin };
30
+ const barHeight = 24;
31
+ const gap = 8;
32
+ const height = data.projects.length * (barHeight + gap) + margin.top + margin.bottom;
33
+ const width = container.clientWidth - margin.left - margin.right;
34
+
35
+ const svg = el.append('svg')
36
+ .attr('width', width + margin.left + margin.right)
37
+ .attr('height', height)
38
+ .append('g').attr('transform', `translate(${margin.left},${margin.top})`);
39
+
40
+ const x = d3.scaleLinear().domain([0, d3.max(data.projects, d => d.total_tokens)]).range([0, width]);
41
+ const y = d3.scaleBand().domain(data.projects.map(d => d.name)).range([0, height - margin.top - margin.bottom]).padding(0.25);
42
+
43
+ // Project name labels
44
+ svg.selectAll('.project-label').data(data.projects).enter().append('text')
45
+ .attr('x', -8).attr('y', d => y(d.name) + y.bandwidth() / 2)
46
+ .attr('text-anchor', 'end').attr('dominant-baseline', 'middle')
47
+ .style('fill', '#e2e8f0').style('font-size', '12px').text(d => d.name);
48
+
49
+ // Stacked bars: cache_read + cache_creation + input + output
50
+ const cr = d => d.cache_read_tokens || 0;
51
+ const cc = d => d.cache_creation_tokens || 0;
52
+
53
+ svg.selectAll('.bar-cache-read').data(data.projects).enter().append('rect')
54
+ .attr('x', 0).attr('y', d => y(d.name))
55
+ .attr('width', d => x(cr(d))).attr('height', y.bandwidth())
56
+ .attr('fill', '#4ade80').attr('opacity', 0.6).attr('rx', 3);
57
+
58
+ svg.selectAll('.bar-cache-creation').data(data.projects).enter().append('rect')
59
+ .attr('x', d => x(cr(d))).attr('y', d => y(d.name))
60
+ .attr('width', d => x(cr(d) + cc(d)) - x(cr(d))).attr('height', y.bandwidth())
61
+ .attr('fill', '#f59e0b').attr('opacity', 0.6);
62
+
63
+ svg.selectAll('.bar-input').data(data.projects).enter().append('rect')
64
+ .attr('x', d => x(cr(d) + cc(d))).attr('y', d => y(d.name))
65
+ .attr('width', d => x(cr(d) + cc(d) + d.total_input_tokens) - x(cr(d) + cc(d))).attr('height', y.bandwidth())
66
+ .attr('fill', '#3b82f6').attr('opacity', 0.7);
67
+
68
+ svg.selectAll('.bar-output').data(data.projects).enter().append('rect')
69
+ .attr('x', d => x(cr(d) + cc(d) + d.total_input_tokens)).attr('y', d => y(d.name))
70
+ .attr('width', d => x(d.total_tokens) - x(cr(d) + cc(d) + d.total_input_tokens)).attr('height', y.bandwidth())
71
+ .attr('fill', '#f97316').attr('opacity', 0.7);
72
+
73
+ // Right-side label: tokens + cost
74
+ svg.selectAll('.detail-label').data(data.projects).enter().append('text')
75
+ .attr('x', d => x(d.total_tokens) + 8).attr('y', d => y(d.name) + y.bandwidth() / 2)
76
+ .attr('dominant-baseline', 'middle')
77
+ .style('font-size', '11px')
78
+ .html(d => {
79
+ // Use tspans for colored segments
80
+ return '';
81
+ })
82
+ .each(function(d) {
83
+ const text = d3.select(this);
84
+ text.append('tspan').style('fill', '#f8fafc').style('font-weight', '600').text(fmt(d.total_tokens));
85
+ text.append('tspan').style('fill', '#64748b').text(' (');
86
+ text.append('tspan').style('fill', '#4ade80').text(`cr:${fmt(d.cache_read_tokens || 0)}`);
87
+ text.append('tspan').style('fill', '#64748b').text('/');
88
+ text.append('tspan').style('fill', '#f59e0b').text(`cw:${fmt(d.cache_creation_tokens || 0)}`);
89
+ text.append('tspan').style('fill', '#64748b').text('/');
90
+ text.append('tspan').style('fill', '#60a5fa').text(`in:${fmt(d.total_input_tokens)}`);
91
+ text.append('tspan').style('fill', '#64748b').text('/');
92
+ text.append('tspan').style('fill', '#f97316').text(`out:${fmt(d.total_output_tokens)}`);
93
+ text.append('tspan').style('fill', '#64748b').text(') ');
94
+ text.append('tspan').style('fill', '#f59e0b').style('font-weight', '600').text(`$${d.estimated_cost_usd.toFixed(2)}`);
95
+ });
96
+
97
+ // Legend
98
+ const legend = el.append('div').style('display', 'flex').style('gap', '16px').style('margin-top', '8px');
99
+ legend.append('span').style('font-size', '11px').style('color', '#4ade80').html('● Cache Read');
100
+ legend.append('span').style('font-size', '11px').style('color', '#f59e0b').html('● Cache Write');
101
+ legend.append('span').style('font-size', '11px').style('color', '#60a5fa').html('● Input');
102
+ legend.append('span').style('font-size', '11px').style('color', '#f97316').html('● Output');
103
+ }