@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.
- package/README.md +135 -8
- package/docs/accessibility.md +12 -0
- package/docs/getting-started.md +32 -2
- package/docs/public-api.md +19 -2
- package/docs/theming.md +16 -0
- package/package.json +18 -3
- package/src/components/shared/codeLanguages.ts +2 -107
- package/src/components/shared/containers/SPanel.css +32 -1
- package/src/components/shared/containers/SPanel.vue +4 -0
- package/src/components/shared/controls/SCheckbox.vue +2 -2
- package/src/components/shared/controls/SInteractiveSurface.css +4 -0
- package/src/components/shared/controls/SInteractiveSurface.vue +1 -1
- package/src/components/shared/controls/SListbox.vue +4 -4
- package/src/components/shared/controls/SSwitch.vue +2 -2
- package/src/components/shared/data-display/SChart.css +149 -0
- package/src/components/shared/data-display/SChart.vue +493 -0
- package/src/components/shared/data-display/SChip.css +19 -0
- package/src/components/shared/data-display/SChip.vue +30 -6
- package/src/components/shared/data-display/SCodeBlock.css +68 -0
- package/src/components/shared/data-display/SCodeBlock.vue +102 -16
- package/src/components/shared/data-display/SMetricCard.css +1 -0
- package/src/components/shared/data-display/SMetricCard.vue +1 -0
- package/src/components/shared/data-display/STable.vue +120 -145
- package/src/components/shared/data-display/chart.ts +54 -0
- package/src/components/shared/navigation/STabList.vue +0 -3
- package/src/internal/chartGeometry.ts +748 -0
- package/src/internal/codeLanguageIdentity.ts +89 -0
- package/src/internal/lazyCodeEditor.ts +1 -0
- package/src/internal/linkTarget.ts +1 -3
- package/src/internal/ownedAttrs.ts +1 -0
- package/src/internal/useBinaryInput.ts +9 -2
- package/src/styles/tailwind.css +3 -0
- 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
|
+
}
|