tledger 0.3.1 → 0.4.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/README.md +283 -157
- package/bin/token-ledger-cache-data.mjs +492 -0
- package/bin/token-ledger-cache-image.mjs +7 -1147
- package/bin/token-ledger-cache-sections.mjs +848 -0
- package/bin/token-ledger-cost-terminal.mjs +234 -0
- package/bin/token-ledger-image-layout.mjs +20 -0
- package/bin/token-ledger-image-primitives.mjs +192 -0
- package/bin/token-ledger-report-data.mjs +1159 -0
- package/bin/token-ledger-source-status.mjs +31 -0
- package/bin/token-ledger-terminal.mjs +245 -69
- package/bin/token-ledger-trend-image.mjs +2025 -1724
- package/bin/token-ledger-trend-terminal.mjs +273 -150
- package/bin/token-ledger-trend.mjs +204 -198
- package/bin/token-ledger-tui.mjs +180 -51
- package/bin/token-ledger.mjs +735 -208
- package/docs/durable-ledger-operations.md +198 -0
- package/docs/release-notes-0.4.0.md +41 -0
- package/docs/token-ledger-report-7-day.png +0 -0
- package/lib/token-ledger-calendar.mjs +225 -0
- package/lib/token-ledger-collection.mjs +100 -0
- package/lib/token-ledger-importer.mjs +3329 -488
- package/lib/token-ledger-labels.mjs +66 -0
- package/lib/token-ledger-ledger.mjs +6056 -0
- package/lib/token-ledger-quota-contract.mjs +38 -0
- package/lib/token-ledger-range-analysis.mjs +120 -0
- package/lib/token-ledger-rates.mjs +330 -0
- package/lib/token-ledger-snapshot.mjs +336 -35
- package/lib/token-ledger-terminal-text.mjs +11 -0
- package/lib/token-ledger-usage.mjs +339 -33
- package/package.json +13 -10
- package/bin/token-ledger-rates.mjs +0 -65
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
API_USD_RATE_CARD_AS_OF,
|
|
3
|
+
CODEX_CREDIT_RATE_CARD,
|
|
4
|
+
CODEX_CREDIT_RATE_CARD_AS_OF,
|
|
5
|
+
apiUsdForUsage,
|
|
6
|
+
calculateCodexPurchasedCredits,
|
|
7
|
+
codexCreditMultiplier,
|
|
8
|
+
hasDetailedTokenBreakdown,
|
|
9
|
+
normalizeCodexCreditModel,
|
|
10
|
+
} from "../lib/token-ledger-rates.mjs";
|
|
11
|
+
import { sanitizeTerminalText } from "../lib/token-ledger-terminal-text.mjs";
|
|
12
|
+
import {
|
|
13
|
+
snapshotFreshnessDetail,
|
|
14
|
+
sourceStatusLine,
|
|
15
|
+
} from "./token-ledger-source-status.mjs";
|
|
16
|
+
|
|
17
|
+
function nonNegative(value) {
|
|
18
|
+
const number = Number(value);
|
|
19
|
+
return Number.isFinite(number) ? Math.max(0, number) : 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function compact(value) {
|
|
23
|
+
const number = nonNegative(value);
|
|
24
|
+
if (number >= 1_000_000_000) return `${(number / 1_000_000_000).toFixed(1)}B`;
|
|
25
|
+
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`;
|
|
26
|
+
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`;
|
|
27
|
+
return Math.round(number).toLocaleString("en-US");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function percent(numerator, denominator) {
|
|
31
|
+
if (!(denominator > 0)) return "0.0%";
|
|
32
|
+
return `${((numerator / denominator) * 100).toFixed(1)}%`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Snapshot model identifiers are unbounded input; padding every row to an
|
|
36
|
+
// unbounded column width would amplify one long label across the whole table.
|
|
37
|
+
const MODEL_LABEL_MAX_WIDTH = 40;
|
|
38
|
+
|
|
39
|
+
function modelLabel(model) {
|
|
40
|
+
const normalized = normalizeCodexCreditModel(model);
|
|
41
|
+
const labels = {
|
|
42
|
+
"gpt-6-astra": "GPT-6 Astra",
|
|
43
|
+
"gpt-5.6-sol": "GPT-5.6 Sol",
|
|
44
|
+
"gpt-5.6-terra": "GPT-5.6 Terra",
|
|
45
|
+
"gpt-5.6-luna": "GPT-5.6 Luna",
|
|
46
|
+
"gpt-5.5": "GPT-5.5",
|
|
47
|
+
"daybreak-blue": "Daybreak Blue",
|
|
48
|
+
"daybreak-red": "Daybreak Red",
|
|
49
|
+
"gpt-5.4": "GPT-5.4",
|
|
50
|
+
"gpt-5.4-mini": "GPT-5.4 mini",
|
|
51
|
+
"gpt-5.3-codex": "GPT-5.3 Codex",
|
|
52
|
+
"gpt-5.2": "GPT-5.2",
|
|
53
|
+
};
|
|
54
|
+
const label = sanitizeTerminalText(
|
|
55
|
+
normalized.includes("astra")
|
|
56
|
+
? "GPT-6 Astra"
|
|
57
|
+
: labels[normalized] ?? String(model ?? "Unknown model"),
|
|
58
|
+
);
|
|
59
|
+
return label.length > MODEL_LABEL_MAX_WIDTH
|
|
60
|
+
? `${label.slice(0, MODEL_LABEL_MAX_WIDTH - 1)}…`
|
|
61
|
+
: label;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function creditReason(event) {
|
|
65
|
+
const model = normalizeCodexCreditModel(
|
|
66
|
+
event?.rateCardModel ?? event?.model,
|
|
67
|
+
);
|
|
68
|
+
if (!CODEX_CREDIT_RATE_CARD[model]) return "unknown-model";
|
|
69
|
+
if (!hasDetailedTokenBreakdown(event)) return "incomplete-token-breakdown";
|
|
70
|
+
if (
|
|
71
|
+
codexCreditMultiplier(
|
|
72
|
+
event?.rateCardModel ?? event?.model,
|
|
73
|
+
event?.serviceTier,
|
|
74
|
+
) === null
|
|
75
|
+
) {
|
|
76
|
+
return "unsupported-credit-fast-tier";
|
|
77
|
+
}
|
|
78
|
+
return "unrated-credit-usage";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function eventEstimate(event, basis) {
|
|
82
|
+
if (basis === "api-usd") return apiUsdForUsage(event);
|
|
83
|
+
const amount = calculateCodexPurchasedCredits({
|
|
84
|
+
model: event?.rateCardModel ?? event?.model,
|
|
85
|
+
serviceTier: event?.serviceTier,
|
|
86
|
+
usage: event,
|
|
87
|
+
});
|
|
88
|
+
const totalTokens = nonNegative(event?.totalTokens);
|
|
89
|
+
if (amount === null) {
|
|
90
|
+
return {
|
|
91
|
+
amount: null,
|
|
92
|
+
ratedTokens: 0,
|
|
93
|
+
unratedTokens: totalTokens,
|
|
94
|
+
reasons: [creditReason(event)],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
amount,
|
|
99
|
+
ratedTokens: totalTokens,
|
|
100
|
+
unratedTokens: 0,
|
|
101
|
+
reasons: [],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function aggregate(events, basis) {
|
|
106
|
+
const models = new Map();
|
|
107
|
+
const reasons = new Map();
|
|
108
|
+
let amount = 0;
|
|
109
|
+
let amountKnown = false;
|
|
110
|
+
let ratedTokens = 0;
|
|
111
|
+
let unratedTokens = 0;
|
|
112
|
+
|
|
113
|
+
for (const event of events) {
|
|
114
|
+
const key = normalizeCodexCreditModel(event?.model);
|
|
115
|
+
const row = models.get(key) ?? {
|
|
116
|
+
model: modelLabel(event?.model),
|
|
117
|
+
inputTokens: 0,
|
|
118
|
+
cachedInputTokens: 0,
|
|
119
|
+
outputTokens: 0,
|
|
120
|
+
amount: 0,
|
|
121
|
+
amountKnown: false,
|
|
122
|
+
ratedTokens: 0,
|
|
123
|
+
unratedTokens: 0,
|
|
124
|
+
};
|
|
125
|
+
row.inputTokens += nonNegative(event?.inputTokens);
|
|
126
|
+
row.cachedInputTokens += Math.min(
|
|
127
|
+
nonNegative(event?.inputTokens),
|
|
128
|
+
nonNegative(event?.cachedInputTokens),
|
|
129
|
+
);
|
|
130
|
+
row.outputTokens += nonNegative(event?.outputTokens);
|
|
131
|
+
const estimate = eventEstimate(event, basis);
|
|
132
|
+
row.ratedTokens += estimate.ratedTokens;
|
|
133
|
+
row.unratedTokens += estimate.unratedTokens;
|
|
134
|
+
ratedTokens += estimate.ratedTokens;
|
|
135
|
+
unratedTokens += estimate.unratedTokens;
|
|
136
|
+
if (estimate.amount !== null) {
|
|
137
|
+
row.amount += estimate.amount;
|
|
138
|
+
row.amountKnown = true;
|
|
139
|
+
amount += estimate.amount;
|
|
140
|
+
amountKnown = true;
|
|
141
|
+
}
|
|
142
|
+
for (const reason of estimate.reasons) {
|
|
143
|
+
reasons.set(reason, (reasons.get(reason) ?? 0) + estimate.unratedTokens);
|
|
144
|
+
}
|
|
145
|
+
models.set(key, row);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
models: [...models.values()].sort((a, b) =>
|
|
150
|
+
b.ratedTokens + b.unratedTokens - (a.ratedTokens + a.unratedTokens)
|
|
151
|
+
),
|
|
152
|
+
amount: amountKnown ? amount : null,
|
|
153
|
+
ratedTokens,
|
|
154
|
+
unratedTokens,
|
|
155
|
+
reasons,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function formatAmount(amount, basis) {
|
|
160
|
+
if (amount === null) return "—";
|
|
161
|
+
if (basis === "api-usd") {
|
|
162
|
+
return amount >= 0.01 ? `$${amount.toFixed(2)}` : `$${amount.toFixed(6)}`;
|
|
163
|
+
}
|
|
164
|
+
return `${amount.toFixed(3)} credits`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function pad(value, width, align = "left") {
|
|
168
|
+
const text = String(value);
|
|
169
|
+
if (text.length >= width) return text;
|
|
170
|
+
const spaces = " ".repeat(width - text.length);
|
|
171
|
+
return align === "right" ? `${spaces}${text}` : `${text}${spaces}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function renderCostTerminal({
|
|
175
|
+
events,
|
|
176
|
+
bounds,
|
|
177
|
+
basis,
|
|
178
|
+
snapshotFreshness = null,
|
|
179
|
+
sourceStatus = "unchecked-cache",
|
|
180
|
+
}) {
|
|
181
|
+
const report = aggregate(events, basis);
|
|
182
|
+
const heading = basis === "api-usd"
|
|
183
|
+
? "Hypothetical API-equivalent cost (USD)"
|
|
184
|
+
: "Codex purchased-credit estimate";
|
|
185
|
+
const amountLabel = basis === "api-usd" ? "USD" : "Credits";
|
|
186
|
+
const modelWidth = Math.max(
|
|
187
|
+
12,
|
|
188
|
+
...report.models.map((row) => row.model.length),
|
|
189
|
+
);
|
|
190
|
+
const rows = report.models.map((row) => {
|
|
191
|
+
const rowTokens = row.ratedTokens + row.unratedTokens;
|
|
192
|
+
return [
|
|
193
|
+
pad(row.model, modelWidth),
|
|
194
|
+
pad(compact(row.inputTokens), 9, "right"),
|
|
195
|
+
pad(compact(row.cachedInputTokens), 9, "right"),
|
|
196
|
+
pad(compact(row.outputTokens), 9, "right"),
|
|
197
|
+
pad(formatAmount(row.amountKnown ? row.amount : null, basis), 16, "right"),
|
|
198
|
+
pad(percent(row.ratedTokens, rowTokens), 9, "right"),
|
|
199
|
+
].join(" ");
|
|
200
|
+
});
|
|
201
|
+
const totalTokens = report.ratedTokens + report.unratedTokens;
|
|
202
|
+
const reasonLines = [...report.reasons.entries()]
|
|
203
|
+
.map(([reason, tokens]) => `${reason} (${compact(tokens)} tokens)`);
|
|
204
|
+
const cardDate = basis === "api-usd"
|
|
205
|
+
? API_USD_RATE_CARD_AS_OF
|
|
206
|
+
: CODEX_CREDIT_RATE_CARD_AS_OF;
|
|
207
|
+
const footer = basis === "api-usd"
|
|
208
|
+
? "Local history is incomplete account evidence; unsupported API charges are excluded. This is not an actual bill."
|
|
209
|
+
: "Purchased-credit rates do not infer included-plan or five-hour/weekly meter usage.";
|
|
210
|
+
|
|
211
|
+
return [
|
|
212
|
+
heading,
|
|
213
|
+
`Range: ${bounds.startDateString ?? bounds.start.toISOString()} through ${bounds.endDateString ?? bounds.end.toISOString()} (${bounds.timeZone})`,
|
|
214
|
+
`Snapshot: ${snapshotFreshnessDetail(snapshotFreshness)}`,
|
|
215
|
+
sourceStatusLine(sourceStatus),
|
|
216
|
+
"",
|
|
217
|
+
[
|
|
218
|
+
pad("Model", modelWidth),
|
|
219
|
+
pad("Input", 9, "right"),
|
|
220
|
+
pad("Cached", 9, "right"),
|
|
221
|
+
pad("Output", 9, "right"),
|
|
222
|
+
pad(amountLabel, 16, "right"),
|
|
223
|
+
pad("Coverage", 9, "right"),
|
|
224
|
+
].join(" "),
|
|
225
|
+
...rows,
|
|
226
|
+
"",
|
|
227
|
+
`Total rated amount: ${formatAmount(report.amount, basis)}`,
|
|
228
|
+
`Rated token coverage: ${percent(report.ratedTokens, totalTokens)}`,
|
|
229
|
+
`Unrated tokens: ${compact(report.unratedTokens)}`,
|
|
230
|
+
`Rate card as of: ${cardDate}`,
|
|
231
|
+
`Reasons: ${reasonLines.length > 0 ? reasonLines.join(", ") : "none"}`,
|
|
232
|
+
footer,
|
|
233
|
+
].join("\n");
|
|
234
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Shared image-axis sizing rules. This module has no renderer or domain imports.
|
|
2
|
+
|
|
3
|
+
export function chooseBinSize(
|
|
4
|
+
days,
|
|
5
|
+
width,
|
|
6
|
+
{ minBinWidth = 1, preferDaily = false } = {},
|
|
7
|
+
) {
|
|
8
|
+
const rangeDays = Number(days);
|
|
9
|
+
const plotWidth = Math.max(1, Number(width) || 1);
|
|
10
|
+
const minimumBinWidth = Math.max(1, Number(minBinWidth) || 1);
|
|
11
|
+
const maxBinCount = Math.max(1, Math.floor(plotWidth / minimumBinWidth));
|
|
12
|
+
const preferredBinSize = preferDaily
|
|
13
|
+
? 1
|
|
14
|
+
: rangeDays <= 14
|
|
15
|
+
? 1
|
|
16
|
+
: plotWidth >= 120
|
|
17
|
+
? 2
|
|
18
|
+
: 3;
|
|
19
|
+
return Math.max(preferredBinSize, Math.ceil(rangeDays / maxBinCount));
|
|
20
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Shared, dependency-neutral values and SVG helpers used by image reports.
|
|
2
|
+
|
|
3
|
+
export const TREND_IMAGE_MODEL_COLORS = {
|
|
4
|
+
Astra: "#e879f9",
|
|
5
|
+
Luna: "#3b82f6",
|
|
6
|
+
Sol: "#10a394",
|
|
7
|
+
Terra: "#8b7cf6",
|
|
8
|
+
"GPT-5.5": "#d55181",
|
|
9
|
+
"GPT-5.4": "#0891b2",
|
|
10
|
+
Daybreak: "#16a34a",
|
|
11
|
+
"Auto review": "#e5484d",
|
|
12
|
+
Other: "#64748b",
|
|
13
|
+
Unknown: "#64748b",
|
|
14
|
+
Unattributed: "#475569",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const TREND_IMAGE_COLORS = {
|
|
18
|
+
background: "#0e1420",
|
|
19
|
+
panel: "#151d2c",
|
|
20
|
+
panelBorder: "#273246",
|
|
21
|
+
meterPanel: "#1b1712",
|
|
22
|
+
meterPanelBorder: "rgba(246,183,60,.4)",
|
|
23
|
+
ink: "#f2f5fa",
|
|
24
|
+
secondary: "#aeb8c9",
|
|
25
|
+
muted: "#77839a",
|
|
26
|
+
grid: "#1c2534",
|
|
27
|
+
baseline: "#33405a",
|
|
28
|
+
rule: "rgba(255,255,255,.1)",
|
|
29
|
+
track: "rgba(255,255,255,.09)",
|
|
30
|
+
projectTrack: "rgba(255,255,255,.07)",
|
|
31
|
+
line: "#f6b73c",
|
|
32
|
+
meterAxis: "#cf9a37",
|
|
33
|
+
chipFill: "#151d2c",
|
|
34
|
+
leftAxis: "#7ea2f0",
|
|
35
|
+
deltaUp: "#7fb37a",
|
|
36
|
+
deltaUpFill: "rgba(127,179,122,.14)",
|
|
37
|
+
deltaDown: "#e08a86",
|
|
38
|
+
deltaDownFill: "rgba(217,83,79,.16)",
|
|
39
|
+
remainderBar: "#475569",
|
|
40
|
+
onFill: "rgba(255,255,255,.82)",
|
|
41
|
+
cached: "#2ec4a1",
|
|
42
|
+
uncached: "#d88362",
|
|
43
|
+
weighted: "#c7d2e8",
|
|
44
|
+
cacheTrack: "#202a3a",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const CACHE_IMAGE_COLORS = {
|
|
48
|
+
background: "#0e1420",
|
|
49
|
+
ink: "#f2f5fa",
|
|
50
|
+
secondary: "#aeb8c9",
|
|
51
|
+
muted: "#77839a",
|
|
52
|
+
grid: "#1c2534",
|
|
53
|
+
baseline: "#33405a",
|
|
54
|
+
track: "#202a3a",
|
|
55
|
+
cached: "#2ec4a1",
|
|
56
|
+
uncached: "#d88362",
|
|
57
|
+
volume: "#64748b",
|
|
58
|
+
weighted: "#c7d2e8",
|
|
59
|
+
rule: "rgba(255,255,255,.1)",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const IMAGE_FONT_FAMILY = "system-ui, -apple-system, 'Segoe UI', sans-serif";
|
|
63
|
+
export const IMAGE_MONO_FAMILY = "ui-monospace, Menlo, monospace";
|
|
64
|
+
export const FAST_MODE_LABEL_COLOR = "#a78bfa";
|
|
65
|
+
|
|
66
|
+
const XML_INVALID_CHARACTERS =
|
|
67
|
+
/[^\u0009\u000a\u000d\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu;
|
|
68
|
+
|
|
69
|
+
export function escapeXml(value) {
|
|
70
|
+
return String(value)
|
|
71
|
+
.replace(XML_INVALID_CHARACTERS, "")
|
|
72
|
+
.replaceAll("&", "&")
|
|
73
|
+
.replaceAll("<", "<")
|
|
74
|
+
.replaceAll(">", ">")
|
|
75
|
+
.replaceAll('"', """)
|
|
76
|
+
.replaceAll("'", "'");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function compact(value, digits = 2) {
|
|
80
|
+
if (!Number.isFinite(value)) return "—";
|
|
81
|
+
const absolute = Math.abs(value);
|
|
82
|
+
const units = [
|
|
83
|
+
[1_000_000_000, "B"],
|
|
84
|
+
[1_000_000, "M"],
|
|
85
|
+
[1_000, "K"],
|
|
86
|
+
];
|
|
87
|
+
for (let index = 0; index < units.length; index += 1) {
|
|
88
|
+
const [divisor, suffix] = units[index];
|
|
89
|
+
if (absolute < divisor) continue;
|
|
90
|
+
const scaled = value / divisor;
|
|
91
|
+
const magnitude = Math.abs(scaled);
|
|
92
|
+
const precision = magnitude >= 100 ? 0 : magnitude >= 10 ? 1 : digits;
|
|
93
|
+
// Values that round to 1000 of a unit belong to the next unit up
|
|
94
|
+
// (999,999 → 1.00M, not 1000K).
|
|
95
|
+
if (index > 0 && Number(magnitude.toFixed(precision)) >= 1_000) {
|
|
96
|
+
return compact(Math.sign(value) * divisor * 1_000, digits);
|
|
97
|
+
}
|
|
98
|
+
return `${scaled.toFixed(precision)}${suffix}`;
|
|
99
|
+
}
|
|
100
|
+
return Math.round(value).toLocaleString("en-US");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Darker step of the same hue, used for the fast-mode share of a segment.
|
|
104
|
+
export function fastShade(hexColor) {
|
|
105
|
+
const match = /^#([0-9a-f]{6})$/i.exec(String(hexColor));
|
|
106
|
+
if (!match) return hexColor;
|
|
107
|
+
const channels = [0, 2, 4].map((offset) =>
|
|
108
|
+
Math.round(parseInt(match[1].slice(offset, offset + 2), 16) * 0.62),
|
|
109
|
+
);
|
|
110
|
+
return `#${channels.map((value) => value.toString(16).padStart(2, "0")).join("")}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function shiftCalendarDate(dateString, amount) {
|
|
114
|
+
const [year, month, day] = String(dateString).split("-").map(Number);
|
|
115
|
+
const date = new Date(Date.UTC(year, month - 1, day + amount));
|
|
116
|
+
return [date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()]
|
|
117
|
+
.map((value, index) =>
|
|
118
|
+
index === 0 ? String(value) : String(value).padStart(2, "0"),
|
|
119
|
+
)
|
|
120
|
+
.join("-");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Rough sans-serif advance widths in em units, for placing inline runs
|
|
124
|
+
// (value + chip, legend items, pace rows). SVG has no flow layout.
|
|
125
|
+
export function textWidth(text, size, weight = 400) {
|
|
126
|
+
let units = 0;
|
|
127
|
+
for (const character of String(text)) {
|
|
128
|
+
if (/[il.,:;'|!]/.test(character)) units += 0.3;
|
|
129
|
+
else if (/[Ijtfr\-()[\] ]/.test(character)) units += 0.37;
|
|
130
|
+
else if (/[mwMW@%]/.test(character)) units += 0.92;
|
|
131
|
+
else if (/[A-Z]/.test(character)) units += 0.7;
|
|
132
|
+
else if (/[0-9+±×−]/.test(character)) units += 0.58;
|
|
133
|
+
else units += 0.55;
|
|
134
|
+
}
|
|
135
|
+
return units * size * (weight >= 700 ? 1.05 : 1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function truncateText(text, maxWidth, size, weight = 400) {
|
|
139
|
+
let value = String(text ?? "").replace(/\.{3,}/g, "…");
|
|
140
|
+
if (!(maxWidth > 0) || textWidth(value, size, weight) <= maxWidth) return value;
|
|
141
|
+
if (value.includes("…")) {
|
|
142
|
+
const leading = `${value.split("…", 1)[0].trimEnd()}…`;
|
|
143
|
+
if (textWidth(leading, size, weight) <= maxWidth) return leading;
|
|
144
|
+
value = leading;
|
|
145
|
+
}
|
|
146
|
+
const ellipsis = "…";
|
|
147
|
+
const ellipsisWidth = textWidth(ellipsis, size, weight);
|
|
148
|
+
if (ellipsisWidth >= maxWidth) return ellipsis;
|
|
149
|
+
|
|
150
|
+
const characters = [...value];
|
|
151
|
+
let low = 0;
|
|
152
|
+
let high = characters.length;
|
|
153
|
+
while (low < high) {
|
|
154
|
+
const middle = Math.ceil((low + high) / 2);
|
|
155
|
+
const candidate = `${characters.slice(0, middle).join("")}${ellipsis}`;
|
|
156
|
+
if (textWidth(candidate, size, weight) <= maxWidth) low = middle;
|
|
157
|
+
else high = middle - 1;
|
|
158
|
+
}
|
|
159
|
+
return `${characters.slice(0, low).join("").trimEnd()}${ellipsis}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function svgText({
|
|
163
|
+
x,
|
|
164
|
+
y,
|
|
165
|
+
value,
|
|
166
|
+
fill = TREND_IMAGE_COLORS.ink,
|
|
167
|
+
size = 12,
|
|
168
|
+
weight = 400,
|
|
169
|
+
anchor = "start",
|
|
170
|
+
spacing = null,
|
|
171
|
+
opacity = null,
|
|
172
|
+
mono = false,
|
|
173
|
+
}) {
|
|
174
|
+
const spacingAttr = spacing ? ` letter-spacing="${spacing}"` : "";
|
|
175
|
+
const opacityAttr = opacity !== null ? ` opacity="${opacity}"` : "";
|
|
176
|
+
const family = mono ? IMAGE_MONO_FAMILY : IMAGE_FONT_FAMILY;
|
|
177
|
+
return `<text x="${x}" y="${y}" fill="${fill}" font-family="${family}" font-size="${size}px" font-weight="${weight}" text-anchor="${anchor}"${spacingAttr}${opacityAttr}>${escapeXml(value)}</text>`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function svgRect(x, y, width, height, attrs = {}) {
|
|
181
|
+
const pieces = [
|
|
182
|
+
`x="${Number(x).toFixed(2)}"`,
|
|
183
|
+
`y="${Number(y).toFixed(2)}"`,
|
|
184
|
+
`width="${Math.max(0, Number(width)).toFixed(2)}"`,
|
|
185
|
+
`height="${Math.max(0, Number(height)).toFixed(2)}"`,
|
|
186
|
+
];
|
|
187
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
188
|
+
if (value === null || value === undefined) continue;
|
|
189
|
+
pieces.push(`${key}="${value}"`);
|
|
190
|
+
}
|
|
191
|
+
return `<rect ${pieces.join(" ")}/>`;
|
|
192
|
+
}
|