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