@inglorious/charts 1.0.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 (49) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +554 -0
  3. package/package.json +64 -0
  4. package/src/base.css +86 -0
  5. package/src/cartesian/area.js +392 -0
  6. package/src/cartesian/area.test.js +366 -0
  7. package/src/cartesian/bar.js +445 -0
  8. package/src/cartesian/bar.test.js +346 -0
  9. package/src/cartesian/line.js +823 -0
  10. package/src/cartesian/line.test.js +177 -0
  11. package/src/chart.test.js +444 -0
  12. package/src/component/brush.js +264 -0
  13. package/src/component/empty-state.js +33 -0
  14. package/src/component/empty-state.test.js +81 -0
  15. package/src/component/grid.js +123 -0
  16. package/src/component/grid.test.js +123 -0
  17. package/src/component/legend.js +76 -0
  18. package/src/component/legend.test.js +103 -0
  19. package/src/component/tooltip.js +65 -0
  20. package/src/component/tooltip.test.js +96 -0
  21. package/src/component/x-axis.js +212 -0
  22. package/src/component/x-axis.test.js +148 -0
  23. package/src/component/y-axis.js +77 -0
  24. package/src/component/y-axis.test.js +107 -0
  25. package/src/handlers.js +150 -0
  26. package/src/index.js +264 -0
  27. package/src/polar/donut.js +181 -0
  28. package/src/polar/donut.test.js +152 -0
  29. package/src/polar/pie.js +758 -0
  30. package/src/polar/pie.test.js +268 -0
  31. package/src/shape/curve.js +55 -0
  32. package/src/shape/dot.js +104 -0
  33. package/src/shape/rectangle.js +46 -0
  34. package/src/shape/sector.js +58 -0
  35. package/src/template.js +25 -0
  36. package/src/theme.css +90 -0
  37. package/src/utils/cartesian-layout.js +164 -0
  38. package/src/utils/chart-utils.js +30 -0
  39. package/src/utils/colors.js +77 -0
  40. package/src/utils/data-utils.js +155 -0
  41. package/src/utils/data-utils.test.js +210 -0
  42. package/src/utils/extract-data-keys.js +22 -0
  43. package/src/utils/padding.js +16 -0
  44. package/src/utils/paths.js +279 -0
  45. package/src/utils/process-declarative-child.js +46 -0
  46. package/src/utils/scales.js +250 -0
  47. package/src/utils/shared-context.js +166 -0
  48. package/src/utils/shared-context.test.js +237 -0
  49. package/src/utils/tooltip-handlers.js +129 -0
