@pgcorp/ui-kit 0.7.3 → 0.8.1

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.
Files changed (36) hide show
  1. package/README.md +161 -8
  2. package/docs/accessibility.md +12 -0
  3. package/docs/getting-started.md +32 -2
  4. package/docs/public-api.md +27 -2
  5. package/package.json +18 -3
  6. package/src/components/shared/codeLanguages.ts +2 -107
  7. package/src/components/shared/containers/SPanel.css +32 -1
  8. package/src/components/shared/containers/SPanel.vue +4 -0
  9. package/src/components/shared/controls/SCheckbox.vue +2 -2
  10. package/src/components/shared/controls/SInteractiveSurface.css +54 -4
  11. package/src/components/shared/controls/SInteractiveSurface.vue +45 -31
  12. package/src/components/shared/controls/SListbox.vue +4 -4
  13. package/src/components/shared/controls/SSwitch.vue +2 -2
  14. package/src/components/shared/data-display/SActionCard.css +6 -2
  15. package/src/components/shared/data-display/SActionCard.vue +13 -3
  16. package/src/components/shared/data-display/SActionListItem.css +7 -8
  17. package/src/components/shared/data-display/SActionListItem.vue +21 -18
  18. package/src/components/shared/data-display/SChart.css +149 -0
  19. package/src/components/shared/data-display/SChart.vue +493 -0
  20. package/src/components/shared/data-display/SChip.css +24 -0
  21. package/src/components/shared/data-display/SChip.vue +30 -6
  22. package/src/components/shared/data-display/SCodeBlock.css +68 -0
  23. package/src/components/shared/data-display/SCodeBlock.vue +102 -16
  24. package/src/components/shared/data-display/SMetricCard.css +1 -0
  25. package/src/components/shared/data-display/SMetricCard.vue +1 -0
  26. package/src/components/shared/data-display/STable.vue +120 -145
  27. package/src/components/shared/data-display/chart.ts +54 -0
  28. package/src/components/shared/navigation/STabList.vue +0 -3
  29. package/src/internal/chartGeometry.ts +852 -0
  30. package/src/internal/codeLanguageIdentity.ts +89 -0
  31. package/src/internal/lazyCodeEditor.ts +1 -0
  32. package/src/internal/linkTarget.ts +1 -3
  33. package/src/internal/ownedAttrs.ts +1 -0
  34. package/src/internal/useBinaryInput.ts +9 -2
  35. package/src/styles/tailwind.css +3 -0
  36. package/src/styles/tokens.css +33 -0
