@roastcodes/ttdash 6.1.9 → 6.2.0

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/dist/index.html CHANGED
@@ -8,7 +8,7 @@
8
8
  <link rel="icon" type="image/png" sizes="256x256" href="/favicon.png" />
9
9
  <link rel="shortcut icon" href="/favicon.png" />
10
10
  <link rel="apple-touch-icon" href="/favicon.png" />
11
- <script type="module" crossorigin src="/assets/index-BfNaLs3g.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-CMtAn7c8.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-COnpUsM8.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/charts-vendor-CiBqdKXh.js">
14
14
  <link rel="modulepreload" crossorigin href="/assets/react-vendor-0R1rd57Z.js">
@@ -17,7 +17,7 @@
17
17
  <link rel="modulepreload" crossorigin href="/assets/icons-vendor-z59La6A4.js">
18
18
  <link rel="modulepreload" crossorigin href="/assets/dialog-CA-ZSHjK.js">
19
19
  <link rel="modulepreload" crossorigin href="/assets/button-B26tLVFw.js">
20
- <link rel="modulepreload" crossorigin href="/assets/CustomTooltip-BXro6tIF.js">
20
+ <link rel="modulepreload" crossorigin href="/assets/CustomTooltip-CdIOw3Ep.js">
21
21
  <link rel="stylesheet" crossorigin href="/assets/index-BkGSNAne.css">
22
22
  </head>
23
23
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roastcodes/ttdash",
3
- "version": "6.1.9",
3
+ "version": "6.2.0",
4
4
  "description": "Local-first dashboard and CLI for toktrack usage data",
5
5
  "main": "server.js",
