calculation-breakdown 0.0.0-stage → 0.1.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.
- package/CHANGELOG-ARCHIVE.md +3 -0
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +179 -2
- package/dist/calculation-breakdown-builder.d.ts +56 -0
- package/dist/calculation-breakdown-builder.d.ts.map +1 -0
- package/dist/calculation-breakdown-builder.js +242 -0
- package/dist/calculation-breakdown-builder.js.map +1 -0
- package/dist/calculation-breakdown-human-string.d.ts +24 -0
- package/dist/calculation-breakdown-human-string.d.ts.map +1 -0
- package/dist/calculation-breakdown-human-string.js +86 -0
- package/dist/calculation-breakdown-human-string.js.map +1 -0
- package/dist/calculation-breakdown.error.d.ts +14 -0
- package/dist/calculation-breakdown.error.d.ts.map +1 -0
- package/dist/calculation-breakdown.error.js +16 -0
- package/dist/calculation-breakdown.error.js.map +1 -0
- package/dist/calculation-breakdown.interface.d.ts +134 -0
- package/dist/calculation-breakdown.interface.d.ts.map +1 -0
- package/dist/calculation-breakdown.interface.js +2 -0
- package/dist/calculation-breakdown.interface.js.map +1 -0
- package/dist/calculation-breakdown.type.d.ts +20 -0
- package/dist/calculation-breakdown.type.d.ts.map +1 -0
- package/dist/calculation-breakdown.type.js +2 -0
- package/dist/calculation-breakdown.type.js.map +1 -0
- package/dist/cjs/calculation-breakdown-builder.d.ts +55 -0
- package/dist/cjs/calculation-breakdown-builder.js +245 -0
- package/dist/cjs/calculation-breakdown-human-string.d.ts +23 -0
- package/dist/cjs/calculation-breakdown-human-string.js +88 -0
- package/dist/cjs/calculation-breakdown.error.d.ts +13 -0
- package/dist/cjs/calculation-breakdown.error.js +19 -0
- package/dist/cjs/calculation-breakdown.interface.d.ts +133 -0
- package/dist/cjs/calculation-breakdown.interface.js +2 -0
- package/dist/cjs/calculation-breakdown.type.d.ts +19 -0
- package/dist/cjs/calculation-breakdown.type.js +2 -0
- package/dist/cjs/index.d.ts +5 -0
- package/dist/cjs/index.js +9 -0
- package/dist/cjs/package.json +3 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/docs/_config.yml +19 -0
- package/docs/assets/calculation-breakdown/calculation-breakdown-builder.js +242 -0
- package/docs/assets/calculation-breakdown/calculation-breakdown-human-string.js +86 -0
- package/docs/assets/calculation-breakdown/calculation-breakdown.error.js +16 -0
- package/docs/assets/calculation-breakdown/calculation-breakdown.interface.js +2 -0
- package/docs/assets/calculation-breakdown/calculation-breakdown.type.js +2 -0
- package/docs/assets/calculation-breakdown/index.js +4 -0
- package/docs/changelog-archive.md +9 -0
- package/docs/changelog.md +25 -0
- package/docs/index.md +13 -0
- package/docs/playground/index.html +110 -0
- package/docs/playground/playground.css +407 -0
- package/docs/playground/playground.js +1303 -0
- package/package.json +66 -4
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CalculationBreakdownBuilder,
|
|
3
|
+
toHumanString,
|
|
4
|
+
} from '../assets/calculation-breakdown/index.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_BUILDER_WITH_LOCALE = `// Derive calendar-day counts in UTC to avoid daylight-saving offsets.
|
|
7
|
+
const helper = {
|
|
8
|
+
now: new Date(),
|
|
9
|
+
get year() { return this.now.getFullYear(); },
|
|
10
|
+
day(month, date) { return Date.UTC(this.year, month, date); },
|
|
11
|
+
daysBetween(from, to) { return Math.round((to - from) / 86400000); },
|
|
12
|
+
get nextYear() { return Date.UTC(this.year + 1, 0, 1); },
|
|
13
|
+
get daysInYear() { return this.daysBetween(this.day(0, 1), this.nextYear); },
|
|
14
|
+
get remainingDays() {
|
|
15
|
+
return this.daysBetween(
|
|
16
|
+
this.day(this.now.getMonth(), this.now.getDate()),
|
|
17
|
+
this.nextYear,
|
|
18
|
+
);
|
|
19
|
+
},
|
|
20
|
+
moneyValue(value) { return value.toFixed(2); },
|
|
21
|
+
};
|
|
22
|
+
// Supply authoritative business amounts calculated by the host application.
|
|
23
|
+
const annualInsurancePrice = 444;
|
|
24
|
+
const iphoneAfterDiscountAmount = 759.05;
|
|
25
|
+
const airpodsAfterDiscountAmount = 45;
|
|
26
|
+
const insuranceAmount = annualInsurancePrice / helper.daysInYear * helper.remainingDays;
|
|
27
|
+
const subtotalAmount = iphoneAfterDiscountAmount + airpodsAfterDiscountAmount + insuranceAmount;
|
|
28
|
+
const taxAmount = subtotalAmount * 0.20;
|
|
29
|
+
const invoiceTotalAmount = subtotalAmount + taxAmount;
|
|
30
|
+
// Build the complete invoice calculation tree.
|
|
31
|
+
const invoice = new CalculationBreakdownBuilder()
|
|
32
|
+
.breakdown(({ money, number, duration, percent, add, subtract, multiply, divide, group }) => {
|
|
33
|
+
// Reuse one calculation shape for every discounted item line.
|
|
34
|
+
const discountedItem = ({ semantic, unitPrice, quantity, discount, subtotal }) => {
|
|
35
|
+
const beforeDiscount = multiply(
|
|
36
|
+
money('itemUnitPrice', unitPrice, 'USD'),
|
|
37
|
+
number('itemQuantity', quantity),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return group(
|
|
41
|
+
subtract(
|
|
42
|
+
group(beforeDiscount),
|
|
43
|
+
multiply(group(beforeDiscount), percent('itemDiscount', discount)),
|
|
44
|
+
),
|
|
45
|
+
{
|
|
46
|
+
wrap: true,
|
|
47
|
+
semantic,
|
|
48
|
+
result: money('itemSubtotal', subtotal, 'USD'),
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Combine compatible item groups under one identity-column semantic.
|
|
54
|
+
const itemLines = group(
|
|
55
|
+
add(
|
|
56
|
+
discountedItem({
|
|
57
|
+
semantic: 'iphoneLine',
|
|
58
|
+
unitPrice: '799.00',
|
|
59
|
+
quantity: '1',
|
|
60
|
+
discount: '0.05',
|
|
61
|
+
subtotal: helper.moneyValue(iphoneAfterDiscountAmount),
|
|
62
|
+
}),
|
|
63
|
+
discountedItem({
|
|
64
|
+
semantic: 'airpodsLine',
|
|
65
|
+
unitPrice: '25.00',
|
|
66
|
+
quantity: '2',
|
|
67
|
+
discount: '0.10',
|
|
68
|
+
subtotal: helper.moneyValue(airpodsAfterDiscountAmount),
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
{ semantic: 'itemName' },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Preserve every insurance detail in a vertical structured view.
|
|
75
|
+
const insuranceLine = group(
|
|
76
|
+
multiply(
|
|
77
|
+
group(
|
|
78
|
+
divide(
|
|
79
|
+
money('insuranceAnnualPrice', helper.moneyValue(annualInsurancePrice), 'USD'),
|
|
80
|
+
duration('insuranceYearLength', String(helper.daysInYear), 'days'),
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
duration('insuranceRemainingDays', String(helper.remainingDays), 'days'),
|
|
84
|
+
),
|
|
85
|
+
{
|
|
86
|
+
wrap: true,
|
|
87
|
+
semantic: 'insuranceLine',
|
|
88
|
+
orientation: 'vertical',
|
|
89
|
+
result: money('insuranceSubtotal', helper.moneyValue(insuranceAmount), 'USD'),
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Combine all detailed invoice lines before calculating summaries.
|
|
94
|
+
const linesBeforeTax = add(
|
|
95
|
+
itemLines,
|
|
96
|
+
insuranceLine,
|
|
97
|
+
);
|
|
98
|
+
// Expose the tax rate as an explicit authoritative summary.
|
|
99
|
+
const taxRate = percent('taxRate', '0.20');
|
|
100
|
+
const tax = multiply(
|
|
101
|
+
money('subtotalBeforeTax', helper.moneyValue(subtotalAmount), 'USD'),
|
|
102
|
+
group(taxRate, {
|
|
103
|
+
semantic: 'taxRate',
|
|
104
|
+
role: 'summary',
|
|
105
|
+
result: taxRate,
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Return the before-tax and after-tax roll-ups.
|
|
110
|
+
return add(
|
|
111
|
+
group(linesBeforeTax, {
|
|
112
|
+
semantic: 'beforeTaxLine',
|
|
113
|
+
role: 'summary',
|
|
114
|
+
result: money('subtotalBeforeTax', helper.moneyValue(subtotalAmount), 'USD'),
|
|
115
|
+
}),
|
|
116
|
+
group(tax, {
|
|
117
|
+
wrap: true,
|
|
118
|
+
semantic: 'afterTaxLine',
|
|
119
|
+
role: 'summary',
|
|
120
|
+
result: money('invoiceTotal', helper.moneyValue(invoiceTotalAmount), 'USD'),
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
})
|
|
124
|
+
// Attach the authoritative result independently from the explanatory tree.
|
|
125
|
+
.result(({ money }) => money('invoiceTotal', helper.moneyValue(invoiceTotalAmount), 'USD'))
|
|
126
|
+
.build();
|
|
127
|
+
|
|
128
|
+
// Optional localized title; comment out its return property below to disable it.
|
|
129
|
+
const title = { en: 'Invoice calculation', cs: 'Výpočet faktury' };
|
|
130
|
+
|
|
131
|
+
// Optional semantic dictionary; comment out its return property below to disable it.
|
|
132
|
+
const dictionary = {
|
|
133
|
+
// Define English names and tooltip descriptions.
|
|
134
|
+
en: {
|
|
135
|
+
iphoneLine: { name: 'iPhone', description: 'Item quantity with a line discount.' },
|
|
136
|
+
airpodsLine: { name: 'AirPods', description: 'Item quantity with a line discount.' },
|
|
137
|
+
itemName: { name: 'Item name', description: 'Name of the invoiced item.' },
|
|
138
|
+
insuranceLine: { name: 'Insurance', description: 'Annual insurance prorated from today through December 31.' },
|
|
139
|
+
beforeTaxLine: { name: 'Before tax', description: 'Line subtotals combined before tax.' },
|
|
140
|
+
afterTaxLine: { name: 'After tax', description: 'Total before tax with 20% tax applied.' },
|
|
141
|
+
itemUnitPrice: { name: 'unit price', description: 'Price for one Item.' },
|
|
142
|
+
itemQuantity: { name: 'quantity', description: 'Number of Items invoiced.' },
|
|
143
|
+
itemDiscount: { name: 'discount', description: 'Discount applied to the Item line total.' },
|
|
144
|
+
itemSubtotal: { name: 'subtotal', description: 'Item line total after discount.' },
|
|
145
|
+
insuranceAnnualPrice: { name: 'annual price', description: 'Price of insurance coverage for the entire current year.' },
|
|
146
|
+
insuranceYearLength: { name: 'days in current year', description: 'Calendar length used to derive the exact daily insurance price.' },
|
|
147
|
+
insuranceRemainingDays: { name: 'remaining days', description: 'Coverage from ' + helper.now.toLocaleDateString('en-US') + ' through December 31.' },
|
|
148
|
+
insuranceSubtotal: { name: 'subtotal', description: 'Prorated insurance total for the remaining calendar year.' },
|
|
149
|
+
subtotalBeforeTax: { name: 'subtotal before tax', description: 'Item subtotals plus prorated insurance: ' + helper.moneyValue(subtotalAmount) + ' USD.' },
|
|
150
|
+
taxRate: { name: 'tax rate', description: 'Tax applied to the subtotal before tax.' },
|
|
151
|
+
invoiceTotal: { name: 'total after tax', description: 'Authoritative invoice amount after tax.' },
|
|
152
|
+
},
|
|
153
|
+
// Define Czech names and tooltip descriptions.
|
|
154
|
+
cs: {
|
|
155
|
+
iphoneLine: { name: 'iPhone', description: 'Množství položky se slevou na řádku.' },
|
|
156
|
+
airpodsLine: { name: 'AirPods', description: 'Množství položky se slevou na řádku.' },
|
|
157
|
+
itemName: { name: 'Název položky', description: 'Název fakturované položky.' },
|
|
158
|
+
insuranceLine: { name: 'Pojištění', description: 'Roční pojištění poměrně ode dneška do 31. prosince.' },
|
|
159
|
+
beforeTaxLine: { name: 'Před zdaněním', description: 'Součet řádkových mezisoučtů před daní.' },
|
|
160
|
+
afterTaxLine: { name: 'Po zdanění', description: 'Celková částka před daní se sazbou daně 20 %.' },
|
|
161
|
+
itemUnitPrice: { name: 'jednotková cena', description: 'Cena za jednu položku.' },
|
|
162
|
+
itemQuantity: { name: 'množství', description: 'Počet fakturovaných položek.' },
|
|
163
|
+
itemDiscount: { name: 'sleva', description: 'Sleva z celkové ceny řádku položky.' },
|
|
164
|
+
itemSubtotal: { name: 'mezisoučet', description: 'Celková cena řádku položky po slevě.' },
|
|
165
|
+
insuranceAnnualPrice: { name: 'roční cena', description: 'Cena pojistného krytí za celý aktuální rok.' },
|
|
166
|
+
insuranceYearLength: { name: 'počet dní v roce', description: 'Délka kalendářního roku použitá pro výpočet přesné denní ceny.' },
|
|
167
|
+
insuranceRemainingDays: { name: 'zbývající dny', description: 'Krytí od ' + helper.now.toLocaleDateString('cs-CZ') + ' do 31. prosince.' },
|
|
168
|
+
insuranceSubtotal: { name: 'mezisoučet', description: 'Poměrná cena pojištění pro zbytek kalendářního roku.' },
|
|
169
|
+
subtotalBeforeTax: { name: 'mezisoučet před daní', description: 'Mezisoučty položek plus poměrná část pojištění: ' + helper.moneyValue(subtotalAmount).replace('.', ',') + ' USD.' },
|
|
170
|
+
taxRate: { name: 'sazba daně', description: 'Daň uplatněná na mezisoučet před daní.' },
|
|
171
|
+
invoiceTotal: { name: 'celkem po zdanění', description: 'Výsledná částka faktury po zdanění.' },
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Return the calculation plus any optional presentation metadata you want to use.
|
|
176
|
+
return {
|
|
177
|
+
calculation: invoice,
|
|
178
|
+
title, // Optional: comment out this line to use the default title.
|
|
179
|
+
dictionary, // Optional: comment out this line to use semantic keys directly.
|
|
180
|
+
};`;
|
|
181
|
+
|
|
182
|
+
const localeMetadataStart = DEFAULT_BUILDER_WITH_LOCALE.indexOf(
|
|
183
|
+
'\n// Optional localized title',
|
|
184
|
+
);
|
|
185
|
+
const DEFAULT_BUILDER_CORE = DEFAULT_BUILDER_WITH_LOCALE.slice(
|
|
186
|
+
0,
|
|
187
|
+
localeMetadataStart,
|
|
188
|
+
);
|
|
189
|
+
const DEFAULT_LOCALE_METADATA = DEFAULT_BUILDER_WITH_LOCALE.slice(
|
|
190
|
+
localeMetadataStart,
|
|
191
|
+
);
|
|
192
|
+
const DEFAULT_MINIMAL_EXPORT = `
|
|
193
|
+
|
|
194
|
+
// Export only the calculation; renderers fall back to its semantic keys.
|
|
195
|
+
return { calculation: invoice };`;
|
|
196
|
+
const DEFAULT_BUILDER = `${DEFAULT_BUILDER_CORE}${DEFAULT_MINIMAL_EXPORT}`;
|
|
197
|
+
|
|
198
|
+
function setBuilderLocaleMode(source, useLocale) {
|
|
199
|
+
const localizedStart = source.indexOf('\n// Optional localized title');
|
|
200
|
+
const hasMinimalExport = source.endsWith(DEFAULT_MINIMAL_EXPORT);
|
|
201
|
+
const core = localizedStart >= 0
|
|
202
|
+
? source.slice(0, localizedStart)
|
|
203
|
+
: hasMinimalExport
|
|
204
|
+
? source.slice(0, -DEFAULT_MINIMAL_EXPORT.length)
|
|
205
|
+
: source;
|
|
206
|
+
|
|
207
|
+
if (localizedStart < 0 && !hasMinimalExport) return source;
|
|
208
|
+
return `${core}${useLocale ? DEFAULT_LOCALE_METADATA : DEFAULT_MINIMAL_EXPORT}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const DEFAULT_CSS = `.calculation-card__expression {
|
|
212
|
+
font: 650 14px/1.45 Inter, system-ui, sans-serif;
|
|
213
|
+
white-space: nowrap;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.calculation-group--multiline {
|
|
217
|
+
display: block;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.calculation-line {
|
|
221
|
+
display: block;
|
|
222
|
+
margin: .32em 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.calculation-line--detail {
|
|
226
|
+
margin-left: .75em;
|
|
227
|
+
padding-left: .9em;
|
|
228
|
+
border-left: 1px solid #d9dfc2;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.calculation-line__content {
|
|
232
|
+
white-space: nowrap;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.calculation-bracket {
|
|
236
|
+
color: var(--bracket-color);
|
|
237
|
+
background: color-mix(in srgb, var(--bracket-color), transparent 88%);
|
|
238
|
+
border-radius: 3px;
|
|
239
|
+
font-weight: 850;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.calculation-line--summary {
|
|
243
|
+
margin-top: .75em;
|
|
244
|
+
padding-top: .75em;
|
|
245
|
+
border-top: 2px solid #c7ccb8;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.calculation-table-section + .calculation-table-section {
|
|
249
|
+
margin-top: 1.25em;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.calculation-table-section {
|
|
253
|
+
border: 1px solid #d9dfc2;
|
|
254
|
+
border-radius: 12px;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.calculation-table {
|
|
258
|
+
width: 100%;
|
|
259
|
+
border-collapse: collapse;
|
|
260
|
+
white-space: nowrap;
|
|
261
|
+
font-size: 14px;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.calculation-table th,
|
|
265
|
+
.calculation-table td {
|
|
266
|
+
padding: .65em .8em;
|
|
267
|
+
border-bottom: 1px solid #d9dfc2;
|
|
268
|
+
text-align: var(--cell-align, left);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.calculation-table tbody tr:last-child td {
|
|
272
|
+
border-bottom: 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.calculation-table th {
|
|
276
|
+
color: #637236;
|
|
277
|
+
font-size: 10px;
|
|
278
|
+
letter-spacing: .08em;
|
|
279
|
+
text-transform: uppercase;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.calculation-table--totals {
|
|
283
|
+
width: 100%;
|
|
284
|
+
margin-left: auto;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.calculation-table--results td:first-child {
|
|
288
|
+
color: #637236;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.calculation-table--vertical caption {
|
|
292
|
+
padding: .65em .8em;
|
|
293
|
+
text-align: left;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.calculation-table--vertical td:first-child {
|
|
297
|
+
color: #637236;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.calculation-table__emphasis td {
|
|
301
|
+
border-top: 2px solid #1c2114;
|
|
302
|
+
font-weight: 850;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.semantic-token {
|
|
306
|
+
position: relative;
|
|
307
|
+
color: var(--semantic-color);
|
|
308
|
+
text-decoration: none;
|
|
309
|
+
cursor: help;
|
|
310
|
+
outline: none;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.semantic-token:hover {
|
|
314
|
+
text-decoration: underline;
|
|
315
|
+
text-decoration-thickness: 2px;
|
|
316
|
+
text-underline-offset: 4px;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.semantic-token:focus-visible {
|
|
320
|
+
border-radius: 2px;
|
|
321
|
+
outline: 2px solid currentColor;
|
|
322
|
+
outline-offset: 2px;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.semantic-tooltip {
|
|
326
|
+
position: fixed;
|
|
327
|
+
z-index: 100;
|
|
328
|
+
width: max-content;
|
|
329
|
+
max-width: 240px;
|
|
330
|
+
padding: 9px 11px;
|
|
331
|
+
border-radius: 8px;
|
|
332
|
+
background: #1c2114;
|
|
333
|
+
color: #f7f8f1;
|
|
334
|
+
box-shadow: 0 8px 28px rgba(0, 0, 0, .28);
|
|
335
|
+
font: 500 12px/1.4 Inter, system-ui, sans-serif;
|
|
336
|
+
white-space: normal;
|
|
337
|
+
pointer-events: none;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.group-label {
|
|
341
|
+
margin-right: .55em;
|
|
342
|
+
color: #1c2114;
|
|
343
|
+
font-weight: 850;
|
|
344
|
+
text-decoration: none;
|
|
345
|
+
}`;
|
|
346
|
+
|
|
347
|
+
const elements = {
|
|
348
|
+
builder: document.querySelector('#builder'),
|
|
349
|
+
builderHighlight: document.querySelector('#builder-highlight'),
|
|
350
|
+
css: document.querySelector('#css'),
|
|
351
|
+
cssHighlight: document.querySelector('#css-highlight'),
|
|
352
|
+
preview: document.querySelector('#preview'),
|
|
353
|
+
template: document.querySelector('#preview-template'),
|
|
354
|
+
status: document.querySelector('#status'),
|
|
355
|
+
run: document.querySelector('#run'),
|
|
356
|
+
reset: document.querySelector('#reset'),
|
|
357
|
+
useLocale: document.querySelector('#use-locale'),
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
let updateTimer;
|
|
361
|
+
let selectedLocale = 'en';
|
|
362
|
+
let selectedView = 'table';
|
|
363
|
+
let showNames = false;
|
|
364
|
+
const SEMANTIC_COLORS = [
|
|
365
|
+
'#6650d8',
|
|
366
|
+
'#b84d15',
|
|
367
|
+
'#08776d',
|
|
368
|
+
'#a72e63',
|
|
369
|
+
'#55750e',
|
|
370
|
+
'#2563a6',
|
|
371
|
+
'#8a4f00',
|
|
372
|
+
'#7040a0',
|
|
373
|
+
'#287044',
|
|
374
|
+
];
|
|
375
|
+
const BRACKET_COLORS = ['#e63946', '#0081a7', '#f77f00', '#7b2cbf'];
|
|
376
|
+
|
|
377
|
+
function escapeHtml(value) {
|
|
378
|
+
return value
|
|
379
|
+
.replaceAll('&', '&')
|
|
380
|
+
.replaceAll('<', '<')
|
|
381
|
+
.replaceAll('>', '>');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function highlight(source, language) {
|
|
385
|
+
const pattern =
|
|
386
|
+
language === 'typescript'
|
|
387
|
+
? /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|('(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`)|(\b(?:const|let|var|return|new|typeof|instanceof|if|else|for|of|in|true|false|null|undefined|as|type|interface|extends|readonly)\b)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)(?=\s*\()/g
|
|
388
|
+
: /(\/\*[\s\S]*?\*\/)|('(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*")|(@[\w-]+)|(#(?:[\da-fA-F]{3,8})\b)|(\b\d+(?:\.\d+)?(?:px|rem|em|vw|vh|fr|%|s|ms)?\b)|((?:--)?[\w-]+)(?=\s*:)|([^{}]+)(?=\s*\{)/g;
|
|
389
|
+
let output = '';
|
|
390
|
+
let cursor = 0;
|
|
391
|
+
|
|
392
|
+
for (const match of source.matchAll(pattern)) {
|
|
393
|
+
output += escapeHtml(source.slice(cursor, match.index));
|
|
394
|
+
const className = language === 'typescript'
|
|
395
|
+
? match[1] ? 'comment'
|
|
396
|
+
: match[2] ? 'string'
|
|
397
|
+
: match[3] ? 'keyword'
|
|
398
|
+
: match[4] ? 'number'
|
|
399
|
+
: 'function'
|
|
400
|
+
: match[1] ? 'comment'
|
|
401
|
+
: match[2] ? 'string'
|
|
402
|
+
: match[3] ? 'atrule'
|
|
403
|
+
: match[4] ? 'color'
|
|
404
|
+
: match[5] ? 'number'
|
|
405
|
+
: match[6] ? 'property'
|
|
406
|
+
: 'selector';
|
|
407
|
+
output += `<span class="token-${className}">${escapeHtml(match[0])}</span>`;
|
|
408
|
+
cursor = match.index + match[0].length;
|
|
409
|
+
}
|
|
410
|
+
return `${output}${escapeHtml(source.slice(cursor))}\n`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function updateHighlight(editor, layer, language) {
|
|
414
|
+
layer.innerHTML = highlight(editor.value, language);
|
|
415
|
+
layer.scrollTop = editor.scrollTop;
|
|
416
|
+
layer.scrollLeft = editor.scrollLeft;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function updateHighlights() {
|
|
420
|
+
updateHighlight(elements.builder, elements.builderHighlight, 'typescript');
|
|
421
|
+
updateHighlight(elements.css, elements.cssHighlight, 'css');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let activeTooltip;
|
|
425
|
+
|
|
426
|
+
function hideTooltip() {
|
|
427
|
+
activeTooltip?.remove();
|
|
428
|
+
activeTooltip = undefined;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function showTooltip(token) {
|
|
432
|
+
hideTooltip();
|
|
433
|
+
const card = token.closest('.calculation-card');
|
|
434
|
+
if (!card || !token.dataset.tooltip) return;
|
|
435
|
+
|
|
436
|
+
const tooltip = document.createElement('div');
|
|
437
|
+
tooltip.className = 'semantic-tooltip';
|
|
438
|
+
tooltip.textContent = token.dataset.tooltip;
|
|
439
|
+
document.body.append(tooltip);
|
|
440
|
+
|
|
441
|
+
const tokenRect = token.getBoundingClientRect();
|
|
442
|
+
const cardRect = card.getBoundingClientRect();
|
|
443
|
+
const tooltipRect = tooltip.getBoundingClientRect();
|
|
444
|
+
const inset = 8;
|
|
445
|
+
const preferredLeft =
|
|
446
|
+
tokenRect.left + tokenRect.width / 2 - tooltipRect.width / 2;
|
|
447
|
+
const left = Math.min(
|
|
448
|
+
Math.max(preferredLeft, cardRect.left + inset),
|
|
449
|
+
cardRect.right - tooltipRect.width - inset,
|
|
450
|
+
);
|
|
451
|
+
const spaceAbove = tokenRect.top - cardRect.top;
|
|
452
|
+
const preferredTop =
|
|
453
|
+
spaceAbove >= tooltipRect.height + 10
|
|
454
|
+
? tokenRect.top - tooltipRect.height - 10
|
|
455
|
+
: tokenRect.bottom + 10;
|
|
456
|
+
const top = Math.min(
|
|
457
|
+
Math.max(preferredTop, cardRect.top + inset),
|
|
458
|
+
cardRect.bottom - tooltipRect.height - inset,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
tooltip.style.left = `${left}px`;
|
|
462
|
+
tooltip.style.top = `${top}px`;
|
|
463
|
+
activeTooltip = tooltip;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function installTooltips(container) {
|
|
467
|
+
for (const token of container.querySelectorAll('.semantic-token')) {
|
|
468
|
+
token.addEventListener('pointerenter', () => showTooltip(token));
|
|
469
|
+
token.addEventListener('pointerleave', hideTooltip);
|
|
470
|
+
token.addEventListener('focus', () => showTooltip(token));
|
|
471
|
+
token.addEventListener('blur', hideTooltip);
|
|
472
|
+
}
|
|
473
|
+
for (const card of container.querySelectorAll('.calculation-card')) {
|
|
474
|
+
card.addEventListener('scroll', hideTooltip, { passive: true });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function evaluateBuilder(source) {
|
|
479
|
+
const execute = new Function(
|
|
480
|
+
'CalculationBreakdownBuilder',
|
|
481
|
+
'toHumanString',
|
|
482
|
+
`"use strict";\n${source}`,
|
|
483
|
+
);
|
|
484
|
+
const output = execute(CalculationBreakdownBuilder, toHumanString);
|
|
485
|
+
if (
|
|
486
|
+
!output ||
|
|
487
|
+
(!output.calculation && !output.examples?.length)
|
|
488
|
+
) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
'Builder code must return { calculation, dictionary } or { examples, dictionary }.',
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
return output;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function collectSemantics(calculation) {
|
|
497
|
+
const semantics = [];
|
|
498
|
+
const visit = (node) => {
|
|
499
|
+
if ('semantic' in node && !semantics.includes(node.semantic)) {
|
|
500
|
+
semantics.push(node.semantic);
|
|
501
|
+
}
|
|
502
|
+
if (node.type === 'operation') {
|
|
503
|
+
node.operands.forEach(visit);
|
|
504
|
+
} else if (node.type === 'group') {
|
|
505
|
+
visit(node.expression);
|
|
506
|
+
if (node.result) visit(node.result);
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
visit(calculation.breakdown);
|
|
511
|
+
visit(calculation.result);
|
|
512
|
+
return semantics;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function createSemanticEntries(output, calculation, locale) {
|
|
516
|
+
const semantics = collectSemantics(calculation);
|
|
517
|
+
return semantics.map((semantic, index) => {
|
|
518
|
+
const entry = output.dictionary?.[locale]?.[semantic];
|
|
519
|
+
return {
|
|
520
|
+
semantic,
|
|
521
|
+
label: typeof entry === 'string' ? entry : (entry?.name ?? semantic),
|
|
522
|
+
explanation:
|
|
523
|
+
typeof entry === 'object'
|
|
524
|
+
? entry.description
|
|
525
|
+
: `Semantic key: ${semantic}`,
|
|
526
|
+
color: SEMANTIC_COLORS[index % SEMANTIC_COLORS.length],
|
|
527
|
+
};
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function useDescriptionOnlyTooltip(container, cell, entries) {
|
|
532
|
+
for (const token of container.querySelectorAll('.semantic-token')) {
|
|
533
|
+
const semantic = token.dataset.semantic ?? cell?.semantic;
|
|
534
|
+
const entry = entries.find((candidate) => candidate.semantic === semantic);
|
|
535
|
+
if (entry) token.dataset.tooltip = entry.explanation;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function collectResultGroups(node, groups = [], headingSemantic) {
|
|
540
|
+
if (node.type === 'operation') {
|
|
541
|
+
node.operands.forEach((operand) =>
|
|
542
|
+
collectResultGroups(operand, groups, headingSemantic),
|
|
543
|
+
);
|
|
544
|
+
} else if (node.type === 'group') {
|
|
545
|
+
const nestedHeading = node.result
|
|
546
|
+
? headingSemantic
|
|
547
|
+
: (node.semantic ?? headingSemantic);
|
|
548
|
+
collectResultGroups(node.expression, groups, nestedHeading);
|
|
549
|
+
if (node.semantic && node.result) {
|
|
550
|
+
groups.push({ ...node, tableHeadingSemantic: headingSemantic });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return groups;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function collectUniqueValues(node, values = [], seen = new Set()) {
|
|
557
|
+
if (node.type === 'operation') {
|
|
558
|
+
node.operands.forEach((operand) => collectUniqueValues(operand, values, seen));
|
|
559
|
+
} else if (node.type === 'group') {
|
|
560
|
+
collectUniqueValues(node.expression, values, seen);
|
|
561
|
+
} else if (node.type !== 'constant' && !seen.has(node.semantic)) {
|
|
562
|
+
seen.add(node.semantic);
|
|
563
|
+
values.push(node);
|
|
564
|
+
}
|
|
565
|
+
return values;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function groupValues(group) {
|
|
569
|
+
const values = collectUniqueValues(group.expression);
|
|
570
|
+
if (!values.some(({ semantic }) => semantic === group.result.semantic)) {
|
|
571
|
+
values.push(group.result);
|
|
572
|
+
}
|
|
573
|
+
return values;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function valueSignature(group) {
|
|
577
|
+
return groupValues(group).map(({ semantic }) => semantic).join('|');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function appendValueCell(container, value, entries, locale, dictionary) {
|
|
581
|
+
appendAnnotatedNode(container, value, entries, locale, dictionary);
|
|
582
|
+
useDescriptionOnlyTooltip(container, value, entries);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function createTable(className = 'calculation-table') {
|
|
586
|
+
const sectionContainer = document.createElement('div');
|
|
587
|
+
sectionContainer.className = 'calculation-table-section';
|
|
588
|
+
const table = document.createElement('table');
|
|
589
|
+
table.className = className;
|
|
590
|
+
sectionContainer.append(table);
|
|
591
|
+
return { sectionContainer, table };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function appendColumnSection(container, groups, entries, locale, dictionary) {
|
|
595
|
+
const { sectionContainer, table } = createTable();
|
|
596
|
+
const values = groupValues(groups[0]);
|
|
597
|
+
const head = table.createTHead();
|
|
598
|
+
const headingRow = head.insertRow();
|
|
599
|
+
const identityHeading = document.createElement('th');
|
|
600
|
+
identityHeading.scope = 'col';
|
|
601
|
+
if (groups[0].tableHeadingSemantic) {
|
|
602
|
+
identityHeading.textContent = entries.find(
|
|
603
|
+
({ semantic }) =>
|
|
604
|
+
semantic === groups[0].tableHeadingSemantic,
|
|
605
|
+
)?.label ?? groups[0].tableHeadingSemantic;
|
|
606
|
+
}
|
|
607
|
+
headingRow.append(identityHeading);
|
|
608
|
+
for (const value of values) {
|
|
609
|
+
const heading = document.createElement('th');
|
|
610
|
+
heading.scope = 'col';
|
|
611
|
+
heading.textContent = entries.find(
|
|
612
|
+
({ semantic }) => semantic === value.semantic,
|
|
613
|
+
)?.label ?? value.semantic;
|
|
614
|
+
heading.style.setProperty('--cell-align', 'right');
|
|
615
|
+
headingRow.append(heading);
|
|
616
|
+
}
|
|
617
|
+
const body = table.createTBody();
|
|
618
|
+
for (const group of groups) {
|
|
619
|
+
const tableRow = body.insertRow();
|
|
620
|
+
const labelCell = tableRow.insertCell();
|
|
621
|
+
appendGroupLabel(labelCell, group.semantic, entries);
|
|
622
|
+
useDescriptionOnlyTooltip(labelCell, group, entries);
|
|
623
|
+
for (const value of groupValues(group)) {
|
|
624
|
+
const tableCell = tableRow.insertCell();
|
|
625
|
+
tableCell.style.setProperty('--cell-align', 'right');
|
|
626
|
+
appendValueCell(tableCell, value, entries, locale, dictionary);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
container.append(sectionContainer);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function appendResultsSection(container, groups, entries, locale, dictionary) {
|
|
633
|
+
const { sectionContainer, table } = createTable(
|
|
634
|
+
'calculation-table calculation-table--results',
|
|
635
|
+
);
|
|
636
|
+
const body = table.createTBody();
|
|
637
|
+
for (const group of groups) {
|
|
638
|
+
const row = body.insertRow();
|
|
639
|
+
const labelCell = row.insertCell();
|
|
640
|
+
appendGroupLabel(labelCell, group.semantic, entries);
|
|
641
|
+
useDescriptionOnlyTooltip(labelCell, group, entries);
|
|
642
|
+
const valueCell = row.insertCell();
|
|
643
|
+
valueCell.style.setProperty('--cell-align', 'right');
|
|
644
|
+
appendValueCell(valueCell, group.result, entries, locale, dictionary);
|
|
645
|
+
}
|
|
646
|
+
if (body.rows.length) {
|
|
647
|
+
body.rows.item(body.rows.length - 1).className =
|
|
648
|
+
'calculation-table__emphasis';
|
|
649
|
+
}
|
|
650
|
+
container.append(sectionContainer);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function appendVerticalDetailSection(
|
|
654
|
+
container,
|
|
655
|
+
group,
|
|
656
|
+
entries,
|
|
657
|
+
locale,
|
|
658
|
+
dictionary,
|
|
659
|
+
) {
|
|
660
|
+
const { sectionContainer, table } = createTable(
|
|
661
|
+
'calculation-table calculation-table--vertical',
|
|
662
|
+
);
|
|
663
|
+
const caption = table.createCaption();
|
|
664
|
+
appendGroupLabel(caption, group.semantic, entries);
|
|
665
|
+
useDescriptionOnlyTooltip(caption, group, entries);
|
|
666
|
+
const body = table.createTBody();
|
|
667
|
+
for (const value of groupValues(group)) {
|
|
668
|
+
const row = body.insertRow();
|
|
669
|
+
const labelCell = row.insertCell();
|
|
670
|
+
labelCell.textContent = entries.find(
|
|
671
|
+
({ semantic }) => semantic === value.semantic,
|
|
672
|
+
)?.label ?? value.semantic;
|
|
673
|
+
const valueCell = row.insertCell();
|
|
674
|
+
valueCell.style.setProperty('--cell-align', 'right');
|
|
675
|
+
appendValueCell(valueCell, value, entries, locale, dictionary);
|
|
676
|
+
}
|
|
677
|
+
container.append(sectionContainer);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function appendTableView(container, calculation, entries, locale, dictionary) {
|
|
681
|
+
const groups = collectResultGroups(calculation.breakdown);
|
|
682
|
+
const summaries = groups.filter((group) => group.role === 'summary');
|
|
683
|
+
const summarySet = new Set(summaries);
|
|
684
|
+
const details = groups.filter((group) => !summarySet.has(group));
|
|
685
|
+
|
|
686
|
+
for (let index = 0; index < details.length; ) {
|
|
687
|
+
const group = details[index];
|
|
688
|
+
if (group.orientation === 'vertical') {
|
|
689
|
+
appendVerticalDetailSection(
|
|
690
|
+
container,
|
|
691
|
+
group,
|
|
692
|
+
entries,
|
|
693
|
+
locale,
|
|
694
|
+
dictionary,
|
|
695
|
+
);
|
|
696
|
+
index += 1;
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
const signature = valueSignature(group);
|
|
700
|
+
const section = [group];
|
|
701
|
+
index += 1;
|
|
702
|
+
while (
|
|
703
|
+
index < details.length &&
|
|
704
|
+
details[index].orientation !== 'vertical' &&
|
|
705
|
+
details[index].tableHeadingSemantic ===
|
|
706
|
+
group.tableHeadingSemantic &&
|
|
707
|
+
valueSignature(details[index]) === signature
|
|
708
|
+
) {
|
|
709
|
+
section.push(details[index]);
|
|
710
|
+
index += 1;
|
|
711
|
+
}
|
|
712
|
+
appendColumnSection(container, section, entries, locale, dictionary);
|
|
713
|
+
}
|
|
714
|
+
if (summaries.length) {
|
|
715
|
+
appendResultsSection(container, summaries, entries, locale, dictionary);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function localizeConstant(value, locale) {
|
|
720
|
+
return locale === 'cs' ? value.replace('.', ',') : value;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function containsWrappedGroup(node) {
|
|
724
|
+
if (node.type === 'group') {
|
|
725
|
+
return node.wrap === true || containsWrappedGroup(node.expression);
|
|
726
|
+
}
|
|
727
|
+
return node.type === 'operation'
|
|
728
|
+
? node.operands.some(containsWrappedGroup)
|
|
729
|
+
: false;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function sameValue(left, right) {
|
|
733
|
+
if (left.type !== right.type || left.semantic !== right.semantic) return false;
|
|
734
|
+
if (left.type === 'number' || left.type === 'percent') {
|
|
735
|
+
return left.value === right.value;
|
|
736
|
+
}
|
|
737
|
+
if (left.type === 'money') {
|
|
738
|
+
return left.amount === right.amount && left.currency === right.currency;
|
|
739
|
+
}
|
|
740
|
+
return left.amount === right.amount && left.unit === right.unit;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function containsMatchingGroupResult(node, result) {
|
|
744
|
+
if (node.type === 'group') {
|
|
745
|
+
return Boolean(
|
|
746
|
+
(node.result && sameValue(node.result, result)) ||
|
|
747
|
+
containsMatchingGroupResult(node.expression, result),
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
return node.type === 'operation'
|
|
751
|
+
? node.operands.some((operand) =>
|
|
752
|
+
containsMatchingGroupResult(operand, result),
|
|
753
|
+
)
|
|
754
|
+
: false;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function appendCalculationLine(
|
|
758
|
+
container,
|
|
759
|
+
node,
|
|
760
|
+
entries,
|
|
761
|
+
locale,
|
|
762
|
+
dictionary,
|
|
763
|
+
) {
|
|
764
|
+
const line = document.createElement('span');
|
|
765
|
+
line.className = node.role === 'summary'
|
|
766
|
+
? 'calculation-line'
|
|
767
|
+
: 'calculation-line calculation-line--detail';
|
|
768
|
+
const content = document.createElement('span');
|
|
769
|
+
content.className = 'calculation-line__content';
|
|
770
|
+
appendAnnotatedNode(content, node, entries, locale, dictionary);
|
|
771
|
+
line.append(content);
|
|
772
|
+
container.append(line);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function appendTrailingOperator(container, operator) {
|
|
776
|
+
const lines = container.querySelectorAll('.calculation-line__content');
|
|
777
|
+
const target = lines.item(lines.length - 1);
|
|
778
|
+
if (target) {
|
|
779
|
+
target.append(` ${operator}`);
|
|
780
|
+
} else {
|
|
781
|
+
container.append(` ${operator}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function formatSemanticValue(value, locale, dictionary) {
|
|
786
|
+
const entry = dictionary[locale]?.[value.semantic];
|
|
787
|
+
const label = typeof entry === 'string' ? entry : (entry?.name ?? value.semantic);
|
|
788
|
+
if (value.type === 'money') {
|
|
789
|
+
return new Intl.NumberFormat(locale, {
|
|
790
|
+
style: 'currency',
|
|
791
|
+
currency: value.currency,
|
|
792
|
+
currencyDisplay: 'narrowSymbol',
|
|
793
|
+
}).format(value.amount);
|
|
794
|
+
}
|
|
795
|
+
const rendered = toHumanString(
|
|
796
|
+
{ breakdown: value, result: value },
|
|
797
|
+
{ locale, dictionary },
|
|
798
|
+
).split(' = ')[0];
|
|
799
|
+
return rendered.endsWith(` ${label}`)
|
|
800
|
+
? rendered.slice(0, -(label.length + 1))
|
|
801
|
+
: rendered;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function appendGroupLabel(container, semantic, entries) {
|
|
805
|
+
if (!semantic) return;
|
|
806
|
+
const entry = entries.find((candidate) => candidate.semantic === semantic);
|
|
807
|
+
const label = document.createElement('strong');
|
|
808
|
+
label.className = 'semantic-token group-label';
|
|
809
|
+
label.textContent = entry.label;
|
|
810
|
+
label.dataset.semantic = semantic;
|
|
811
|
+
label.dataset.tooltip = entry.explanation;
|
|
812
|
+
label.tabIndex = 0;
|
|
813
|
+
label.setAttribute('aria-label', `${entry.label}. ${entry.explanation}`);
|
|
814
|
+
container.append(label);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function appendBracket(container, character, depth) {
|
|
818
|
+
const bracket = document.createElement('span');
|
|
819
|
+
bracket.className = 'calculation-bracket';
|
|
820
|
+
bracket.style.setProperty(
|
|
821
|
+
'--bracket-color',
|
|
822
|
+
BRACKET_COLORS[depth % BRACKET_COLORS.length],
|
|
823
|
+
);
|
|
824
|
+
bracket.textContent = character;
|
|
825
|
+
container.append(bracket);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function appendGroupResultExpression(
|
|
829
|
+
container,
|
|
830
|
+
node,
|
|
831
|
+
entries,
|
|
832
|
+
locale,
|
|
833
|
+
dictionary,
|
|
834
|
+
) {
|
|
835
|
+
if (node.type === 'group' && node.result) {
|
|
836
|
+
appendAnnotatedNode(
|
|
837
|
+
container,
|
|
838
|
+
node.result,
|
|
839
|
+
entries,
|
|
840
|
+
locale,
|
|
841
|
+
dictionary,
|
|
842
|
+
);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (node.type === 'group') {
|
|
846
|
+
appendGroupResultExpression(
|
|
847
|
+
container,
|
|
848
|
+
node.expression,
|
|
849
|
+
entries,
|
|
850
|
+
locale,
|
|
851
|
+
dictionary,
|
|
852
|
+
);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (node.type === 'operation') {
|
|
856
|
+
const [left, right] = node.operands;
|
|
857
|
+
appendGroupResultExpression(container, left, entries, locale, dictionary);
|
|
858
|
+
if (node.operator === 'minimum' || node.operator === 'maximum') {
|
|
859
|
+
container.append(node.operator === 'minimum' ? ' min ' : ' max ');
|
|
860
|
+
} else {
|
|
861
|
+
const operator = {
|
|
862
|
+
add: ' + ',
|
|
863
|
+
subtract: ' − ',
|
|
864
|
+
multiply: ' × ',
|
|
865
|
+
divide: ' ÷ ',
|
|
866
|
+
}[node.operator];
|
|
867
|
+
container.append(operator);
|
|
868
|
+
}
|
|
869
|
+
appendGroupResultExpression(container, right, entries, locale, dictionary);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
appendAnnotatedNode(container, node, entries, locale, dictionary);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function appendAnnotatedNode(
|
|
876
|
+
container,
|
|
877
|
+
node,
|
|
878
|
+
entries,
|
|
879
|
+
locale,
|
|
880
|
+
dictionary,
|
|
881
|
+
nested = false,
|
|
882
|
+
bracketDepth = 0,
|
|
883
|
+
) {
|
|
884
|
+
if (node.type === 'constant') {
|
|
885
|
+
container.append(localizeConstant(node.value, locale));
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (node.type === 'group') {
|
|
889
|
+
const groupContainer = document.createElement('span');
|
|
890
|
+
const multiline = containsWrappedGroup(node.expression);
|
|
891
|
+
const showParentheses = !multiline && node.wrap !== true;
|
|
892
|
+
const summarizesWrappedGroups = Boolean(
|
|
893
|
+
node.result && containsWrappedGroup(node.expression),
|
|
894
|
+
);
|
|
895
|
+
groupContainer.className = multiline
|
|
896
|
+
? 'calculation-group calculation-group--multiline'
|
|
897
|
+
: 'calculation-group';
|
|
898
|
+
if (!summarizesWrappedGroups) {
|
|
899
|
+
appendGroupLabel(groupContainer, node.semantic, entries);
|
|
900
|
+
}
|
|
901
|
+
if (showParentheses) {
|
|
902
|
+
appendBracket(groupContainer, '(', bracketDepth);
|
|
903
|
+
groupContainer.append(' ');
|
|
904
|
+
}
|
|
905
|
+
appendAnnotatedNode(
|
|
906
|
+
groupContainer,
|
|
907
|
+
node.expression,
|
|
908
|
+
entries,
|
|
909
|
+
locale,
|
|
910
|
+
dictionary,
|
|
911
|
+
false,
|
|
912
|
+
bracketDepth + 1,
|
|
913
|
+
);
|
|
914
|
+
if (summarizesWrappedGroups) {
|
|
915
|
+
const summaryLine = document.createElement('span');
|
|
916
|
+
summaryLine.className =
|
|
917
|
+
'calculation-line calculation-line--summary';
|
|
918
|
+
const summaryContent = document.createElement('span');
|
|
919
|
+
summaryContent.className = 'calculation-line__content';
|
|
920
|
+
appendGroupLabel(summaryContent, node.semantic, entries);
|
|
921
|
+
appendGroupResultExpression(
|
|
922
|
+
summaryContent,
|
|
923
|
+
node.expression,
|
|
924
|
+
entries,
|
|
925
|
+
locale,
|
|
926
|
+
dictionary,
|
|
927
|
+
);
|
|
928
|
+
summaryContent.append(' = ');
|
|
929
|
+
appendAnnotatedNode(
|
|
930
|
+
summaryContent,
|
|
931
|
+
node.result,
|
|
932
|
+
entries,
|
|
933
|
+
locale,
|
|
934
|
+
dictionary,
|
|
935
|
+
);
|
|
936
|
+
summaryLine.append(summaryContent);
|
|
937
|
+
groupContainer.append(summaryLine);
|
|
938
|
+
} else if (
|
|
939
|
+
node.result &&
|
|
940
|
+
!sameValue(node.expression, node.result)
|
|
941
|
+
) {
|
|
942
|
+
groupContainer.append(' = ');
|
|
943
|
+
appendAnnotatedNode(
|
|
944
|
+
groupContainer,
|
|
945
|
+
node.result,
|
|
946
|
+
entries,
|
|
947
|
+
locale,
|
|
948
|
+
dictionary,
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
if (showParentheses) {
|
|
952
|
+
groupContainer.append(' ');
|
|
953
|
+
appendBracket(groupContainer, ')', bracketDepth);
|
|
954
|
+
}
|
|
955
|
+
container.append(groupContainer);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (node.type !== 'operation') {
|
|
959
|
+
const entry = entries.find(({ semantic }) => semantic === node.semantic);
|
|
960
|
+
const token = document.createElement('span');
|
|
961
|
+
token.className = 'semantic-token';
|
|
962
|
+
token.dataset.semantic = node.semantic;
|
|
963
|
+
token.style.setProperty('--semantic-color', entry.color);
|
|
964
|
+
token.dataset.tooltip = showNames
|
|
965
|
+
? entry.explanation
|
|
966
|
+
: `${entry.label} — ${entry.explanation}`;
|
|
967
|
+
token.tabIndex = 0;
|
|
968
|
+
const formattedValue = formatSemanticValue(node, locale, dictionary);
|
|
969
|
+
token.setAttribute(
|
|
970
|
+
'aria-label',
|
|
971
|
+
showNames
|
|
972
|
+
? `${formattedValue} ${entry.label}. ${entry.explanation}`
|
|
973
|
+
: `${formattedValue}: ${entry.label}. ${entry.explanation}`,
|
|
974
|
+
);
|
|
975
|
+
token.append(formattedValue);
|
|
976
|
+
if (showNames) token.append(` ${entry.label}`);
|
|
977
|
+
container.append(token);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const [left, right] = node.operands;
|
|
982
|
+
if (node.operator === 'minimum' || node.operator === 'maximum') {
|
|
983
|
+
container.append(node.operator === 'minimum' ? 'min(' : 'max(');
|
|
984
|
+
appendAnnotatedNode(
|
|
985
|
+
container,
|
|
986
|
+
left,
|
|
987
|
+
entries,
|
|
988
|
+
locale,
|
|
989
|
+
dictionary,
|
|
990
|
+
false,
|
|
991
|
+
bracketDepth + 1,
|
|
992
|
+
);
|
|
993
|
+
container.append(', ');
|
|
994
|
+
appendAnnotatedNode(
|
|
995
|
+
container,
|
|
996
|
+
right,
|
|
997
|
+
entries,
|
|
998
|
+
locale,
|
|
999
|
+
dictionary,
|
|
1000
|
+
false,
|
|
1001
|
+
bracketDepth + 1,
|
|
1002
|
+
);
|
|
1003
|
+
container.append(')');
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const leftWraps = left.type === 'group' && left.wrap === true;
|
|
1008
|
+
const rightWraps = right.type === 'group' && right.wrap === true;
|
|
1009
|
+
if (leftWraps || rightWraps) {
|
|
1010
|
+
if (leftWraps) {
|
|
1011
|
+
appendCalculationLine(
|
|
1012
|
+
container,
|
|
1013
|
+
left,
|
|
1014
|
+
entries,
|
|
1015
|
+
locale,
|
|
1016
|
+
dictionary,
|
|
1017
|
+
);
|
|
1018
|
+
} else {
|
|
1019
|
+
appendAnnotatedNode(
|
|
1020
|
+
container,
|
|
1021
|
+
left,
|
|
1022
|
+
entries,
|
|
1023
|
+
locale,
|
|
1024
|
+
dictionary,
|
|
1025
|
+
false,
|
|
1026
|
+
bracketDepth,
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
const operator = {
|
|
1030
|
+
add: '+',
|
|
1031
|
+
subtract: '−',
|
|
1032
|
+
multiply: '×',
|
|
1033
|
+
divide: '÷',
|
|
1034
|
+
}[node.operator];
|
|
1035
|
+
const leftIsSummary =
|
|
1036
|
+
left.type === 'group' &&
|
|
1037
|
+
left.result &&
|
|
1038
|
+
containsWrappedGroup(left.expression);
|
|
1039
|
+
if (!leftIsSummary) {
|
|
1040
|
+
appendTrailingOperator(container, operator);
|
|
1041
|
+
}
|
|
1042
|
+
if (rightWraps) {
|
|
1043
|
+
appendCalculationLine(
|
|
1044
|
+
container,
|
|
1045
|
+
right,
|
|
1046
|
+
entries,
|
|
1047
|
+
locale,
|
|
1048
|
+
dictionary,
|
|
1049
|
+
);
|
|
1050
|
+
} else {
|
|
1051
|
+
container.append(' ');
|
|
1052
|
+
appendAnnotatedNode(
|
|
1053
|
+
container,
|
|
1054
|
+
right,
|
|
1055
|
+
entries,
|
|
1056
|
+
locale,
|
|
1057
|
+
dictionary,
|
|
1058
|
+
false,
|
|
1059
|
+
bracketDepth,
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
if (nested) {
|
|
1066
|
+
appendBracket(container, '(', bracketDepth);
|
|
1067
|
+
container.append(' ');
|
|
1068
|
+
}
|
|
1069
|
+
appendAnnotatedNode(
|
|
1070
|
+
container,
|
|
1071
|
+
left,
|
|
1072
|
+
entries,
|
|
1073
|
+
locale,
|
|
1074
|
+
dictionary,
|
|
1075
|
+
true,
|
|
1076
|
+
bracketDepth + 1,
|
|
1077
|
+
);
|
|
1078
|
+
const operator = {
|
|
1079
|
+
add: ' + ',
|
|
1080
|
+
subtract: ' − ',
|
|
1081
|
+
multiply: ' × ',
|
|
1082
|
+
divide: ' ÷ ',
|
|
1083
|
+
}[node.operator];
|
|
1084
|
+
container.append(operator);
|
|
1085
|
+
appendAnnotatedNode(
|
|
1086
|
+
container,
|
|
1087
|
+
right,
|
|
1088
|
+
entries,
|
|
1089
|
+
locale,
|
|
1090
|
+
dictionary,
|
|
1091
|
+
true,
|
|
1092
|
+
bracketDepth + 1,
|
|
1093
|
+
);
|
|
1094
|
+
if (nested) {
|
|
1095
|
+
container.append(' ');
|
|
1096
|
+
appendBracket(container, ')', bracketDepth);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function appendAnnotatedCalculation(
|
|
1101
|
+
container,
|
|
1102
|
+
calculation,
|
|
1103
|
+
entries,
|
|
1104
|
+
locale,
|
|
1105
|
+
dictionary,
|
|
1106
|
+
) {
|
|
1107
|
+
appendAnnotatedNode(
|
|
1108
|
+
container,
|
|
1109
|
+
calculation.breakdown,
|
|
1110
|
+
entries,
|
|
1111
|
+
locale,
|
|
1112
|
+
dictionary,
|
|
1113
|
+
);
|
|
1114
|
+
if (containsWrappedGroup(calculation.breakdown)) {
|
|
1115
|
+
if (!containsMatchingGroupResult(calculation.breakdown, calculation.result)) {
|
|
1116
|
+
appendTrailingOperator(container, '=');
|
|
1117
|
+
const lines = container.querySelectorAll(
|
|
1118
|
+
'.calculation-line__content',
|
|
1119
|
+
);
|
|
1120
|
+
const target = lines.item(lines.length - 1) ?? container;
|
|
1121
|
+
target.append(' ');
|
|
1122
|
+
appendAnnotatedNode(
|
|
1123
|
+
target,
|
|
1124
|
+
calculation.result,
|
|
1125
|
+
entries,
|
|
1126
|
+
locale,
|
|
1127
|
+
dictionary,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
} else {
|
|
1131
|
+
container.append(' = ');
|
|
1132
|
+
appendAnnotatedNode(
|
|
1133
|
+
container,
|
|
1134
|
+
calculation.result,
|
|
1135
|
+
entries,
|
|
1136
|
+
locale,
|
|
1137
|
+
dictionary,
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function render() {
|
|
1143
|
+
try {
|
|
1144
|
+
hideTooltip();
|
|
1145
|
+
const output = evaluateBuilder(elements.builder.value);
|
|
1146
|
+
const locale = selectedLocale;
|
|
1147
|
+
const useLocale = elements.useLocale.checked;
|
|
1148
|
+
const dictionary = useLocale ? output.dictionary ?? {} : {};
|
|
1149
|
+
const hasLocalization = useLocale && Boolean(
|
|
1150
|
+
output.title || output.dictionary,
|
|
1151
|
+
);
|
|
1152
|
+
const examples = output.examples ?? [
|
|
1153
|
+
{
|
|
1154
|
+
title: useLocale
|
|
1155
|
+
? output.title ?? { en: 'Calculation', cs: 'Výpočet' }
|
|
1156
|
+
: undefined,
|
|
1157
|
+
calculation: output.calculation,
|
|
1158
|
+
},
|
|
1159
|
+
];
|
|
1160
|
+
const list = document.createElement('div');
|
|
1161
|
+
list.className = 'preview-list';
|
|
1162
|
+
|
|
1163
|
+
for (const example of examples) {
|
|
1164
|
+
const fragment = elements.template.content.cloneNode(true);
|
|
1165
|
+
const calculation = example.calculation;
|
|
1166
|
+
const entries = createSemanticEntries(output, calculation, locale);
|
|
1167
|
+
const languageSwitcher = fragment.querySelector(
|
|
1168
|
+
'.language-switcher',
|
|
1169
|
+
);
|
|
1170
|
+
languageSwitcher.hidden = !hasLocalization;
|
|
1171
|
+
|
|
1172
|
+
for (const button of fragment.querySelectorAll('[data-locale]')) {
|
|
1173
|
+
const isActive = button.dataset.locale === locale;
|
|
1174
|
+
button.setAttribute('aria-pressed', String(isActive));
|
|
1175
|
+
button.addEventListener('click', () => {
|
|
1176
|
+
selectedLocale = button.dataset.locale;
|
|
1177
|
+
render();
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
for (const button of fragment.querySelectorAll('[data-view]')) {
|
|
1181
|
+
const isActive = button.dataset.view === selectedView;
|
|
1182
|
+
button.setAttribute('aria-pressed', String(isActive));
|
|
1183
|
+
button.addEventListener('click', () => {
|
|
1184
|
+
selectedView = button.dataset.view;
|
|
1185
|
+
showNames = selectedView === 'formula-names';
|
|
1186
|
+
render();
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
fragment.querySelector('[data-title]').textContent =
|
|
1191
|
+
example.title?.[locale] ?? 'Calculation';
|
|
1192
|
+
const expression = fragment.querySelector('[data-expression]');
|
|
1193
|
+
if (selectedView === 'table') {
|
|
1194
|
+
appendTableView(
|
|
1195
|
+
expression,
|
|
1196
|
+
calculation,
|
|
1197
|
+
entries,
|
|
1198
|
+
locale,
|
|
1199
|
+
dictionary,
|
|
1200
|
+
);
|
|
1201
|
+
} else {
|
|
1202
|
+
appendAnnotatedCalculation(
|
|
1203
|
+
expression,
|
|
1204
|
+
calculation,
|
|
1205
|
+
entries,
|
|
1206
|
+
locale,
|
|
1207
|
+
dictionary,
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
fragment.querySelector('[data-result-type]').textContent =
|
|
1211
|
+
calculation.result.type;
|
|
1212
|
+
const includeNames = selectedView === 'formula-names';
|
|
1213
|
+
const command = useLocale
|
|
1214
|
+
? `toHumanString(calculation, { locale: '${locale}', dictionary, includeNames: ${includeNames} })`
|
|
1215
|
+
: `toHumanString(calculation, { includeNames: ${includeNames} })`;
|
|
1216
|
+
const commandElement = fragment.querySelector('[data-command]');
|
|
1217
|
+
commandElement.hidden = selectedView === 'table';
|
|
1218
|
+
commandElement.innerHTML = highlight(
|
|
1219
|
+
command,
|
|
1220
|
+
'typescript',
|
|
1221
|
+
).trimEnd();
|
|
1222
|
+
fragment.querySelector('[data-json]').textContent = JSON.stringify(
|
|
1223
|
+
calculation,
|
|
1224
|
+
null,
|
|
1225
|
+
2,
|
|
1226
|
+
);
|
|
1227
|
+
list.append(fragment);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
elements.preview.replaceChildren(list);
|
|
1231
|
+
installTooltips(list);
|
|
1232
|
+
let customStyle = document.querySelector('#custom-preview-css');
|
|
1233
|
+
if (!customStyle) {
|
|
1234
|
+
customStyle = document.createElement('style');
|
|
1235
|
+
customStyle.id = 'custom-preview-css';
|
|
1236
|
+
document.head.append(customStyle);
|
|
1237
|
+
}
|
|
1238
|
+
customStyle.textContent = elements.css.value;
|
|
1239
|
+
elements.status.textContent = 'Live';
|
|
1240
|
+
elements.status.dataset.state = 'success';
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1243
|
+
const errorBox = document.createElement('div');
|
|
1244
|
+
errorBox.className = 'preview-error';
|
|
1245
|
+
errorBox.textContent = message;
|
|
1246
|
+
elements.preview.replaceChildren(errorBox);
|
|
1247
|
+
elements.status.textContent = 'Error';
|
|
1248
|
+
elements.status.dataset.state = 'error';
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function scheduleRender() {
|
|
1253
|
+
updateHighlights();
|
|
1254
|
+
clearTimeout(updateTimer);
|
|
1255
|
+
updateTimer = setTimeout(render, 250);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function reset() {
|
|
1259
|
+
elements.useLocale.checked = false;
|
|
1260
|
+
elements.builder.value = DEFAULT_BUILDER;
|
|
1261
|
+
elements.css.value = DEFAULT_CSS;
|
|
1262
|
+
selectedLocale = 'en';
|
|
1263
|
+
selectedView = 'table';
|
|
1264
|
+
showNames = false;
|
|
1265
|
+
updateHighlights();
|
|
1266
|
+
render();
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
for (const editor of [elements.builder, elements.css]) {
|
|
1270
|
+
editor.addEventListener('input', scheduleRender);
|
|
1271
|
+
editor.addEventListener('keydown', (event) => {
|
|
1272
|
+
if (event.key === 'Tab') {
|
|
1273
|
+
event.preventDefault();
|
|
1274
|
+
const start = editor.selectionStart;
|
|
1275
|
+
editor.setRangeText(' ', start, editor.selectionEnd, 'end');
|
|
1276
|
+
scheduleRender();
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
editor.addEventListener('scroll', () => {
|
|
1280
|
+
const layer =
|
|
1281
|
+
editor === elements.builder
|
|
1282
|
+
? elements.builderHighlight
|
|
1283
|
+
: elements.cssHighlight;
|
|
1284
|
+
layer.scrollTop = editor.scrollTop;
|
|
1285
|
+
layer.scrollLeft = editor.scrollLeft;
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
elements.run.addEventListener('click', render);
|
|
1290
|
+
elements.reset.addEventListener('click', reset);
|
|
1291
|
+
elements.useLocale.addEventListener('change', () => {
|
|
1292
|
+
selectedLocale = 'en';
|
|
1293
|
+
elements.builder.value = setBuilderLocaleMode(
|
|
1294
|
+
elements.builder.value,
|
|
1295
|
+
elements.useLocale.checked,
|
|
1296
|
+
);
|
|
1297
|
+
updateHighlights();
|
|
1298
|
+
render();
|
|
1299
|
+
});
|
|
1300
|
+
elements.preview.addEventListener('scroll', hideTooltip, { passive: true });
|
|
1301
|
+
window.addEventListener('resize', hideTooltip, { passive: true });
|
|
1302
|
+
|
|
1303
|
+
reset();
|