@@ -0,0 +1,852 @@
1
+ import type {
2
+ SChartBarMode,
3
+ SChartCategory,
4
+ SChartCurve,
5
+ SChartDataLabels,
6
+ SChartDomain,
7
+ SChartMarkers,
8
+ SChartRadialLabel,
9
+ SChartSeries,
10
+ SChartSize,
11
+ SChartTone,
12
+ SChartType,
13
+ SChartXLabels,
14
+ } from '../components/shared/data-display/chart'
15
+
16
+ const CHART_TONES = [
17
+ 'series-1',
18
+ 'series-2',
19
+ 'series-3',
20
+ 'series-4',
21
+ 'series-5',
22
+ 'series-6',
23
+ 'series-7',
24
+ 'series-8',
25
+ 'success',
26
+ 'warn',
27
+ 'danger',
28
+ ] as const satisfies readonly SChartTone[]
29
+ const DEFAULT_TONES = CHART_TONES.slice(0, 8)
30
+
31
+ interface Point {
32
+ readonly x: number
33
+ readonly y: number
34
+ readonly value: number
35
+ readonly valueLabel: string
36
+ readonly categoryKey: string
37
+ readonly categoryLabel: string
38
+ readonly seriesKey: string
39
+ readonly seriesLabel: string
40
+ readonly tone: SChartTone
41
+ readonly title: string
42
+ }
43
+
44
+ export interface ChartLineModel {
45
+ readonly key: string
46
+ readonly label: string
47
+ readonly tone: SChartTone
48
+ readonly path: string
49
+ readonly areaPath: string
50
+ readonly points: readonly Point[]
51
+ }
52
+
53
+ export interface ChartBarModel extends Point {
54
+ readonly key: string
55
+ readonly width: number
56
+ readonly height: number
57
+ }
58
+
59
+ export interface ChartSliceModel {
60
+ readonly key: string
61
+ readonly label: string
62
+ readonly tone: SChartTone
63
+ readonly value: number
64
+ readonly valueLabel: string
65
+ readonly percentageLabel: string
66
+ readonly path: string
67
+ readonly labelX: number
68
+ readonly labelY: number
69
+ readonly dataLabel: string
70
+ readonly title: string
71
+ }
72
+
73
+ export interface ChartLegendItem {
74
+ readonly key: string
75
+ readonly label: string
76
+ readonly tone: SChartTone
77
+ }
78
+
79
+ export interface ChartTick {
80
+ readonly value: number
81
+ readonly label: string
82
+ readonly displayLabel: string
83
+ readonly position: number
84
+ }
85
+
86
+ export interface ChartCategoryLabel {
87
+ readonly key: string
88
+ readonly label: string
89
+ readonly lines: readonly string[]
90
+ readonly x: number
91
+ readonly textAnchor: 'start' | 'middle' | 'end'
92
+ }
93
+
94
+ export interface ChartGridLine {
95
+ readonly key: string
96
+ readonly x1: number
97
+ readonly x2: number
98
+ readonly y1: number
99
+ readonly y2: number
100
+ }
101
+
102
+ export interface ChartTableRow {
103
+ readonly key: string
104
+ readonly label: string
105
+ readonly values: readonly string[]
106
+ }
107
+
108
+ export interface ChartModel {
109
+ readonly width: number
110
+ readonly height: number
111
+ readonly plotX: number
112
+ readonly plotY: number
113
+ readonly plotWidth: number
114
+ readonly plotHeight: number
115
+ readonly baselineY: number
116
+ readonly empty: boolean
117
+ readonly emptyReason: 'no-data' | 'zero-total' | null
118
+ readonly lines: readonly ChartLineModel[]
119
+ readonly bars: readonly ChartBarModel[]
120
+ readonly slices: readonly ChartSliceModel[]
121
+ readonly horizontalGrid: readonly ChartGridLine[]
122
+ readonly verticalGrid: readonly ChartGridLine[]
123
+ readonly yTicks: readonly ChartTick[]
124
+ readonly xLabels: readonly ChartCategoryLabel[]
125
+ readonly legend: readonly ChartLegendItem[]
126
+ readonly tableRows: readonly ChartTableRow[]
127
+ readonly centerX: number
128
+ readonly centerY: number
129
+ }
130
+
131
+ export interface ResolveChartModelInput {
132
+ readonly type: SChartType
133
+ readonly size: SChartSize
134
+ readonly categories: readonly SChartCategory[]
135
+ readonly series: readonly SChartSeries[]
136
+ readonly domain: SChartDomain
137
+ readonly xAxis: boolean
138
+ readonly yAxis: boolean
139
+ readonly xLabels: SChartXLabels
140
+ readonly yTickCount: number
141
+ readonly curve: SChartCurve
142
+ readonly barMode: SChartBarMode
143
+ readonly dataLabels: SChartDataLabels
144
+ readonly markers: SChartMarkers
145
+ readonly radialLabel: SChartRadialLabel
146
+ readonly valueFormatter: (value: number) => string
147
+ /** Фактическая CSS-геометрия viewport сохраняет readable SVG typography при resize. / Actual CSS viewport geometry keeps SVG typography readable while resizing. */
148
+ readonly dimensions?: ChartDimensions
149
+ }
150
+
151
+ export interface ChartDimensions {
152
+ readonly width: number
153
+ readonly height: number
154
+ }
155
+
156
+ interface PlotRect {
157
+ readonly x: number
158
+ readonly y: number
159
+ readonly width: number
160
+ readonly height: number
161
+ }
162
+
163
+ interface ResolvedDomain {
164
+ readonly minimum: number
165
+ readonly maximum: number
166
+ readonly ticks: readonly number[]
167
+ }
168
+
169
+ const SIZE_DIMENSIONS: Readonly<Record<SChartSize, ChartDimensions>> = {
170
+ compact: { width: 360, height: 96 },
171
+ sm: { width: 640, height: 240 },
172
+ md: { width: 800, height: 360 },
173
+ lg: { width: 1000, height: 480 },
174
+ }
175
+
176
+ function resolveDimensions(size: SChartSize, candidate: ChartDimensions | undefined): ChartDimensions {
177
+ if (candidate === undefined) return SIZE_DIMENSIONS[size]
178
+ const dimensions = requireRecord(candidate, 'dimensions')
179
+ assertExactKeys(dimensions, ['width', 'height'], 'dimensions')
180
+ const width = requireFiniteNumber(dimensions.width, 'dimensions.width')
181
+ const height = requireFiniteNumber(dimensions.height, 'dimensions.height')
182
+ if (width <= 0 || height <= 0) fail('dimensions требует width и height больше нуля')
183
+ return { width, height }
184
+ }
185
+
186
+ function fail(message: string): never {
187
+ throw new TypeError(`SChart: ${message}`)
188
+ }
189
+
190
+ function assertExactKeys(
191
+ value: Record<string, unknown>,
192
+ allowed: readonly string[],
193
+ coordinate: string,
194
+ ): void {
195
+ const unexpected = Object.keys(value).filter((key) => !allowed.includes(key))
196
+ if (unexpected.length > 0) fail(`${coordinate} не допускает поля ${unexpected.join(', ')}`)
197
+ }
198
+
199
+ function requireRecord(value: unknown, coordinate: string): Record<string, unknown> {
200
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
201
+ fail(`${coordinate} должен быть object`)
202
+ }
203
+ return value as Record<string, unknown>
204
+ }
205
+
206
+ function requireString(value: unknown, coordinate: string): string {
207
+ if (typeof value !== 'string' || value.trim().length === 0) {
208
+ fail(`${coordinate} должен быть непустой строкой`)
209
+ }
210
+ return value
211
+ }
212
+
213
+ function requireTone(value: unknown, coordinate: string): SChartTone | undefined {
214
+ if (value === undefined) return undefined
215
+ if (typeof value !== 'string' || !CHART_TONES.includes(value as SChartTone)) {
216
+ fail(`${coordinate} должен быть semantic chart tone`)
217
+ }
218
+ return value as SChartTone
219
+ }
220
+
221
+ function requireFiniteNumber(value: unknown, coordinate: string): number {
222
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
223
+ fail(`${coordinate} должен быть конечным числом`)
224
+ }
225
+ return value
226
+ }
227
+
228
+ function validateCategories(value: unknown): readonly SChartCategory[] {
229
+ if (!Array.isArray(value)) fail('categories должен быть массивом')
230
+ const keys = new Set<string>()
231
+ return value.map((candidate, index) => {
232
+ const category = requireRecord(candidate, `categories[${index}]`)
233
+ assertExactKeys(category, ['key', 'label', 'tone'], `categories[${index}]`)
234
+ const key = requireString(category.key, `categories[${index}].key`)
235
+ if (keys.has(key)) fail(`categories содержит повторный key ${JSON.stringify(key)}`)
236
+ keys.add(key)
237
+ return {
238
+ key,
239
+ label: requireString(category.label, `categories[${index}].label`),
240
+ tone: requireTone(category.tone, `categories[${index}].tone`),
241
+ }
242
+ })
243
+ }
244
+
245
+ function validateSeries(value: unknown, categoryCount: number): readonly SChartSeries[] {
246
+ if (!Array.isArray(value)) fail('series должен быть массивом')
247
+ const keys = new Set<string>()
248
+ return value.map((candidate, seriesIndex) => {
249
+ const series = requireRecord(candidate, `series[${seriesIndex}]`)
250
+ assertExactKeys(series, ['key', 'label', 'values', 'tone'], `series[${seriesIndex}]`)
251
+ const key = requireString(series.key, `series[${seriesIndex}].key`)
252
+ if (keys.has(key)) fail(`series содержит повторный key ${JSON.stringify(key)}`)
253
+ keys.add(key)
254
+ if (!Array.isArray(series.values)) fail(`series[${seriesIndex}].values должен быть массивом`)
255
+ if (series.values.length !== categoryCount) {
256
+ fail(`series[${seriesIndex}].values содержит ${series.values.length} значений для ${categoryCount} categories`)
257
+ }
258
+ return {
259
+ key,
260
+ label: requireString(series.label, `series[${seriesIndex}].label`),
261
+ values: series.values.map((item, valueIndex) => requireFiniteNumber(
262
+ item,
263
+ `series[${seriesIndex}].values[${valueIndex}]`,
264
+ )),
265
+ tone: requireTone(series.tone, `series[${seriesIndex}].tone`),
266
+ }
267
+ })
268
+ }
269
+
270
+ function formatValue(formatter: (value: number) => string, value: number, coordinate: string): string {
271
+ const label = formatter(value)
272
+ if (typeof label !== 'string' || label.trim().length === 0) {
273
+ fail(`valueFormatter вернул пустое значение для ${coordinate}`)
274
+ }
275
+ return label
276
+ }
277
+
278
+ function resolveTone(explicit: SChartTone | undefined, index: number): SChartTone {
279
+ return explicit ?? DEFAULT_TONES[index % DEFAULT_TONES.length]!
280
+ }
281
+
282
+ function niceStep(rawStep: number): number {
283
+ const exponent = Math.floor(Math.log10(rawStep))
284
+ const magnitude = 10 ** exponent
285
+ const fraction = rawStep / magnitude
286
+ const niceFraction = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10
287
+ return niceFraction * magnitude
288
+ }
289
+
290
+ function resolveDomain(
291
+ domain: SChartDomain,
292
+ values: readonly number[],
293
+ includeZeroDefault: boolean,
294
+ tickCount: number,
295
+ ): { readonly minimum: number; readonly maximum: number; readonly ticks: readonly number[] } {
296
+ const record = requireRecord(domain, 'domain')
297
+ if (record.mode === 'fixed') {
298
+ assertExactKeys(record, ['mode', 'minimum', 'maximum'], 'domain')
299
+ const minimum = requireFiniteNumber(record.minimum, 'domain.minimum')
300
+ const maximum = requireFiniteNumber(record.maximum, 'domain.maximum')
301
+ if (minimum >= maximum) fail('domain fixed требует minimum < maximum')
302
+ const outside = values.find((value) => value < minimum || value > maximum)
303
+ if (outside !== undefined) fail(`domain fixed [${minimum}, ${maximum}] не содержит value ${outside}`)
304
+ const step = (maximum - minimum) / (tickCount - 1)
305
+ return {
306
+ minimum,
307
+ maximum,
308
+ ticks: Array.from({ length: tickCount }, (_, index) => minimum + step * index),
309
+ }
310
+ }
311
+ if (record.mode !== 'auto') fail(`неизвестный domain.mode ${JSON.stringify(record.mode)}`)
312
+ assertExactKeys(record, ['mode', 'includeZero'], 'domain')
313
+ if (record.includeZero !== undefined && typeof record.includeZero !== 'boolean') {
314
+ fail('domain.includeZero должен быть boolean')
315
+ }
316
+ const includeZero = (record.includeZero as boolean | undefined) ?? includeZeroDefault
317
+ let minimum = Math.min(...values)
318
+ let maximum = Math.max(...values)
319
+ if (includeZero) {
320
+ minimum = Math.min(0, minimum)
321
+ maximum = Math.max(0, maximum)
322
+ }
323
+ if (minimum === maximum) {
324
+ const delta = Math.abs(minimum) * 0.1 || 1
325
+ if (includeZero && minimum === 0) {
326
+ maximum = 1
327
+ } else {
328
+ minimum -= delta
329
+ maximum += delta
330
+ }
331
+ }
332
+ const step = niceStep((maximum - minimum) / (tickCount - 1))
333
+ const niceMinimum = includeZero && minimum >= 0 ? 0 : Math.floor(minimum / step) * step
334
+ const niceMaximum = includeZero && maximum <= 0 ? 0 : Math.ceil(maximum / step) * step
335
+ const ticks: number[] = []
336
+ for (let value = niceMinimum; value <= niceMaximum + step / 2 && ticks.length < 20; value += step) {
337
+ ticks.push(Object.is(value, -0) ? 0 : value)
338
+ }
339
+ return { minimum: niceMinimum, maximum: niceMaximum, ticks }
340
+ }
341
+
342
+ function linePath(points: readonly Point[], curve: SChartCurve): string {
343
+ if (points.length === 0) return ''
344
+ const [first, ...rest] = points
345
+ let path = `M ${first!.x} ${first!.y}`
346
+ for (let index = 0; index < rest.length; index += 1) {
347
+ const previous = points[index]!
348
+ const current = rest[index]!
349
+ if (curve === 'linear') {
350
+ path += ` L ${current.x} ${current.y}`
351
+ continue
352
+ }
353
+ const controlOffset = (current.x - previous.x) / 3
354
+ path += ` C ${previous.x + controlOffset} ${previous.y}, ${current.x - controlOffset} ${current.y}, ${current.x} ${current.y}`
355
+ }
356
+ return path
357
+ }
358
+
359
+ function polarPoint(centerX: number, centerY: number, radius: number, angle: number): { x: number; y: number } {
360
+ const radians = (angle - 90) * Math.PI / 180
361
+ return {
362
+ x: centerX + radius * Math.cos(radians),
363
+ y: centerY + radius * Math.sin(radians),
364
+ }
365
+ }
366
+
367
+ function radialPath(
368
+ centerX: number,
369
+ centerY: number,
370
+ outerRadius: number,
371
+ innerRadius: number,
372
+ startAngle: number,
373
+ endAngle: number,
374
+ ): string {
375
+ const span = endAngle - startAngle
376
+ if (span >= 359.999) {
377
+ const topOuter = polarPoint(centerX, centerY, outerRadius, 0)
378
+ const bottomOuter = polarPoint(centerX, centerY, outerRadius, 180)
379
+ if (innerRadius === 0) {
380
+ return `M ${centerX} ${centerY} L ${topOuter.x} ${topOuter.y} A ${outerRadius} ${outerRadius} 0 1 1 ${bottomOuter.x} ${bottomOuter.y} A ${outerRadius} ${outerRadius} 0 1 1 ${topOuter.x} ${topOuter.y} Z`
381
+ }
382
+ const topInner = polarPoint(centerX, centerY, innerRadius, 0)
383
+ const bottomInner = polarPoint(centerX, centerY, innerRadius, 180)
384
+ return `M ${topOuter.x} ${topOuter.y} A ${outerRadius} ${outerRadius} 0 1 1 ${bottomOuter.x} ${bottomOuter.y} A ${outerRadius} ${outerRadius} 0 1 1 ${topOuter.x} ${topOuter.y} L ${topInner.x} ${topInner.y} A ${innerRadius} ${innerRadius} 0 1 0 ${bottomInner.x} ${bottomInner.y} A ${innerRadius} ${innerRadius} 0 1 0 ${topInner.x} ${topInner.y} Z`
385
+ }
386
+ const outerStart = polarPoint(centerX, centerY, outerRadius, startAngle)
387
+ const outerEnd = polarPoint(centerX, centerY, outerRadius, endAngle)
388
+ const largeArc = span > 180 ? 1 : 0
389
+ if (innerRadius === 0) {
390
+ return `M ${centerX} ${centerY} L ${outerStart.x} ${outerStart.y} A ${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y} Z`
391
+ }
392
+ const innerEnd = polarPoint(centerX, centerY, innerRadius, endAngle)
393
+ const innerStart = polarPoint(centerX, centerY, innerRadius, startAngle)
394
+ return `M ${outerStart.x} ${outerStart.y} A ${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y} L ${innerEnd.x} ${innerEnd.y} A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${innerStart.x} ${innerStart.y} Z`
395
+ }
396
+
397
+ function resolvePlot(
398
+ dimensions: ChartDimensions,
399
+ type: SChartType,
400
+ size: SChartSize,
401
+ xAxis: boolean,
402
+ yAxis: boolean,
403
+ dataLabels: SChartDataLabels,
404
+ yTickLabels: readonly string[],
405
+ ): PlotRect {
406
+ if (type === 'pie' || type === 'donut') {
407
+ const inset = size === 'compact' ? 4 : 12
408
+ return { x: inset, y: inset, width: dimensions.width - inset * 2, height: dimensions.height - inset * 2 }
409
+ }
410
+ const top = dataLabels === 'none'
411
+ ? (size === 'compact' ? 5 : 12)
412
+ : (size === 'compact' ? 14 : 24)
413
+ const right = size === 'compact' ? 6 : 16
414
+ const bottom = xAxis ? (size === 'compact' ? 32 : 48) : (size === 'compact' ? 5 : 12)
415
+ const defaultLeft = yAxis ? (size === 'compact' ? 34 : 54) : (size === 'compact' ? 6 : 14)
416
+ const tickFontSize = size === 'compact' ? 9 : 11
417
+ const tickWidth = yAxis
418
+ ? Math.max(0, ...yTickLabels.map((label) => estimateAxisTextWidth(label, tickFontSize)))
419
+ : 0
420
+ const desiredLeft = yAxis ? Math.max(defaultLeft, Math.ceil(tickWidth + 16)) : defaultLeft
421
+ const minimumPlotWidth = Math.min(size === 'compact' ? 72 : 120, dimensions.width * 0.4)
422
+ const maximumLeft = Math.max(0, dimensions.width - right - minimumPlotWidth)
423
+ const left = Math.min(desiredLeft, maximumLeft)
424
+ return {
425
+ x: left,
426
+ y: top,
427
+ width: dimensions.width - left - right,
428
+ height: dimensions.height - top - bottom,
429
+ }
430
+ }
431
+
432
+ function estimateAxisTextWidth(label: string, fontSize: number): number {
433
+ const units = Array.from(label).reduce((total, character) => {
434
+ if (/\s/u.test(character)) return total + 0.36
435
+ if (/[.,:;!|ilI1'`]/u.test(character)) return total + 0.34
436
+ if (/[MW@%ШЩЖЮ]/u.test(character)) return total + 0.92
437
+ return total + 0.62
438
+ }, 0)
439
+ return units * fontSize
440
+ }
441
+
442
+ function truncateAxisLabelToWidth(label: string, availableWidth: number, fontSize: number): string {
443
+ if (estimateAxisTextWidth(label, fontSize) <= availableWidth) return label
444
+ const ellipsis = '…'
445
+ const characters = Array.from(label)
446
+ while (characters.length > 0) {
447
+ const candidate = `${characters.join('')}${ellipsis}`
448
+ if (estimateAxisTextWidth(candidate, fontSize) <= availableWidth) return candidate
449
+ characters.pop()
450
+ }
451
+ return ellipsis
452
+ }
453
+
454
+ function truncateAxisWord(word: string, maximumCharacters: number): string {
455
+ if (word.length <= maximumCharacters) return word
456
+ return `${word.slice(0, Math.max(1, maximumCharacters - 1))}…`
457
+ }
458
+
459
+ function wrapAxisLabel(label: string, availableWidth: number): readonly string[] {
460
+ const maximumCharacters = Math.max(4, Math.floor((availableWidth - 8) / 6.5))
461
+ const words = label.trim().split(/\s+/u)
462
+ const lines: string[] = []
463
+ let current = ''
464
+ for (const word of words) {
465
+ const candidate = current.length === 0 ? word : `${current} ${word}`
466
+ if (candidate.length <= maximumCharacters) {
467
+ current = candidate
468
+ continue
469
+ }
470
+ if (current.length > 0) lines.push(current)
471
+ current = word
472
+ if (lines.length === 2) break
473
+ }
474
+ if (current.length > 0 && lines.length < 2) lines.push(current)
475
+ const consumedWords = lines.join(' ').split(/\s+/u).length
476
+ if (consumedWords < words.length && lines.length > 0) {
477
+ lines[lines.length - 1] = truncateAxisWord(`${lines[lines.length - 1]}…`, maximumCharacters)
478
+ }
479
+ return lines.slice(0, 2).map((line) => truncateAxisWord(line, maximumCharacters))
480
+ }
481
+
482
+ function resolveStackedDomainValues(series: readonly SChartSeries[], categoryCount: number): readonly number[] {
483
+ const totals: number[] = []
484
+ for (let categoryIndex = 0; categoryIndex < categoryCount; categoryIndex += 1) {
485
+ let positive = 0
486
+ let negative = 0
487
+ for (const item of series) {
488
+ const value = item.values[categoryIndex]!
489
+ if (value >= 0) positive += value
490
+ else negative += value
491
+ }
492
+ totals.push(positive, negative)
493
+ }
494
+ return totals
495
+ }
496
+
497
+ function cartesianModel(
498
+ input: ResolveChartModelInput,
499
+ categories: readonly SChartCategory[],
500
+ series: readonly SChartSeries[],
501
+ dimensions: ChartDimensions,
502
+ plot: PlotRect,
503
+ resolvedDomain: ResolvedDomain,
504
+ yTickLabels: readonly string[],
505
+ ): Omit<ChartModel, 'empty' | 'emptyReason' | 'slices' | 'centerX' | 'centerY'> {
506
+ const range = resolvedDomain.maximum - resolvedDomain.minimum
507
+ const yAt = (value: number): number => plot.y + plot.height - ((value - resolvedDomain.minimum) / range) * plot.height
508
+ const baselineY = Math.max(plot.y, Math.min(plot.y + plot.height, yAt(0)))
509
+ const categoryBand = plot.width / categories.length
510
+ const xAt = (index: number): number => plot.x + categoryBand * (index + 0.5)
511
+ const pointsBySeries = series.map((item, seriesIndex): ChartLineModel => {
512
+ const tone = resolveTone(item.tone, seriesIndex)
513
+ const points = item.values.map((value, categoryIndex): Point => {
514
+ const category = categories[categoryIndex]!
515
+ const valueLabel = formatValue(input.valueFormatter, value, `${item.key}.${category.key}`)
516
+ return {
517
+ x: xAt(categoryIndex),
518
+ y: yAt(value),
519
+ value,
520
+ valueLabel,
521
+ categoryKey: category.key,
522
+ categoryLabel: category.label,
523
+ seriesKey: item.key,
524
+ seriesLabel: item.label,
525
+ tone,
526
+ title: `${item.label}, ${category.label}: ${valueLabel}`,
527
+ }
528
+ })
529
+ const path = linePath(points, input.curve)
530
+ const areaPath = points.length === 0
531
+ ? ''
532
+ : `${path} L ${points[points.length - 1]!.x} ${baselineY} L ${points[0]!.x} ${baselineY} Z`
533
+ return { key: item.key, label: item.label, tone, path, areaPath, points }
534
+ })
535
+
536
+ const bars: ChartBarModel[] = []
537
+ if (input.type === 'bar') {
538
+ const groupWidth = categoryBand * 0.72
539
+ const positiveTotals = Array.from({ length: categories.length }, () => 0)
540
+ const negativeTotals = Array.from({ length: categories.length }, () => 0)
541
+ for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) {
542
+ const item = series[seriesIndex]!
543
+ const tone = resolveTone(item.tone, seriesIndex)
544
+ for (let categoryIndex = 0; categoryIndex < categories.length; categoryIndex += 1) {
545
+ const category = categories[categoryIndex]!
546
+ const value = item.values[categoryIndex]!
547
+ let startValue = 0
548
+ let endValue = value
549
+ let x = xAt(categoryIndex) - groupWidth / 2 + seriesIndex * (groupWidth / series.length)
550
+ let width = groupWidth / series.length
551
+ if (input.barMode === 'stacked') {
552
+ startValue = value >= 0 ? positiveTotals[categoryIndex]! : negativeTotals[categoryIndex]!
553
+ endValue = startValue + value
554
+ if (value >= 0) positiveTotals[categoryIndex] = endValue
555
+ else negativeTotals[categoryIndex] = endValue
556
+ x = xAt(categoryIndex) - groupWidth * 0.34
557
+ width = groupWidth * 0.68
558
+ }
559
+ const yStart = yAt(startValue)
560
+ const yEnd = yAt(endValue)
561
+ const valueLabel = formatValue(input.valueFormatter, value, `${item.key}.${category.key}`)
562
+ bars.push({
563
+ key: `${item.key}:${category.key}`,
564
+ x,
565
+ y: Math.min(yStart, yEnd),
566
+ width,
567
+ height: Math.max(1, Math.abs(yEnd - yStart)),
568
+ value,
569
+ valueLabel,
570
+ categoryKey: category.key,
571
+ categoryLabel: category.label,
572
+ seriesKey: item.key,
573
+ seriesLabel: item.label,
574
+ tone,
575
+ title: `${item.label}, ${category.label}: ${valueLabel}`,
576
+ })
577
+ }
578
+ }
579
+ }
580
+
581
+ const showXLabel = (index: number): boolean => {
582
+ if (input.xLabels === 'none') return false
583
+ if (input.xLabels === 'all') return true
584
+ if (input.xLabels === 'endpoints') return index === 0 || index === categories.length - 1
585
+ return categories.length <= 6 || index === 0 || index === categories.length - 1
586
+ }
587
+ const visibleCategories = categories
588
+ .map((category, index) => ({ category, index, x: xAt(index) }))
589
+ .filter(({ index }) => showXLabel(index))
590
+ const xLabels = visibleCategories.map(({ category, x }, visibleIndex): ChartCategoryLabel => {
591
+ const previous = visibleCategories[visibleIndex - 1]
592
+ const next = visibleCategories[visibleIndex + 1]
593
+ if (visibleCategories.length === 1) {
594
+ return {
595
+ key: category.key,
596
+ label: category.label,
597
+ lines: wrapAxisLabel(category.label, plot.width),
598
+ x: plot.x + plot.width / 2,
599
+ textAnchor: 'middle',
600
+ }
601
+ }
602
+ if (!previous) {
603
+ const rightBoundary = (x + next!.x) / 2
604
+ return {
605
+ key: category.key,
606
+ label: category.label,
607
+ lines: wrapAxisLabel(category.label, rightBoundary - plot.x),
608
+ x: plot.x,
609
+ textAnchor: 'start',
610
+ }
611
+ }
612
+ if (!next) {
613
+ const leftBoundary = (previous.x + x) / 2
614
+ return {
615
+ key: category.key,
616
+ label: category.label,
617
+ lines: wrapAxisLabel(category.label, plot.x + plot.width - leftBoundary),
618
+ x: plot.x + plot.width,
619
+ textAnchor: 'end',
620
+ }
621
+ }
622
+ const leftBoundary = (previous.x + x) / 2
623
+ const rightBoundary = (x + next.x) / 2
624
+ return {
625
+ key: category.key,
626
+ label: category.label,
627
+ lines: wrapAxisLabel(category.label, 2 * Math.min(x - leftBoundary, rightBoundary - x)),
628
+ x,
629
+ textAnchor: 'middle',
630
+ }
631
+ })
632
+ const tickFontSize = input.size === 'compact' ? 9 : 11
633
+ const tickLabelWidth = Math.max(1, plot.x - 16)
634
+ const yTicks = resolvedDomain.ticks.map((value, index) => ({
635
+ value,
636
+ label: yTickLabels[index]!,
637
+ displayLabel: truncateAxisLabelToWidth(yTickLabels[index]!, tickLabelWidth, tickFontSize),
638
+ position: yAt(value),
639
+ }))
640
+ const horizontalGrid = yTicks.map((tick) => ({
641
+ key: `y:${tick.value}`,
642
+ x1: plot.x,
643
+ x2: plot.x + plot.width,
644
+ y1: tick.position,
645
+ y2: tick.position,
646
+ }))
647
+ const verticalGrid = categories.map((category, index) => ({
648
+ key: `x:${category.key}`,
649
+ x1: xAt(index),
650
+ x2: xAt(index),
651
+ y1: plot.y,
652
+ y2: plot.y + plot.height,
653
+ }))
654
+ return {
655
+ width: dimensions.width,
656
+ height: dimensions.height,
657
+ plotX: plot.x,
658
+ plotY: plot.y,
659
+ plotWidth: plot.width,
660
+ plotHeight: plot.height,
661
+ baselineY,
662
+ lines: pointsBySeries,
663
+ bars,
664
+ horizontalGrid,
665
+ verticalGrid,
666
+ yTicks,
667
+ xLabels,
668
+ legend: series.map((item, index) => ({
669
+ key: item.key,
670
+ label: item.label,
671
+ tone: resolveTone(item.tone, index),
672
+ })),
673
+ tableRows: categories.map((category, categoryIndex) => ({
674
+ key: category.key,
675
+ label: category.label,
676
+ values: series.map((item) => formatValue(
677
+ input.valueFormatter,
678
+ item.values[categoryIndex]!,
679
+ `${item.key}.${category.key}`,
680
+ )),
681
+ })),
682
+ }
683
+ }
684
+
685
+ function radialModel(
686
+ input: ResolveChartModelInput,
687
+ categories: readonly SChartCategory[],
688
+ series: readonly SChartSeries[],
689
+ dimensions: ChartDimensions,
690
+ plot: PlotRect,
691
+ ): ChartModel {
692
+ if (series.length !== 1) fail(`${input.type} требует ровно одну series`)
693
+ if (input.xAxis || input.yAxis) fail(`${input.type} не поддерживает Cartesian axes`)
694
+ if (input.markers !== 'none') fail(`${input.type} не поддерживает markers`)
695
+ if (input.dataLabels === 'latest') fail(`${input.type} поддерживает dataLabels только none или all`)
696
+ const item = series[0]!
697
+ for (let index = 0; index < item.values.length; index += 1) {
698
+ if (item.values[index]! < 0) fail(`${input.type} не допускает отрицательное series[0].values[${index}]`)
699
+ }
700
+ const total = item.values.reduce((sum, value) => sum + value, 0)
701
+ const centerX = plot.x + plot.width / 2
702
+ const centerY = plot.y + plot.height / 2
703
+ const outerRadius = Math.max(1, Math.min(plot.width, plot.height) / 2)
704
+ const innerRadius = input.type === 'donut' ? outerRadius * 0.58 : 0
705
+ let angle = 0
706
+ const slices = total === 0 ? [] : categories.map((category, index): ChartSliceModel => {
707
+ const value = item.values[index]!
708
+ const startAngle = angle
709
+ const endAngle = angle + value / total * 360
710
+ angle = endAngle
711
+ const midAngle = startAngle + (endAngle - startAngle) / 2
712
+ const labelRadius = innerRadius + (outerRadius - innerRadius) * 0.58
713
+ const labelPoint = polarPoint(centerX, centerY, labelRadius, midAngle)
714
+ const valueLabel = formatValue(input.valueFormatter, value, `${item.key}.${category.key}`)
715
+ const percentageLabel = `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(value / total * 100)}%`
716
+ const dataLabel = input.radialLabel === 'category'
717
+ ? category.label
718
+ : input.radialLabel === 'percentage'
719
+ ? percentageLabel
720
+ : valueLabel
721
+ return {
722
+ key: category.key,
723
+ label: category.label,
724
+ tone: resolveTone(category.tone, index),
725
+ value,
726
+ valueLabel,
727
+ percentageLabel,
728
+ path: radialPath(centerX, centerY, outerRadius, innerRadius, startAngle, endAngle),
729
+ labelX: labelPoint.x,
730
+ labelY: labelPoint.y,
731
+ dataLabel,
732
+ title: `${category.label}: ${valueLabel} (${percentageLabel})`,
733
+ }
734
+ })
735
+ return {
736
+ width: dimensions.width,
737
+ height: dimensions.height,
738
+ plotX: plot.x,
739
+ plotY: plot.y,
740
+ plotWidth: plot.width,
741
+ plotHeight: plot.height,
742
+ baselineY: centerY,
743
+ empty: total === 0,
744
+ emptyReason: total === 0 ? 'zero-total' : null,
745
+ lines: [],
746
+ bars: [],
747
+ slices,
748
+ horizontalGrid: [],
749
+ verticalGrid: [],
750
+ yTicks: [],
751
+ xLabels: [],
752
+ legend: categories.map((category, index) => ({
753
+ key: category.key,
754
+ label: category.label,
755
+ tone: resolveTone(category.tone, index),
756
+ })),
757
+ tableRows: categories.map((category, index) => ({
758
+ key: category.key,
759
+ label: category.label,
760
+ values: [formatValue(input.valueFormatter, item.values[index]!, `${item.key}.${category.key}`)],
761
+ })),
762
+ centerX,
763
+ centerY,
764
+ }
765
+ }
766
+
767
+ /** Валидирует public data contract и строит детерминированную SVG-модель. / Validates the public data contract and builds deterministic SVG geometry. */
768
+ export function resolveChartModel(input: ResolveChartModelInput): ChartModel {
769
+ const categories = validateCategories(input.categories)
770
+ const series = validateSeries(input.series, categories.length)
771
+ if (!SIZE_DIMENSIONS[input.size]) fail(`неизвестный size ${JSON.stringify(input.size)}`)
772
+ const dimensions = resolveDimensions(input.size, input.dimensions)
773
+ if (categories.length === 0 || series.length === 0) {
774
+ if (categories.length !== 0 || series.length !== 0) {
775
+ fail('empty state требует одновременно пустые categories и series')
776
+ }
777
+ return {
778
+ width: dimensions.width,
779
+ height: dimensions.height,
780
+ plotX: 0,
781
+ plotY: 0,
782
+ plotWidth: dimensions.width,
783
+ plotHeight: dimensions.height,
784
+ baselineY: dimensions.height,
785
+ empty: true,
786
+ emptyReason: 'no-data',
787
+ lines: [],
788
+ bars: [],
789
+ slices: [],
790
+ horizontalGrid: [],
791
+ verticalGrid: [],
792
+ yTicks: [],
793
+ xLabels: [],
794
+ legend: [],
795
+ tableRows: [],
796
+ centerX: dimensions.width / 2,
797
+ centerY: dimensions.height / 2,
798
+ }
799
+ }
800
+ if (input.type === 'pie' || input.type === 'donut') {
801
+ const plot = resolvePlot(
802
+ dimensions,
803
+ input.type,
804
+ input.size,
805
+ input.xAxis,
806
+ input.yAxis,
807
+ input.dataLabels,
808
+ [],
809
+ )
810
+ return radialModel(input, categories, series, dimensions, plot)
811
+ }
812
+ const domainValues = input.type === 'bar' && input.barMode === 'stacked'
813
+ ? resolveStackedDomainValues(series, categories.length)
814
+ : series.flatMap((item) => [...item.values])
815
+ const resolvedDomain = resolveDomain(
816
+ input.domain,
817
+ domainValues,
818
+ input.type === 'bar' || input.type === 'area',
819
+ input.yTickCount,
820
+ )
821
+ const yTickLabels = resolvedDomain.ticks.map((value) => formatValue(
822
+ input.valueFormatter,
823
+ value,
824
+ `yTick.${value}`,
825
+ ))
826
+ const plot = resolvePlot(
827
+ dimensions,
828
+ input.type,
829
+ input.size,
830
+ input.xAxis,
831
+ input.yAxis,
832
+ input.dataLabels,
833
+ yTickLabels,
834
+ )
835
+ const model = cartesianModel(
836
+ input,
837
+ categories,
838
+ series,
839
+ dimensions,
840
+ plot,
841
+ resolvedDomain,
842
+ yTickLabels,
843
+ )
844
+ return {
845
+ ...model,
846
+ empty: false,
847
+ emptyReason: null,
848
+ slices: [],
849
+ centerX: plot.x + plot.width / 2,
850
+ centerY: plot.y + plot.height / 2,
851
+ }
852
+ }