@@ -0,0 +1,758 @@
1
+ /* eslint-disable no-magic-numbers */
2
+
3
+ import { html, repeat, svg } from "@inglorious/web"
4
+
5
+ import { createTooltipComponent, renderTooltip } from "../component/tooltip.js"
6
+ import { renderSector } from "../shape/sector.js"
7
+ import { formatNumber } from "../utils/data-utils.js"
8
+ import { calculatePieData } from "../utils/paths.js"
9
+ import { processDeclarativeChild } from "../utils/process-declarative-child.js"
10
+
11
+ export const pie = {
12
+ /**
13
+ * Top-level rendering entry point for pie charts.
14
+ * @param {import('../types/charts').ChartEntity} entity
15
+ * @param {import('@inglorious/web').Api} api
16
+ * @returns {import('lit-html').TemplateResult}
17
+ */
18
+ render(entity, api) {
19
+ if (!entity.data || entity.data.length === 0) {
20
+ return svg`<svg>...</svg>`
21
+ }
22
+
23
+ // dataKey and nameKey: like Recharts (flexible data access)
24
+ const dataKey = entity.dataKey ?? ((d) => d.value)
25
+ const nameKey = entity.nameKey ?? ((d) => d.label || d.name || "")
26
+
27
+ // startAngle, endAngle, paddingAngle, minAngle: like Recharts
28
+ const startAngle = entity.startAngle ?? 0
29
+ const endAngle = entity.endAngle ?? 360
30
+ const paddingAngle = entity.paddingAngle ?? 0
31
+ const minAngle = entity.minAngle ?? 0
32
+
33
+ const pieData = calculatePieData(
34
+ entity.data,
35
+ dataKey,
36
+ startAngle,
37
+ endAngle,
38
+ paddingAngle,
39
+ minAngle,
40
+ )
41
+
42
+ const labelPosition = entity.labelPosition ?? "outside"
43
+
44
+ const outerPadding =
45
+ entity.outerPadding ??
46
+ (labelPosition === "tooltip" ? 50 : labelPosition === "inside" ? 20 : 60)
47
+
48
+ // outerRadius: like Recharts (default calculated from dimensions)
49
+ const outerRadius =
50
+ entity.outerRadius ??
51
+ Math.min(entity.width, entity.height) / 2 - outerPadding
52
+
53
+ // innerRadius: like Recharts (default 0 for pie chart)
54
+ const innerRadius = entity.innerRadius ?? 0
55
+
56
+ const offsetRadius = entity.offsetRadius ?? 20
57
+
58
+ // cx and cy: like Recharts (custom center position)
59
+ const centerX = entity.cx
60
+ ? typeof entity.cx === "string"
61
+ ? (parseFloat(entity.cx) / 100) * entity.width
62
+ : entity.cx
63
+ : entity.width / 2
64
+
65
+ const centerY = entity.cy
66
+ ? typeof entity.cy === "string"
67
+ ? (parseFloat(entity.cy) / 100) * entity.height
68
+ : entity.cy
69
+ : entity.height / 2
70
+
71
+ // cornerRadius: like Recharts (rounded edges)
72
+ const cornerRadius = entity.cornerRadius ?? 0
73
+
74
+ const slices = renderPieSectors({
75
+ pieData,
76
+ outerRadius,
77
+ innerRadius,
78
+ centerX,
79
+ centerY,
80
+ colors: entity.colors,
81
+ labelPosition,
82
+ showLabel: entity.showLabel ?? true,
83
+ offsetRadius,
84
+ minLabelPercentage: entity.minLabelPercentage,
85
+ labelOverflowMargin: entity.labelOverflowMargin,
86
+ cornerRadius,
87
+ nameKey,
88
+ width: entity.width,
89
+ height: entity.height,
90
+ labelPositions: null,
91
+ onSliceEnter: (slice, index, event) => {
92
+ if (!entity.showTooltip) return
93
+
94
+ const path = event.target
95
+ const svgEl = path.closest("svg")
96
+ const svgRect = svgEl.getBoundingClientRect()
97
+ // Get container element (.iw-chart) for relative positioning
98
+ const containerElement =
99
+ svgEl.closest(".iw-chart") || svgEl.parentElement
100
+ const containerRect = containerElement.getBoundingClientRect()
101
+
102
+ const angle = (slice.startAngle + slice.endAngle) / 2
103
+ const angleOffset = angle - Math.PI / 2
104
+ const labelRadius = outerRadius * 1.1
105
+ // x and y are relative to SVG
106
+ const x = centerX + Math.cos(angleOffset) * labelRadius
107
+ const y = centerY + Math.sin(angleOffset) * labelRadius
108
+ // Use absolute value to handle both clockwise and counter-clockwise slices
109
+ const percentage =
110
+ (Math.abs(slice.endAngle - slice.startAngle) / (2 * Math.PI)) * 100
111
+
112
+ // Use nameKey to get label
113
+ const label = nameKey(slice.data)
114
+
115
+ // Calculate position relative to container (not absolute page position)
116
+ // SVG position relative to container + tooltip position relative to SVG
117
+ const tooltipX = svgRect.left - containerRect.left + x
118
+ const tooltipY = svgRect.top - containerRect.top + y
119
+
120
+ api.notify(`#${entity.id}:tooltipShow`, {
121
+ label,
122
+ percentage,
123
+ value: slice.value,
124
+ color:
125
+ slice.data.color || entity.colors[index % entity.colors.length],
126
+ x: tooltipX,
127
+ y: tooltipY,
128
+ })
129
+ },
130
+ onSliceLeave: () => {
131
+ api.notify(`#${entity.id}:tooltipHide`)
132
+ },
133
+ })
134
+
135
+ return html`
136
+ <div class="iw-chart">
137
+ <svg
138
+ width=${entity.width}
139
+ height=${entity.height}
140
+ viewBox="0 0 ${entity.width} ${entity.height}"
141
+ class="iw-chart-svg"
142
+ @mousemove=${(e) => {
143
+ if (!entity.tooltip) return
144
+ const rect = e.currentTarget.getBoundingClientRect()
145
+ api.notify(`#${entity.id}:tooltipMove`, {
146
+ x: e.clientX - rect.left + 15,
147
+ y: e.clientY - rect.top - 15,
148
+ })
149
+ }}
150
+ >
151
+ ${slices}
152
+ </svg>
153
+
154
+ ${renderTooltip(entity, {}, api)}
155
+ </div>
156
+ `
157
+ },
158
+
159
+ /**
160
+ * Composition rendering entry point for pie charts.
161
+ * Acts as a context provider for nested `renderPie` components.
162
+ * @param {import('../types/charts').ChartEntity} entity
163
+ * @param {{ children?: any[]|any, config?: Record<string, any> }|any[]} params
164
+ * @param {import('@inglorious/web').Api} api
165
+ * @returns {import('lit-html').TemplateResult}
166
+ */
167
+ renderPieChart(entity, params, api) {
168
+ if (!entity) return html`<div>Entity not found</div>`
169
+
170
+ // Handle both { children, config } and { children, ...config } formats
171
+ let children, config
172
+ if (params && Array.isArray(params.children)) {
173
+ // Format: { children: [...], config: {...} } or { children: [...], ...config }
174
+ children = params.children
175
+ const existingConfig = params.config || {}
176
+ // If params has other properties (like width, height), merge them into config
177
+ const restParams = { ...params }
178
+ delete restParams.children
179
+ delete restParams.config
180
+ config = { ...restParams, ...existingConfig }
181
+ } else if (Array.isArray(params)) {
182
+ // Format: [children] (legacy)
183
+ children = params
184
+ config = {}
185
+ } else {
186
+ // Format: { children, config } or just config properties
187
+ children = params?.children || []
188
+ const existingConfig = params?.config || {}
189
+ // If params has other properties, merge them into config
190
+ const restParams = params ? { ...params } : {}
191
+ delete restParams.children
192
+ delete restParams.config
193
+ config = { ...restParams, ...existingConfig }
194
+ }
195
+
196
+ const entityWithData = config.data
197
+ ? { ...entity, data: config.data }
198
+ : entity
199
+
200
+ // Extract dimensions from config
201
+ const width = config.width || entity.width || 500
202
+ const height = config.height || entity.height || 400
203
+
204
+ // Calculate center position (cx, cy)
205
+ const cx =
206
+ config.cx !== undefined
207
+ ? typeof config.cx === "string"
208
+ ? (parseFloat(config.cx) / 100) * width
209
+ : config.cx
210
+ : width / 2
211
+ const cy =
212
+ config.cy !== undefined
213
+ ? typeof config.cy === "string"
214
+ ? (parseFloat(config.cy) / 100) * height
215
+ : config.cy
216
+ : height / 2
217
+
218
+ // Create context for Pie components
219
+ const context = {
220
+ entity: entityWithData,
221
+ width,
222
+ height,
223
+ cx,
224
+ cy,
225
+ api,
226
+ colors: entity.colors || [
227
+ "#3b82f6",
228
+ "#ef4444",
229
+ "#10b981",
230
+ "#f59e0b",
231
+ "#8b5cf6",
232
+ ],
233
+ }
234
+
235
+ const childrenArray = (
236
+ Array.isArray(children) ? children : [children]
237
+ ).filter(Boolean)
238
+
239
+ // Process declarative children before categorizing
240
+ // This converts { type: 'Pie', config } into rendered functions
241
+ const processedChildrenArray = childrenArray
242
+ .map((child) =>
243
+ processDeclarativeChild(child, entityWithData, "pie", api),
244
+ )
245
+ .filter(Boolean)
246
+
247
+ // Separate components using stable flags (survives minification)
248
+ // This ensures correct Z-index ordering: Slices -> Labels -> Tooltip
249
+ const slices = []
250
+ const labels = []
251
+ const tooltip = []
252
+ const others = []
253
+
254
+ for (const child of processedChildrenArray) {
255
+ // Use stable flags instead of string matching (survives minification)
256
+ if (typeof child === "function") {
257
+ // If it's already marked, add to the correct bucket
258
+ if (child.isPie) {
259
+ slices.push(child)
260
+ } else if (child.isLabel) {
261
+ labels.push(child)
262
+ } else if (child.isTooltip) {
263
+ tooltip.push(child)
264
+ } else {
265
+ // It's a lazy function from index.js - process it to identify its real type
266
+ // Use the real context (already created) to peek at what it returns
267
+ try {
268
+ const result = child(context)
269
+ // If the result is a marked function, use its type
270
+ if (typeof result === "function") {
271
+ if (result.isPie) {
272
+ slices.push(child) // Keep the original lazy function
273
+ } else if (result.isLabel) {
274
+ labels.push(child)
275
+ } else if (result.isTooltip) {
276
+ tooltip.push(child)
277
+ } else {
278
+ others.push(child)
279
+ }
280
+ } else {
281
+ others.push(child)
282
+ }
283
+ } catch {
284
+ // If processing fails, add to others (will be processed later)
285
+ others.push(child)
286
+ }
287
+ }
288
+ } else {
289
+ others.push(child)
290
+ }
291
+ }
292
+
293
+ // Reorder children for correct Z-index: Slices -> Labels -> Tooltip -> Others
294
+ // This ensures slices are behind, labels are in the middle, and tooltip is on top
295
+ const childrenToProcess = [...slices, ...labels, ...tooltip, ...others]
296
+
297
+ // Process children to handle lazy functions (like renderPie from index.js)
298
+ // Flow:
299
+ // 1. renderPie from index.js returns (ctx) => { return chartType.renderPie(...) }
300
+ // 2. chartType.renderPie (from pie.js) returns pieFn which is (ctx) => { return svg... }
301
+ // 3. So we need: child(context) -> pieFn, then pieFn(context) -> svg
302
+ // Simplified deterministic approach: all functions from index.js return (ctx) => ..., so we can safely call with context
303
+ const processedChildren = childrenToProcess.map((child) => {
304
+ // Non-function children are passed through as-is
305
+ if (typeof child !== "function") {
306
+ return child
307
+ }
308
+
309
+ // If it's a marked component (isPie, isLabel, etc), it expects context directly
310
+ if (child.isPie || child.isLabel || child.isTooltip) {
311
+ return child(context)
312
+ }
313
+
314
+ // If it's a function from index.js (renderPie, etc),
315
+ // it returns another function that also expects context
316
+ const result = child(context)
317
+ // If the result is a function (marked component), call it with context
318
+ if (typeof result === "function") {
319
+ return result(context)
320
+ }
321
+ // Otherwise, return the result directly (already SVG or TemplateResult)
322
+ return result
323
+ })
324
+
325
+ return html`
326
+ <div
327
+ class="iw-chart"
328
+ style="display: block; position: relative; width: 100%; box-sizing: border-box;"
329
+ >
330
+ <svg
331
+ width=${width}
332
+ height=${height}
333
+ viewBox="0 0 ${width} ${height}"
334
+ class="iw-chart-svg"
335
+ style="width: ${width}px; height: ${height}px;"
336
+ >
337
+ ${processedChildren}
338
+ </svg>
339
+ ${renderTooltip(entityWithData, {}, api)}
340
+ </div>
341
+ `
342
+ },
343
+
344
+ /**
345
+ * Composition sub-render for pie slices.
346
+ * @param {import('../types/charts').ChartEntity} entity
347
+ * @param {{ config?: Record<string, any> }} params
348
+ * @param {import('@inglorious/web').Api} api
349
+ * @returns {(ctx: Record<string, any>) => import('lit-html').TemplateResult}
350
+ */
351
+ // eslint-disable-next-line no-unused-vars
352
+ renderPie(entity, { config = {} }, api) {
353
+ const pieFn = (ctx) => {
354
+ const entityFromContext = ctx.entity || entity
355
+ if (!entityFromContext.data || entityFromContext.data.length === 0) {
356
+ return svg``
357
+ }
358
+
359
+ // Extract config props (Recharts-like)
360
+ const {
361
+ dataKey = "value",
362
+ nameKey = "name",
363
+ cx = ctx.cx,
364
+ cy = ctx.cy,
365
+ innerRadius = 0,
366
+ outerRadius,
367
+ startAngle = 0,
368
+ endAngle = 360,
369
+ paddingAngle = 0,
370
+ minAngle = 0,
371
+ cornerRadius = 0,
372
+ label = false,
373
+ labelPosition = "outside",
374
+ fill,
375
+ colors = ctx.colors,
376
+ } = config
377
+
378
+ // Convert dataKey to function if it's a string (like Recharts)
379
+ const valueAccessor =
380
+ typeof dataKey === "function"
381
+ ? dataKey
382
+ : typeof dataKey === "string"
383
+ ? (d) => d[dataKey]
384
+ : (d) => d.value
385
+
386
+ // Convert nameKey to function if it's a string
387
+ const nameAccessor =
388
+ typeof nameKey === "function"
389
+ ? nameKey
390
+ : typeof nameKey === "string"
391
+ ? (d) => d[nameKey] || d.label || d.name || ""
392
+ : (d) => d.label || d.name || ""
393
+
394
+ // Calculate outerRadius if not provided
395
+ const calculatedOuterRadius =
396
+ outerRadius !== undefined
397
+ ? typeof outerRadius === "string"
398
+ ? (parseFloat(outerRadius) / 100) * Math.min(ctx.width, ctx.height)
399
+ : outerRadius
400
+ : Math.min(ctx.width, ctx.height) / 2 - 60
401
+
402
+ // Calculate center position
403
+ const centerX =
404
+ typeof cx === "string"
405
+ ? (parseFloat(cx) / 100) * ctx.width
406
+ : cx !== undefined
407
+ ? cx
408
+ : ctx.cx
409
+ const centerY =
410
+ typeof cy === "string"
411
+ ? (parseFloat(cy) / 100) * ctx.height
412
+ : cy !== undefined
413
+ ? cy
414
+ : ctx.cy
415
+
416
+ // Calculate pie data
417
+ const pieData = calculatePieData(
418
+ entityFromContext.data,
419
+ valueAccessor,
420
+ startAngle,
421
+ endAngle,
422
+ paddingAngle,
423
+ minAngle,
424
+ )
425
+
426
+ // Render sectors
427
+ return renderPieSectors({
428
+ pieData,
429
+ outerRadius: calculatedOuterRadius,
430
+ innerRadius:
431
+ typeof innerRadius === "string"
432
+ ? (parseFloat(innerRadius) / 100) * Math.min(ctx.width, ctx.height)
433
+ : innerRadius,
434
+ centerX,
435
+ centerY,
436
+ colors,
437
+ labelPosition: label ? labelPosition : "tooltip",
438
+ showLabel: Boolean(label),
439
+ offsetRadius: 20,
440
+ minLabelPercentage: 2,
441
+ labelOverflowMargin: 20,
442
+ cornerRadius,
443
+ nameKey: nameAccessor,
444
+ width: ctx.width,
445
+ height: ctx.height,
446
+ labelPositions: null,
447
+ onSliceEnter: (slice, index, event) => {
448
+ const path = event.target
449
+ const svgEl = path.closest("svg")
450
+ const svgRect = svgEl.getBoundingClientRect()
451
+ // Get container element (.iw-chart) for relative positioning
452
+ const containerElement =
453
+ svgEl.closest(".iw-chart") || svgEl.parentElement
454
+ const containerRect = containerElement.getBoundingClientRect()
455
+
456
+ const angle = (slice.startAngle + slice.endAngle) / 2
457
+ const angleOffset = angle - Math.PI / 2
458
+ const labelRadius = calculatedOuterRadius * 1.1
459
+ // x and y are relative to SVG
460
+ const x = centerX + Math.cos(angleOffset) * labelRadius
461
+ const y = centerY + Math.sin(angleOffset) * labelRadius
462
+ const percentage =
463
+ (Math.abs(slice.endAngle - slice.startAngle) / (2 * Math.PI)) * 100
464
+
465
+ const label = nameAccessor(slice.data)
466
+ const color =
467
+ slice.data.color || colors[index % colors.length] || fill
468
+
469
+ // Calculate position relative to container (not absolute page position)
470
+ // SVG position relative to container + tooltip position relative to SVG
471
+ const tooltipX = svgRect.left - containerRect.left + x
472
+ const tooltipY = svgRect.top - containerRect.top + y
473
+
474
+ ctx.api.notify(`#${entityFromContext.id}:tooltipShow`, {
475
+ label,
476
+ percentage,
477
+ value: slice.value,
478
+ color,
479
+ x: tooltipX,
480
+ y: tooltipY,
481
+ })
482
+ },
483
+ onSliceLeave: () => {
484
+ ctx.api.notify(`#${entityFromContext.id}:tooltipHide`)
485
+ },
486
+ })
487
+ }
488
+ // Mark as pie component for stable identification
489
+ pieFn.isPie = true
490
+ return pieFn
491
+ },
492
+
493
+ /**
494
+ * Composition sub-render for tooltip overlay.
495
+ * @type {(entity: import('../types/charts').ChartEntity, params: { config?: Record<string, any> }, api: import('@inglorious/web').Api) => (ctx: Record<string, any>) => import('lit-html').TemplateResult}
496
+ */
497
+ renderTooltip: createTooltipComponent(),
498
+ }
499
+
500
+ /**
501
+ * Calculates ordered Y positions for external labels, avoiding overlap
502
+ * Similar to Recharts label positioning logic
503
+ */
504
+ function calculateLabelPositions(
505
+ pieData,
506
+ outerRadius,
507
+ width,
508
+ height,
509
+ offsetRadius,
510
+ ) {
511
+ const positions = new Map()
512
+ const minSpacing = 14
513
+ const maxY = height / 2 - 10
514
+ const minY = -height / 2 + 10
515
+
516
+ // Separate slices by side (left/right)
517
+ const rightSlices = []
518
+ const leftSlices = []
519
+
520
+ pieData.forEach((slice, i) => {
521
+ const angle = (slice.startAngle + slice.endAngle) / 2 - Math.PI / 2
522
+ const side = Math.cos(angle) >= 0 ? 1 : -1
523
+ const baseY = Math.sin(angle) * (outerRadius + offsetRadius)
524
+
525
+ if (side > 0) {
526
+ rightSlices.push({ index: i, angle, baseY })
527
+ } else {
528
+ leftSlices.push({ index: i, angle, baseY })
529
+ }
530
+ })
531
+
532
+ // Sort by Y (top to bottom)
533
+ rightSlices.sort((a, b) => a.baseY - b.baseY)
534
+ leftSlices.sort((a, b) => a.baseY - b.baseY)
535
+
536
+ // Calculate adjusted positions for right side
537
+ let currentY = minY
538
+ rightSlices.forEach(({ index, baseY }) => {
539
+ const adjustedY = Math.max(currentY, Math.min(maxY, baseY))
540
+ positions.set(index, adjustedY)
541
+ currentY = adjustedY + minSpacing
542
+ })
543
+
544
+ // Calculate adjusted positions for left side
545
+ currentY = minY
546
+ leftSlices.forEach(({ index, baseY }) => {
547
+ const adjustedY = Math.max(currentY, Math.min(maxY, baseY))
548
+ positions.set(index, adjustedY)
549
+ currentY = adjustedY + minSpacing
550
+ })
551
+
552
+ return positions
553
+ }
554
+
555
+ /**
556
+ * Renders pie sectors using Sector primitives
557
+ * Similar to Recharts Pie component
558
+ */
559
+ export function renderPieSectors({
560
+ pieData,
561
+ outerRadius,
562
+ innerRadius,
563
+ centerX,
564
+ centerY,
565
+ colors,
566
+ labelPosition,
567
+ showLabel,
568
+ offsetRadius,
569
+ minLabelPercentage,
570
+ labelOverflowMargin,
571
+ cornerRadius,
572
+ nameKey,
573
+ onSliceEnter,
574
+ onSliceLeave,
575
+ width,
576
+ height,
577
+ labelPositions: providedLabelPositions,
578
+ }) {
579
+ const labelPositions =
580
+ labelPosition === "outside" && !providedLabelPositions
581
+ ? calculateLabelPositions(
582
+ pieData,
583
+ outerRadius,
584
+ width,
585
+ height,
586
+ offsetRadius,
587
+ )
588
+ : providedLabelPositions
589
+
590
+ return svg`
591
+ ${repeat(
592
+ pieData,
593
+ (_, i) => i,
594
+ (slice, i) => {
595
+ // Use absolute value to handle both clockwise and counter-clockwise slices
596
+ const sliceSize = Math.abs(slice.endAngle - slice.startAngle)
597
+ const percentage = (sliceSize / (2 * Math.PI)) * 100
598
+
599
+ // Use user-controlled minLabelPercentage or default
600
+ const minPercentage = minLabelPercentage ?? 2
601
+ const shouldShowLabel = showLabel && percentage > minPercentage
602
+
603
+ const color = slice.data.color || colors[i % colors.length]
604
+
605
+ return svg`
606
+ ${renderSector({
607
+ innerRadius,
608
+ outerRadius,
609
+ startAngle: slice.startAngle,
610
+ endAngle: slice.endAngle,
611
+ centerX,
612
+ centerY,
613
+ fill: color,
614
+ className: "iw-chart-pie-slice",
615
+ cornerRadius: cornerRadius ?? 0,
616
+ dataIndex: i,
617
+ onMouseEnter: (e) => onSliceEnter?.(slice, i, e),
618
+ onMouseLeave: () => onSliceLeave?.(),
619
+ })}
620
+ ${
621
+ shouldShowLabel
622
+ ? renderLabel({
623
+ slice,
624
+ outerRadius,
625
+ percentage,
626
+ labelPosition,
627
+ pieData,
628
+ index: i,
629
+ color,
630
+ offsetRadius,
631
+ labelOverflowMargin,
632
+ nameKey,
633
+ labelPositions,
634
+ width,
635
+ height,
636
+ centerX,
637
+ centerY,
638
+ })
639
+ : ""
640
+ }
641
+ `
642
+ },
643
+ )}
644
+ `
645
+ }
646
+
647
+ // Label rendering functions
648
+ function renderLabel(params) {
649
+ const { labelPosition } = params
650
+
651
+ // If "tooltip", don't show label (only tooltip)
652
+ if (labelPosition === "tooltip") return svg``
653
+
654
+ // If "inside", show internal label
655
+ if (labelPosition === "inside") return renderInsideLabel(params)
656
+
657
+ // For "outside", "auto" or any other value, show external label (default)
658
+ return renderOutsideLabel(params)
659
+ }
660
+
661
+ function renderOutsideLabel({
662
+ slice,
663
+ outerRadius,
664
+ percentage,
665
+ index,
666
+ offsetRadius,
667
+ labelOverflowMargin,
668
+ nameKey,
669
+ labelPositions,
670
+ width,
671
+ height,
672
+ centerX,
673
+ centerY,
674
+ }) {
675
+ const angle = (slice.startAngle + slice.endAngle) / 2 - Math.PI / 2
676
+ const side = Math.cos(angle) >= 0 ? 1 : -1
677
+
678
+ const startX = Math.cos(angle) * outerRadius
679
+ const startY = Math.sin(angle) * outerRadius
680
+ const midX = Math.cos(angle) * (outerRadius + offsetRadius)
681
+
682
+ const baseMidY = Math.sin(angle) * (outerRadius + offsetRadius)
683
+ const midY = labelPositions?.get(index) ?? baseMidY
684
+
685
+ const endX = midX + side * 25
686
+ const endY = midY
687
+
688
+ const textX = endX + side * 8
689
+ const anchor = side > 0 ? "start" : "end"
690
+
691
+ const margin = labelOverflowMargin ?? 20
692
+ const minX = -width / 2 - margin
693
+ const maxX = width / 2 + margin
694
+ const minY = -height / 2 - margin
695
+ const maxY = height / 2 + margin
696
+
697
+ if (textX < minX || textX > maxX || endY < minY || endY > maxY) {
698
+ return svg``
699
+ }
700
+
701
+ const clampedEndX = Math.max(minX, Math.min(maxX, endX))
702
+ const clampedTextX = clampedEndX + side * 8
703
+
704
+ const labelText = nameKey ? nameKey(slice.data) : slice.data.label || ""
705
+
706
+ return svg`
707
+ <g transform="translate(${centerX}, ${centerY})">
708
+ <path
709
+ d="M${startX},${startY}L${midX},${midY}L${clampedEndX},${endY}"
710
+ stroke="#999"
711
+ fill="none"
712
+ />
713
+ <circle cx=${clampedEndX} cy=${endY} r="2" fill="#999" />
714
+ <text x=${clampedTextX} y=${endY - 6} text-anchor=${anchor}
715
+ font-size="0.75em" fill="#333" font-weight="500">
716
+ ${labelText}
717
+ </text>
718
+ <text x=${clampedTextX} y=${endY + 8} text-anchor=${anchor}
719
+ font-size="0.625em" fill="#777">
720
+ ${formatNumber(percentage)}%
721
+ </text>
722
+ </g>
723
+ `
724
+ }
725
+
726
+ function renderInsideLabel({ slice, outerRadius, percentage, nameKey, color }) {
727
+ // Use absolute value to handle both clockwise and counter-clockwise slices
728
+ const sliceSize = Math.abs(slice.endAngle - slice.startAngle)
729
+ const labelRadius = outerRadius * (sliceSize > Math.PI / 3 ? 0.55 : 0.75)
730
+ const angle = (slice.startAngle + slice.endAngle) / 2 - Math.PI / 2
731
+
732
+ const x = Math.cos(angle) * labelRadius
733
+ const y = Math.sin(angle) * labelRadius
734
+
735
+ const labelText = nameKey ? nameKey(slice.data) : slice.data.label || ""
736
+ const textColor = isDarkColor(color) ? "#fff" : "#444"
737
+
738
+ return svg`
739
+ <g>
740
+ <text x=${x} y=${y} text-anchor="middle"
741
+ font-size="0.75em" fill="#333" font-weight="500">
742
+ ${labelText}
743
+ </text>
744
+ <text x=${x} y=${y + 14} text-anchor="middle"
745
+ font-size="0.625em" fill=${textColor}>
746
+ ${formatNumber(percentage)}%
747
+ </text>
748
+ </g>
749
+ `
750
+ }
751
+
752
+ function isDarkColor(hexColor) {
753
+ const r = parseInt(hexColor.slice(1, 3), 16)
754
+ const g = parseInt(hexColor.slice(3, 5), 16)
755
+ const b = parseInt(hexColor.slice(5, 7), 16)
756
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
757
+ return luminance < 0.5
758
+ }