6
6
  "repository": {
@@ -0,0 +1,12 @@
1
+ const TOP_MODEL_CHART_LABEL_MAX_LENGTH = 34;
2
+
3
+ function truncateTopModelChartLabel(value) {
4
+ const stringValue = String(value || '');
5
+ if (stringValue.length <= TOP_MODEL_CHART_LABEL_MAX_LENGTH) return stringValue;
6
+ return `${stringValue.slice(0, Math.max(1, TOP_MODEL_CHART_LABEL_MAX_LENGTH - 1)).trimEnd()}…`;
7
+ }
8
+
9
+ module.exports = {
10
+ TOP_MODEL_CHART_LABEL_MAX_LENGTH,
11
+ truncateTopModelChartLabel,
12
+ };
@@ -1,3 +1,5 @@
1
+ const { truncateTopModelChartLabel } = require('./chart-labels');
2
+
1
3
  function escapeXml(value) {
2
4
  return String(value)
3
5
  .replace(/&/g, '&amp;')
@@ -19,12 +21,6 @@ ${body}
19
21
 
20
22
  const DEFAULT_FONT_FAMILY = 'Liberation Sans, DejaVu Sans, Arial, sans-serif';
21
23
 
22
- function truncateSvgLabel(value, maxLength = 28) {
23
- const stringValue = String(value || '');
24
- if (stringValue.length <= maxLength) return stringValue;
25
- return `${stringValue.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
26
- }
27
-
28
24
  function lineChart(
29
25
  data,
30
26
  {
@@ -136,7 +132,7 @@ function horizontalBarChart(
136
132
  top: 46,
137
133
  right: 100,
138
134
  bottom: 24,
139
- left: clamp(180 + longestLabelLength * 3.4, 220, 320),
135
+ left: clamp(180 + longestLabelLength * 3.4, 220, 360),
140
136
  };
141
137
  const plotWidth = width - margin.left - margin.right;
142
138
  const barGap = 18;
@@ -158,7 +154,7 @@ function horizontalBarChart(
158
154
  const value = getValue(entry);
159
155
  const barWidth = clamp((value / maxValue) * plotWidth, 0, plotWidth);
160
156
  return `
161
- <text x="${margin.left - 18}" y="${y + barHeight / 2 + 4}" text-anchor="end" font-size="13" font-family="${fontFamily}" fill="#122033">${escapeXml(truncateSvgLabel(getLabel(entry), 30))}</text>
157
+ <text x="${margin.left - 18}" y="${y + barHeight / 2 + 4}" text-anchor="end" font-size="13" font-family="${fontFamily}" fill="#122033">${escapeXml(truncateTopModelChartLabel(getLabel(entry)))}</text>
162
158
  <rect x="${margin.left}" y="${y}" width="${plotWidth}" height="${barHeight}" rx="12" fill="#eef3f8"/>
163
159
  <rect x="${margin.left}" y="${y}" width="${barWidth}" height="${barHeight}" rx="12" fill="${getColor(entry)}"/>
164
160
  <text x="${margin.left + plotWidth + 12}" y="${y + barHeight / 2 + 4}" font-size="12" font-family="${fontFamily}" fill="#475569">${escapeXml(formatter(value))}</text>
@@ -3,7 +3,7 @@ const os = require('os');
3
3
  const path = require('path');
4
4
  const { spawn } = require('child_process');
5
5
  const { buildReportData, formatCompactAxis, formatDateAxis } = require('./utils');
6
- const { translate } = require('./i18n');
6
+ const { getLocale, translate } = require('./i18n');
7
7
  const { horizontalBarChart, lineChart, stackedBarChart } = require('./charts');
8
8
 
9
9
  function ensureTypstInstalled() {
@@ -39,10 +39,41 @@ function compileTypst(workingDir, typPath, pdfPath) {
39
39
  });
40
40
  }
41
41
 
42
+ function formatCostAxisValue(value, language = 'de') {
43
+ const numericValue = Number(value) || 0;
44
+ const absoluteValue = Math.abs(numericValue);
45
+ const locale = getLocale(language);
46
+
47
+ if (absoluteValue >= 100) {
48
+ return `$${Math.round(numericValue).toLocaleString(locale)}`;
49
+ }
50
+
51
+ if (absoluteValue >= 10) {
52
+ return `$${numericValue.toLocaleString(locale, {
53
+ minimumFractionDigits: 0,
54
+ maximumFractionDigits: 1,
55
+ })}`;
56
+ }
57
+
58
+ if (absoluteValue >= 1) {
59
+ return `$${numericValue.toLocaleString(locale, {
60
+ minimumFractionDigits: 0,
61
+ maximumFractionDigits: 2,
62
+ })}`;
63
+ }
64
+
65
+ return `$${numericValue.toLocaleString(locale, {
66
+ minimumFractionDigits: 2,
67
+ maximumFractionDigits: 2,
68
+ })}`;
69
+ }
70
+
42
71
  function buildTemplate() {
43
72
  return `
44
73
  #let report = json("report.json")
45
74
 
75
+ #set document(title: report.meta.reportTitle)
76
+
46
77
  #set page(
47
78
  paper: "a4",
48
79
  margin: (x: 14mm, y: 16mm),
@@ -56,10 +87,10 @@ function buildTemplate() {
56
87
  #let muted = rgb("#5c6b7e")
57
88
  #let panel = rgb("#ffffff")
58
89
  #let line = rgb("#d9e2ec")
59
- #let accent = rgb("#1d6fd8")
90
+ #let accent = rgb("#175fc0")
60
91
  #let accent-soft = rgb("#eaf2ff")
61
92
  #let good = rgb("#16825d")
62
- #let warn = rgb("#c67700")
93
+ #let warn = rgb("#9a5a00")
63
94
 
64
95
  #let metric-card(label, value, note: none, tone: accent) = rect(
65
96
  inset: 10pt,
@@ -89,6 +120,22 @@ function buildTemplate() {
89
120
  ],
90
121
  )
91
122
 
123
+ #let chart-panel(file, alt, summary, note: none) = rect(
124
+ inset: 10pt,
125
+ radius: 14pt,
126
+ fill: panel,
127
+ stroke: (paint: line, thickness: 0.8pt),
128
+ [
129
+ #image(file, width: 100%, alt: alt)
130
+ #v(6pt)
131
+ #text(size: 8.7pt, fill: muted)[#summary]
132
+ #if note != none [
133
+ #v(4pt)
134
+ #text(size: 8.5pt, fill: muted)[#note]
135
+ ]
136
+ ],
137
+ )
138
+
92
139
  #show heading.where(level: 1): it => block(above: 0pt, below: 10pt)[
93
140
  #text(size: 24pt, fill: ink, weight: "bold")[#it.body]
94
141
  ]
@@ -150,21 +197,26 @@ function buildTemplate() {
150
197
  #grid(
151
198
  columns: (1fr, 1fr),
152
199
  gutter: 10pt,
153
- rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[
154
- #image("cost-trend.svg", width: 100%)
155
- ],
156
- rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[
157
- #image("top-models.svg", width: 100%)
158
- ],
200
+ chart-panel(
201
+ "cost-trend.svg",
202
+ report.chartDescriptions.costTrend.alt,
203
+ report.chartDescriptions.costTrend.summary,
204
+ ),
205
+ chart-panel(
206
+ "top-models.svg",
207
+ report.chartDescriptions.topModels.alt,
208
+ report.chartDescriptions.topModels.summary,
209
+ note: report.chartDescriptions.topModels.fullNamesNote,
210
+ ),
159
211
  )
160
212
 
161
213
  #v(10pt)
162
214
 
163
- #rect(inset: 10pt, radius: 14pt, fill: panel, stroke: (paint: line, thickness: 0.8pt))[
164
- #image("token-trend.svg", width: 100%)
165
- ]
166
-
167
- #pagebreak()
215
+ #chart-panel(
216
+ "token-trend.svg",
217
+ report.chartDescriptions.tokenTrend.alt,
218
+ report.chartDescriptions.tokenTrend.summary,
219
+ )
168
220
 
169
221
  #v(12pt)
170
222
 
@@ -279,14 +331,14 @@ function createChartAssets(reportData) {
279
331
  title: reportData.text.charts.costTrend,
280
332
  valueKey: 'cost',
281
333
  secondaryKey: reportData.meta.filterSummary.viewModeKey === 'daily' ? 'ma7' : null,
282
- formatter: (value) => `$${Math.round(value)}`,
334
+ formatter: (value) => formatCostAxisValue(value, reportData.meta.language),
283
335
  }),
284
336
  'top-models.svg': horizontalBarChart(topModels, {
285
337
  title: reportData.text.charts.topModels,
286
338
  getValue: (entry) => entry.cost,
287
339
  getLabel: (entry) => entry.name,
288
340
  getColor: (entry) => entry.color,
289
- formatter: (value) => `$${value.toFixed(value >= 100 ? 0 : 2)}`,
341
+ formatter: (value) => formatCostAxisValue(value, reportData.meta.language),
290
342
  }),
291
343
  'token-trend.svg': stackedBarChart(tokenTrend, {
292
344
  title: reportData.text.charts.tokenTrend,
@@ -363,4 +415,9 @@ async function generatePdfReport(allDailyData, options = {}) {
363
415
 
364
416
  module.exports = {
365
417
  generatePdfReport,
418
+ __test__: {
419
+ buildTemplate,
420
+ createChartAssets,
421
+ formatCostAxisValue,
422
+ },
366
423
  };
@@ -1,5 +1,6 @@
1
1
  const { version: APP_VERSION } = require('../../package.json');
2
2
  const { getLanguage, getLocale, translate } = require('./i18n');
3
+ const { truncateTopModelChartLabel } = require('./chart-labels');
3
4
  const {
4
5
  aggregateToDailyFormat,
5
6
  computeMetrics,
@@ -12,24 +13,12 @@ const {
12
13
  normalizeModelName,
13
14
  sortByDate,
14
15
  } = require('../../shared/dashboard-domain');
15
-
16
- const MODEL_COLORS = {
17
- 'Opus 4.6': 'rgb(175, 92, 224)',
18
- 'Opus 4.5': 'rgb(200, 66, 111)',
19
- 'Sonnet 4.6': 'rgb(71, 134, 221)',
20
- 'Sonnet 4.5': 'rgb(66, 161, 130)',
21
- 'Haiku 4.5': 'rgb(231, 146, 34)',
22
- 'GPT-5.4': 'rgb(230, 98, 56)',
23
- 'GPT-5': 'rgb(230, 98, 56)',
24
- 'Gemini 3 Flash Preview': 'rgb(237, 188, 8)',
25
- Gemini: 'rgb(237, 188, 8)',
26
- OpenCode: 'rgb(51, 181, 193)',
27
- };
16
+ const { getModelColorRgb } = require('../../shared/model-colors.js');
28
17
 
29
18
  const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
30
19
 
31
20
  function getModelColor(name) {
32
- return MODEL_COLORS[name] || 'rgb(113, 128, 150)';
21
+ return getModelColorRgb(name, { theme: 'light' });
33
22
  }
34
23
 
35
24
  function toCostChartData(data) {
@@ -232,6 +221,16 @@ function formatPercent(value, language = 'de') {
232
221
  })}%`;
233
222
  }
234
223
 
224
+ function findPeakEntry(data, getValue) {
225
+ let best = null;
226
+ for (const entry of data) {
227
+ if (!best || getValue(entry) > getValue(best)) {
228
+ best = entry;
229
+ }
230
+ }
231
+ return best;
232
+ }
233
+
235
234
  function formatCompactNumber(value, language = 'de') {
236
235
  if (!Number.isFinite(value)) return '0';
237
236
 
@@ -292,12 +291,6 @@ function summarizeSelection(
292
291
  return `${visible.join(', ')}${suffix}`;
293
292
  }
294
293
 
295
- function truncateLabel(value, maxLength = 28) {
296
- const stringValue = String(value || '');
297
- if (stringValue.length <= maxLength) return stringValue;
298
- return `${stringValue.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
299
- }
300
-
301
294
  function buildInsights(metrics, { filteredDaily, filtered, language }) {
302
295
  const insights = [];
303
296
 
@@ -409,6 +402,9 @@ function buildReportData(allDailyData, options = {}) {
409
402
  const topProviderValue = metrics.topProvider ? metrics.topProvider.name : notAvailable;
410
403
  const insights = buildInsights(metrics, { filteredDaily, filtered, language });
411
404
  const avgPeriodCost = filtered.length > 0 ? metrics.totalCost / filtered.length : 0;
405
+ const latestPeriod = filtered[filtered.length - 1] || null;
406
+ const peakCostPeriod = findPeakEntry(filtered, (entry) => entry.totalCost);
407
+ const peakTokenPeriod = findPeakEntry(filtered, (entry) => entry.totalTokens);
412
408
  const recentRows = sortByDate(filtered)
413
409
  .slice(-12)
414
410
  .reverse()
@@ -466,6 +462,34 @@ function buildReportData(allDailyData, options = {}) {
466
462
  },
467
463
  ];
468
464
 
465
+ const topChartModels = modelRows.slice(0, 8);
466
+ const truncatedTopModelNames = topChartModels
467
+ .filter((entry) => truncateTopModelChartLabel(entry.name) !== entry.name)
468
+ .map((entry) => entry.name);
469
+ const topModelSummary = metrics.topModel
470
+ ? translate(language, 'report.charts.topModelsSummary', {
471
+ model: metrics.topModel.name,
472
+ cost: formatCurrency(metrics.topModel.cost, language),
473
+ share: formatPercent(metrics.topModelShare, language),
474
+ })
475
+ : translate(language, 'report.charts.noDataSummary');
476
+ const costTrendSummary =
477
+ latestPeriod && peakCostPeriod
478
+ ? translate(language, 'report.charts.costTrendSummary', {
479
+ latest: formatCurrency(latestPeriod.totalCost, language),
480
+ peak: formatCurrency(peakCostPeriod.totalCost, language),
481
+ date: formatDate(peakCostPeriod.date, 'long', language),
482
+ })
483
+ : translate(language, 'report.charts.noDataSummary');
484
+ const tokenTrendSummary =
485
+ peakTokenPeriod && metrics.totalTokens > 0
486
+ ? translate(language, 'report.charts.tokenTrendSummary', {
487
+ total: formatCompact(metrics.totalTokens, language),
488
+ peak: formatCompact(peakTokenPeriod.totalTokens, language),
489
+ date: formatDate(peakTokenPeriod.date, 'long', language),
490
+ })
491
+ : translate(language, 'report.charts.noDataSummary');
492
+
469
493
  const interpretationSummary = translate(language, 'report.interpretation.summary', {
470
494
  days: formatInteger(filteredDaily.length, language),
471
495
  periods: formatInteger(filtered.length, language),
@@ -530,6 +554,26 @@ function buildReportData(allDailyData, options = {}) {
530
554
  tokensLabel: formatCompact(entry.tokens, language),
531
555
  })),
532
556
  recentPeriods: recentRows,
557
+ chartDescriptions: {
558
+ costTrend: {
559
+ alt: translate(language, 'report.charts.costTrendAlt'),
560
+ summary: costTrendSummary,
561
+ },
562
+ topModels: {
563
+ alt: translate(language, 'report.charts.topModelsAlt'),
564
+ summary: topModelSummary,
565
+ fullNamesNote:
566
+ truncatedTopModelNames.length > 0
567
+ ? translate(language, 'report.charts.topModelsFullNames', {
568
+ names: truncatedTopModelNames.join(', '),
569
+ })
570
+ : null,
571
+ },
572
+ tokenTrend: {
573
+ alt: translate(language, 'report.charts.tokenTrendAlt'),
574
+ summary: tokenTrendSummary,
575
+ },
576
+ },
533
577
  labels: {
534
578
  dateRangeText: dateRange
535
579
  ? `${formatDate(dateRange.start, 'long', language)} - ${formatDate(dateRange.end, 'long', language)}`
@@ -618,7 +662,6 @@ module.exports = {
618
662
  formatDate,
619
663
  formatDateAxis,
620
664
  getModelColor,
621
- truncateLabel,
622
665
  __test__: {
623
666
  getModelProvider,
624
667
  normalizeModelName,
@@ -0,0 +1,17 @@
1
+ export type ModelColorTheme = 'dark' | 'light'
2
+
3
+ export interface ModelColorSpec {
4
+ h: number
5
+ s: number
6
+ l: number
7
+ }
8
+
9
+ export interface ModelColorOptions {
10
+ theme?: ModelColorTheme
11
+ alpha?: number
12
+ }
13
+
14
+ export function normalizeTheme(theme?: string): ModelColorTheme
15
+ export function getModelColorSpec(name: string, options?: ModelColorOptions): ModelColorSpec
16
+ export function getModelColor(name: string, options?: ModelColorOptions): string
17
+ export function getModelColorRgb(name: string, options?: ModelColorOptions): string
@@ -0,0 +1,241 @@
1
+ const MODEL_COLOR_RULES = [
2
+ {
3
+ pattern: /^OpenCode$/i,
4
+ light: { h: 192, s: 76, l: 40 },
5
+ dark: { h: 192, s: 82, l: 58 },
6
+ },
7
+ {
8
+ pattern: /^Codex(?: .+)?$/i,
9
+ light: { h: 194, s: 72, l: 38 },
10
+ dark: { h: 194, s: 78, l: 55 },
11
+ },
12
+ {
13
+ pattern: /^GPT-5\.4 Codex$/i,
14
+ light: { h: 194, s: 76, l: 42 },
15
+ dark: { h: 194, s: 82, l: 60 },
16
+ },
17
+ {
18
+ pattern: /^GPT-5\.3 Codex$/i,
19
+ light: { h: 194, s: 70, l: 36 },
20
+ dark: { h: 194, s: 74, l: 52 },
21
+ },
22
+ {
23
+ pattern: /^GPT-5\.4$/i,
24
+ light: { h: 148, s: 68, l: 40 },
25
+ dark: { h: 148, s: 72, l: 57 },
26
+ },
27
+ {
28
+ pattern: /^GPT-5$/i,
29
+ light: { h: 148, s: 60, l: 33 },
30
+ dark: { h: 148, s: 64, l: 47 },
31
+ },
32
+ {
33
+ pattern: /^GPT-(?:4o|4\.1)(?: .+)?$/i,
34
+ light: { h: 160, s: 58, l: 34 },
35
+ dark: { h: 160, s: 62, l: 49 },
36
+ },
37
+ {
38
+ pattern: /^o4 Mini$/i,
39
+ light: { h: 166, s: 64, l: 33 },
40
+ dark: { h: 166, s: 68, l: 48 },
41
+ },
42
+ {
43
+ pattern: /^o1$/i,
44
+ light: { h: 166, s: 56, l: 30 },
45
+ dark: { h: 166, s: 60, l: 43 },
46
+ },
47
+ {
48
+ pattern: /^Gemini 3 Flash Preview(?: .+)?$/i,
49
+ light: { h: 48, s: 100, l: 42 },
50
+ dark: { h: 52, s: 98, l: 61 },
51
+ },
52
+ {
53
+ pattern: /^Gemini 2\.5 Flash$/i,
54
+ light: { h: 44, s: 92, l: 39 },
55
+ dark: { h: 46, s: 94, l: 56 },
56
+ },
57
+ {
58
+ pattern: /^Gemini 2\.5 Pro$/i,
59
+ light: { h: 38, s: 86, l: 34 },
60
+ dark: { h: 40, s: 88, l: 49 },
61
+ },
62
+ {
63
+ pattern: /^Gemini(?: .+)?$/i,
64
+ light: { h: 42, s: 88, l: 36 },
65
+ dark: { h: 44, s: 90, l: 52 },
66
+ },
67
+ {
68
+ pattern: /^(?:Claude\s+)?Opus 4\.6$/i,
69
+ light: { h: 274, s: 68, l: 44 },
70
+ dark: { h: 274, s: 74, l: 66 },
71
+ },
72
+ {
73
+ pattern: /^(?:Claude\s+)?Opus 4\.5$/i,
74
+ light: { h: 274, s: 58, l: 36 },
75
+ dark: { h: 274, s: 62, l: 56 },
76
+ },
77
+ {
78
+ pattern: /^(?:Claude\s+)?Opus(?: .+)?$/i,
79
+ light: { h: 274, s: 62, l: 40 },
80
+ dark: { h: 274, s: 68, l: 60 },
81
+ },
82
+ {
83
+ pattern: /^(?:Claude\s+)?Sonnet 4\.6$/i,
84
+ light: { h: 214, s: 72, l: 44 },
85
+ dark: { h: 214, s: 80, l: 63 },
86
+ },
87
+ {
88
+ pattern: /^(?:Claude\s+)?Sonnet 4\.5$/i,
89
+ light: { h: 214, s: 60, l: 36 },
90
+ dark: { h: 214, s: 66, l: 52 },
91
+ },
92
+ {
93
+ pattern: /^(?:Claude\s+)?Sonnet 4$/i,
94
+ light: { h: 214, s: 56, l: 34 },
95
+ dark: { h: 214, s: 62, l: 48 },
96
+ },
97
+ {
98
+ pattern: /^(?:Claude\s+)?Sonnet(?: .+)?$/i,
99
+ light: { h: 214, s: 64, l: 40 },
100
+ dark: { h: 214, s: 70, l: 56 },
101
+ },
102
+ {
103
+ pattern: /^(?:Claude\s+)?Haiku 4\.5$/i,
104
+ light: { h: 28, s: 90, l: 43 },
105
+ dark: { h: 28, s: 92, l: 61 },
106
+ },
107
+ {
108
+ pattern: /^(?:Claude\s+)?Haiku(?: .+)?$/i,
109
+ light: { h: 28, s: 84, l: 38 },
110
+ dark: { h: 28, s: 88, l: 56 },
111
+ },
112
+ ]
113
+
114
+ const FALLBACK_HUES = [148, 168, 190, 208, 226, 248, 272, 332, 18, 30, 44]
115
+
116
+ function normalizeTheme(theme) {
117
+ return theme === 'light' ? 'light' : 'dark'
118
+ }
119
+
120
+ function normalizeAlpha(alpha) {
121
+ if (typeof alpha !== 'number' || !Number.isFinite(alpha)) return null
122
+ if (alpha <= 0) return 0
123
+ if (alpha >= 1) return 1
124
+ return Math.round(alpha * 1000) / 1000
125
+ }
126
+
127
+ function hashName(name) {
128
+ let hash = 0
129
+ const value = String(name || '')
130
+ .trim()
131
+ .toLowerCase()
132
+ for (let index = 0; index < value.length; index += 1) {
133
+ hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0
134
+ }
135
+ return hash
136
+ }
137
+
138
+ function mod(value, divisor) {
139
+ return ((value % divisor) + divisor) % divisor
140
+ }
141
+
142
+ function findKnownColor(name) {
143
+ return MODEL_COLOR_RULES.find((rule) => rule.pattern.test(String(name || '').trim())) ?? null
144
+ }
145
+
146
+ function fallbackColor(name, theme) {
147
+ const hash = hashName(name)
148
+ const baseHue = FALLBACK_HUES[mod(hash, FALLBACK_HUES.length)]
149
+ const hueShift = ((Math.abs(hash >> 4) % 7) - 3) * 4
150
+ const hue = mod(baseHue + hueShift, 360)
151
+
152
+ if (theme === 'light') {
153
+ return {
154
+ h: hue,
155
+ s: 62 + (Math.abs(hash >> 8) % 10),
156
+ l: 34 + (Math.abs(hash >> 12) % 8),
157
+ }
158
+ }
159
+
160
+ return {
161
+ h: hue,
162
+ s: 68 + (Math.abs(hash >> 8) % 10),
163
+ l: 52 + (Math.abs(hash >> 12) % 8),
164
+ }
165
+ }
166
+
167
+ function getModelColorSpec(name, options = {}) {
168
+ const theme = normalizeTheme(options.theme)
169
+ const known = findKnownColor(name)
170
+ return known ? { ...known[theme] } : fallbackColor(name, theme)
171
+ }
172
+
173
+ function toHslString(spec) {
174
+ return `hsl(${spec.h}, ${spec.s}%, ${spec.l}%)`
175
+ }
176
+
177
+ function toHslaString(spec, alpha) {
178
+ return `hsla(${spec.h}, ${spec.s}%, ${spec.l}%, ${alpha})`
179
+ }
180
+
181
+ function hslToRgb(spec) {
182
+ const hue = mod(spec.h, 360)
183
+ const saturation = Math.max(0, Math.min(100, spec.s)) / 100
184
+ const lightness = Math.max(0, Math.min(100, spec.l)) / 100
185
+ const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation
186
+ const huePrime = hue / 60
187
+ const x = chroma * (1 - Math.abs((huePrime % 2) - 1))
188
+
189
+ let red = 0
190
+ let green = 0
191
+ let blue = 0
192
+
193
+ if (huePrime >= 0 && huePrime < 1) {
194
+ red = chroma
195
+ green = x
196
+ } else if (huePrime < 2) {
197
+ red = x
198
+ green = chroma
199
+ } else if (huePrime < 3) {
200
+ green = chroma
201
+ blue = x
202
+ } else if (huePrime < 4) {
203
+ green = x
204
+ blue = chroma
205
+ } else if (huePrime < 5) {
206
+ red = x
207
+ blue = chroma
208
+ } else {
209
+ red = chroma
210
+ blue = x
211
+ }
212
+
213
+ const match = lightness - chroma / 2
214
+ return {
215
+ r: Math.round((red + match) * 255),
216
+ g: Math.round((green + match) * 255),
217
+ b: Math.round((blue + match) * 255),
218
+ }
219
+ }
220
+
221
+ function getModelColor(name, options = {}) {
222
+ const spec = getModelColorSpec(name, options)
223
+ const alpha = normalizeAlpha(options.alpha)
224
+ return alpha === null ? toHslString(spec) : toHslaString(spec, alpha)
225
+ }
226
+
227
+ function getModelColorRgb(name, options = {}) {
228
+ const spec = getModelColorSpec(name, options)
229
+ const { r, g, b } = hslToRgb(spec)
230
+ const alpha = normalizeAlpha(options.alpha)
231
+ if (alpha === null) return `rgb(${r}, ${g}, ${b})`
232
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
233
+ }
234
+
235
+ module.exports = {
236
+ MODEL_COLOR_RULES,
237
+ getModelColor,
238
+ getModelColorRgb,
239
+ getModelColorSpec,
240
+ normalizeTheme,
241
+ }
@@ -1101,7 +1101,15 @@
1101
1101
  "charts": {
1102
1102
  "costTrend": "Kostenverlauf",
1103
1103
  "topModels": "Top-Modelle nach Kosten",
1104
- "tokenTrend": "Token-Mix pro Zeitraum"
1104
+ "tokenTrend": "Token-Mix pro Zeitraum",
1105
+ "costTrendAlt": "Liniendiagramm der Report-Kosten pro Zeitraum.",
1106
+ "costTrendSummary": "Letzter Wert {{latest}}. Peak {{peak}} am {{date}}.",
1107
+ "topModelsAlt": "Horizontales Balkendiagramm der teuersten Modelle im Report.",
1108
+ "topModelsSummary": "{{model}} führt mit {{cost}} und {{share}} der Report-Kosten.",
1109
+ "topModelsFullNames": "Vollständige Diagramm-Labels: {{names}}.",
1110
+ "tokenTrendAlt": "Gestapeltes Balkendiagramm des Token-Mix pro Report-Zeitraum.",
1111
+ "tokenTrendSummary": "Gesamt {{total}}. Höchstes Token-Volumen {{peak}} am {{date}}.",
1112
+ "noDataSummary": "Nicht genug Daten für eine belastbare Diagramm-Zusammenfassung."
1105
1113
  },
1106
1114
  "interpretation": {
1107
1115
  "summary": "Dieser Report umfasst {{days}} Rohdaten-Tage und {{periods}} aggregierte Zeiträume. Spitzenzeitraum: {{peak}}. Top-Modell: {{topModel}}. Führender Anbieter: {{topProvider}}.",
@@ -1101,7 +1101,15 @@
1101
1101
  "charts": {
1102
1102
  "costTrend": "Cost trend",
1103
1103
  "topModels": "Top models by cost",
1104
- "tokenTrend": "Token mix by period"
1104
+ "tokenTrend": "Token mix by period",
1105
+ "costTrendAlt": "Line chart showing report cost by period.",
1106
+ "costTrendSummary": "Latest {{latest}}. Peak {{peak}} on {{date}}.",
1107
+ "topModelsAlt": "Horizontal bar chart comparing the highest-cost models in the report.",
1108
+ "topModelsSummary": "{{model}} leads with {{cost}} and {{share}} of report cost.",
1109
+ "topModelsFullNames": "Full chart labels: {{names}}.",
1110
+ "tokenTrendAlt": "Stacked bar chart showing the token mix for each report period.",
1111
+ "tokenTrendSummary": "Total {{total}}. Peak token volume {{peak}} on {{date}}.",
1112
+ "noDataSummary": "Not enough data for a stable chart summary."
1105
1113
  },
1106
1114
  "interpretation": {
1107
1115
  "summary": "This report covers {{days}} raw days and {{periods}} aggregated periods. Peak period: {{peak}}. Top model: {{topModel}}. Leading provider: {{topProvider}}.",