@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,107 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { render } from "@inglorious/web/test"
5
+ import { beforeEach, describe, expect, it, vi } from "vitest"
6
+
7
+ import { renderYAxis } from "./y-axis.js"
8
+
9
+ describe("renderYAxis", () => {
10
+ let entity
11
+ let props
12
+ let api
13
+
14
+ beforeEach(() => {
15
+ entity = {
16
+ id: "test-chart",
17
+ type: "line",
18
+ data: [
19
+ { x: 0, y: 50 },
20
+ { x: 1, y: 150 },
21
+ { x: 2, y: 120 },
22
+ ],
23
+ width: 800,
24
+ height: 400,
25
+ padding: { top: 20, right: 50, bottom: 30, left: 50 },
26
+ }
27
+
28
+ props = {
29
+ yScale: vi.fn((y) => 370 - (y / 200) * 350),
30
+ width: 800,
31
+ height: 400,
32
+ padding: { top: 20, right: 50, bottom: 30, left: 50 },
33
+ }
34
+
35
+ props.yScale.domain = vi.fn(() => [0, 200])
36
+ props.yScale.range = vi.fn(() => [370, 20])
37
+ props.yScale.ticks = vi.fn(() => [0, 50, 100, 150, 200])
38
+
39
+ api = {
40
+ notify: vi.fn(),
41
+ }
42
+ })
43
+
44
+ describe("basic rendering", () => {
45
+ it("should render Y axis with line and ticks", () => {
46
+ const result = renderYAxis(entity, props, api)
47
+ const container = document.createElement("div")
48
+ render(result, container)
49
+
50
+ const axisLine = container.querySelector("line")
51
+ expect(axisLine).toBeTruthy()
52
+
53
+ const ticks = container.querySelectorAll("g.iw-chart-yAxis-tick")
54
+ expect(ticks.length).toBeGreaterThan(0)
55
+ })
56
+
57
+ it("should format integer ticks without decimals", () => {
58
+ const result = renderYAxis(entity, props, api)
59
+ const container = document.createElement("div")
60
+ render(result, container)
61
+
62
+ const labels = container.querySelectorAll(".iw-chart-yAxis-tick-label")
63
+ labels.forEach((label) => {
64
+ const text = label.textContent.trim()
65
+ // Should not contain ".00" for integers
66
+ if (text.match(/^\d+$/)) {
67
+ expect(text).not.toContain(".00")
68
+ }
69
+ })
70
+ })
71
+ })
72
+
73
+ describe("with custom ticks", () => {
74
+ it("should use customTicks when provided", () => {
75
+ props.customTicks = [0, 100, 200]
76
+
77
+ const result = renderYAxis(entity, props, api)
78
+ const container = document.createElement("div")
79
+ render(result, container)
80
+
81
+ const ticks = container.querySelectorAll("g.iw-chart-yAxis-tick")
82
+ expect(ticks.length).toBe(3)
83
+ })
84
+ })
85
+
86
+ describe("with negative values", () => {
87
+ it("should render axis at zero line when domain includes negatives", () => {
88
+ props.yScale.domain = vi.fn(() => [-50, 200])
89
+ props.yScale.range = vi.fn(() => [370, 20])
90
+ props.yScale.ticks = vi.fn(() => [-50, 0, 50, 100, 150, 200])
91
+ props.yScale = vi.fn((y) => {
92
+ if (y === 0) return 296 // Zero line position
93
+ return 370 - ((y + 50) / 250) * 350
94
+ })
95
+ props.yScale.domain = vi.fn(() => [-50, 200])
96
+ props.yScale.range = vi.fn(() => [370, 20])
97
+ props.yScale.ticks = vi.fn(() => [-50, 0, 50, 100, 150, 200])
98
+
99
+ const result = renderYAxis(entity, props, api)
100
+ const container = document.createElement("div")
101
+ render(result, container)
102
+
103
+ const axisLine = container.querySelector("line")
104
+ expect(axisLine).toBeTruthy()
105
+ })
106
+ })
107
+ })
@@ -0,0 +1,150 @@
1
+ /* eslint-disable no-magic-numbers */
2
+
3
+ import { generateColors, getDefaultColors } from "./utils/colors.js"
4
+ import { calculatePadding } from "./utils/padding.js"
5
+
6
+ /**
7
+ * @typedef {import('../types/charts').ChartEntity} ChartEntity
8
+ * @typedef {import('../types/charts').ChartDataPoint} ChartDataPoint
9
+ * @typedef {import('../types/charts').PieDataPoint} PieDataPoint
10
+ * @typedef {import('../types/charts').ChartTooltip} ChartTooltip
11
+ * @typedef {import('../types/charts').TooltipPosition} TooltipPosition
12
+ */
13
+
14
+ /**
15
+ * Initializes the chart entity with default state.
16
+ * @param {ChartEntity} entity
17
+ */
18
+ export function create(entity) {
19
+ entity.width ??= 800
20
+ entity.height ??= 400
21
+ entity.padding ??= calculatePadding(entity.width, entity.height)
22
+ entity.data ??= []
23
+
24
+ if (!entity.colors) {
25
+ const dataCount = entity.data?.length || 0
26
+ entity.colors =
27
+ dataCount > 5 ? generateColors(dataCount) : getDefaultColors()
28
+ }
29
+
30
+ entity.showLegend ??= true
31
+ entity.showGrid ??= true
32
+ entity.showTooltip ??= true
33
+ entity.tooltip ??= null
34
+ entity.tooltipX ??= 0
35
+ entity.tooltipY ??= 0
36
+ // labelPosition: "inside" | "outside" | "tooltip" | "auto"
37
+ entity.labelPosition ??= "outside"
38
+ entity.showLabel ??= true
39
+ entity.outerPadding ??= undefined
40
+ entity.outerRadius ??= undefined
41
+ entity.innerRadius ??= undefined
42
+ entity.offsetRadius ??= 20
43
+ entity.minLabelPercentage ??= 2
44
+ entity.labelOverflowMargin ??= 20
45
+ entity.cx ??= undefined
46
+ entity.cy ??= undefined
47
+ entity.startAngle ??= undefined
48
+ entity.endAngle ??= undefined
49
+ entity.paddingAngle ??= undefined
50
+ entity.minAngle ??= undefined
51
+ entity.cornerRadius ??= undefined
52
+ entity.dataKey ??= undefined
53
+ entity.nameKey ??= undefined
54
+
55
+ if (!entity.xAxisType && entity.data?.length > 0) {
56
+ const hasDates = entity.data.some(
57
+ (d) => d.date || (d.values && d.values.some((v) => v.date)),
58
+ )
59
+ entity.xAxisType = hasDates ? "time" : "linear"
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Updates the chart data.
65
+ * @param {ChartEntity} entity
66
+ * @param {ChartDataPoint[] | PieDataPoint[]} data
67
+ */
68
+ export function dataUpdate(entity, data) {
69
+ entity.data = data
70
+
71
+ if (entity.brush?.enabled) {
72
+ const maxIndex = Math.max(0, (data?.length || 0) - 1)
73
+ entity.brush.startIndex = Math.min(entity.brush.startIndex || 0, maxIndex)
74
+ entity.brush.endIndex = Math.min(
75
+ entity.brush.endIndex || maxIndex,
76
+ maxIndex,
77
+ )
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Resizes the chart.
83
+ * @param {ChartEntity} entity
84
+ * @param {number} width
85
+ * @param {number} height
86
+ */
87
+ export function sizeUpdate(entity, width, height) {
88
+ entity.width = width
89
+ entity.height = height
90
+ entity.padding = calculatePadding(width, height)
91
+ }
92
+
93
+ /**
94
+ * Shows a tooltip at the specified position.
95
+ * @param {ChartEntity} entity
96
+ * @param {{ label: string; percentage?: number; value: number; color: string; x: number; y: number }} tooltipData
97
+ */
98
+ export function tooltipShow(entity, tooltipData) {
99
+ entity.tooltip = {
100
+ label: tooltipData.label,
101
+ percentage: tooltipData.percentage,
102
+ value: tooltipData.value,
103
+ color: tooltipData.color,
104
+ }
105
+ entity.tooltipX = tooltipData.x
106
+ entity.tooltipY = tooltipData.y
107
+ }
108
+
109
+ /**
110
+ * Hides the tooltip.
111
+ * @param {ChartEntity} entity
112
+ */
113
+ export function tooltipHide(entity) {
114
+ entity.tooltip = null
115
+ }
116
+
117
+ /**
118
+ * Moves the tooltip to a new position.
119
+ * @param {ChartEntity} entity
120
+ * @param {TooltipPosition} position
121
+ */
122
+ export function tooltipMove(entity, position) {
123
+ if (!entity.tooltip) return
124
+ entity.tooltipX = position.x
125
+ entity.tooltipY = position.y
126
+ }
127
+
128
+ /**
129
+ * Handles brush change event (zoom/pan).
130
+ * This is called automatically when brush selection changes.
131
+ * @param {ChartEntity} entity
132
+ * @param {{ startIndex: number; endIndex: number }} payload
133
+ */
134
+ export function brushChange(entity, payload) {
135
+ if (!entity.brush) entity.brush = { enabled: true }
136
+
137
+ const { startIndex, endIndex } = payload
138
+ const dataLength = entity.data?.length || 0
139
+
140
+ entity.brush.startIndex = Math.max(0, Math.min(startIndex, dataLength - 1))
141
+ entity.brush.endIndex = Math.max(
142
+ entity.brush.startIndex,
143
+ Math.min(endIndex, dataLength - 1),
144
+ )
145
+
146
+ if (entity.brush.startIndex === entity.brush.endIndex && dataLength > 1) {
147
+ if (entity.brush.endIndex < dataLength - 1) entity.brush.endIndex++
148
+ else if (entity.brush.startIndex > 0) entity.brush.startIndex--
149
+ }
150
+ }
package/src/index.js ADDED
@@ -0,0 +1,264 @@
1
+ import { svg } from "@inglorious/web"
2
+
3
+ import * as handlers from "./handlers.js"
4
+ import { render } from "./template.js"
5
+ import { extractDataKeysFromChildren } from "./utils/extract-data-keys.js"
6
+
7
+ // Export chart types for config style
8
+ export {
9
+ areaChart,
10
+ barChart,
11
+ donutChart,
12
+ lineChart,
13
+ pieChart,
14
+ } from "./utils/chart-utils.js"
15
+
16
+ export const chart = {
17
+ ...handlers,
18
+ render,
19
+
20
+ // Chart Delegators
21
+ renderLineChart: createDelegator("line"),
22
+ renderAreaChart: createDelegator("area"),
23
+ renderBarChart: createDelegator("bar"),
24
+ renderPieChart: createDelegator("pie"),
25
+
26
+ // Component Renderers (Abstracted)
27
+ renderLine: createComponentRenderer("renderLine", "line"),
28
+ renderArea: createComponentRenderer("renderArea", "area"),
29
+ renderBar: createComponentRenderer("renderBar", "bar"),
30
+ renderPie: createComponentRenderer("renderPie", "pie"),
31
+ renderYAxis: createComponentRenderer("renderYAxis"),
32
+ renderTooltip: createComponentRenderer("renderTooltip"),
33
+
34
+ // Lazy Renderers
35
+ renderCartesianGrid: (entity, props, api) =>
36
+ createLazyRenderer(entity, api, "renderCartesianGrid"),
37
+ renderXAxis: (entity, props, api) =>
38
+ createLazyRenderer(entity, api, "renderXAxis"),
39
+ renderBrush: (entity, props, api) =>
40
+ createLazyRenderer(entity, api, "renderBrush"),
41
+
42
+ // Declarative Helpers for Composition Style (return intention objects)
43
+ // The parent (renderLineChart, etc) processes these objects and "stamps" them with entity and api
44
+ XAxis: (config = {}) => ({ type: "XAxis", config }),
45
+ YAxis: (config = {}) => ({ type: "YAxis", config }),
46
+ Line: (config = {}) => ({ type: "Line", config }),
47
+ Area: (config = {}) => ({ type: "Area", config }),
48
+ Bar: (config = {}) => ({ type: "Bar", config }),
49
+ Pie: (config = {}) => ({ type: "Pie", config }),
50
+ CartesianGrid: (config = {}) => ({ type: "CartesianGrid", config }),
51
+ Tooltip: (config = {}) => ({ type: "Tooltip", config }),
52
+ Brush: (config = {}) => ({ type: "Brush", config }),
53
+ Dots: (config = {}) => ({ type: "Dots", config }),
54
+ Legend: (config = {}) => ({ type: "Legend", config }),
55
+
56
+ // Helper to create bound methods (reduces repetition)
57
+ forEntity(entityId, api) {
58
+ const entity = api.getEntity(entityId)
59
+ return entity ? createInstance(entity, api) : getEmptyInstance()
60
+ },
61
+
62
+ // Create instance for inline charts (no entityId needed)
63
+ forEntityInline(api, tempEntity = null) {
64
+ const entity = tempEntity || {
65
+ id: `__temp_${Date.now()}`,
66
+ type: "line", // Default, can be overridden by config
67
+ data: [],
68
+ }
69
+ // Preserve showTooltip if explicitly set in tempEntity
70
+ const preserveShowTooltip =
71
+ tempEntity?.showTooltip !== undefined ? tempEntity.showTooltip : undefined
72
+ // Initialize entity manually since it doesn't go through the store's create handler
73
+ handlers.create(entity)
74
+ // Restore showTooltip if it was explicitly set
75
+ if (preserveShowTooltip !== undefined) {
76
+ entity.showTooltip = preserveShowTooltip
77
+ }
78
+ return createInstance(entity, api, true) // true = inline mode
79
+ },
80
+
81
+ createInstance,
82
+ }
83
+
84
+ function createInstance(entity, api, isInline = false) {
85
+ let currentEntity = entity
86
+
87
+ const createChartFactory =
88
+ (chartType, renderMethod, forceStandard = false) =>
89
+ (arg1 = {}, arg2 = []) => {
90
+ const isLegacy = !forceStandard && Array.isArray(arg1)
91
+ const config = isLegacy ? arg2 || {} : arg1
92
+ const children = isLegacy ? arg1 : arg2
93
+
94
+ if (isInline) {
95
+ const resolvedData = config.data ?? currentEntity.data
96
+ currentEntity = {
97
+ ...currentEntity,
98
+ type: config.type || chartType,
99
+ ...(resolvedData ? { data: resolvedData } : null),
100
+ width: config.width || currentEntity.width,
101
+ height: config.height || currentEntity.height,
102
+ }
103
+ }
104
+
105
+ const finalConfig = {
106
+ ...config,
107
+ data:
108
+ config.data ||
109
+ (!isInline && currentEntity.data ? currentEntity.data : undefined),
110
+ // PieChart usually doesn't need dataKeys, but the extractor handles it
111
+ dataKeys:
112
+ chartType !== "pie"
113
+ ? config.dataKeys || extractDataKeysFromChildren(children)
114
+ : undefined,
115
+ }
116
+
117
+ return renderMethodOnType(
118
+ currentEntity,
119
+ renderMethod,
120
+ {
121
+ children: Array.isArray(children) ? children : [children],
122
+ config: finalConfig,
123
+ },
124
+ api,
125
+ )
126
+ }
127
+
128
+ // baseMethods return intention objects (don't render directly)
129
+ // Processing happens in renderXxxChart which receives the children
130
+ const baseMethods = {
131
+ CartesianGrid: (cfg = {}) => ({ type: "CartesianGrid", config: cfg }),
132
+ XAxis: (cfg = {}) => ({ type: "XAxis", config: cfg }),
133
+ YAxis: (cfg = {}) => ({ type: "YAxis", config: cfg }),
134
+ Tooltip: (cfg = {}) => ({ type: "Tooltip", config: cfg }),
135
+ Brush: (cfg = {}) => ({ type: "Brush", config: cfg }),
136
+ Line: (cfg = {}) => ({ type: "Line", config: cfg }),
137
+ Area: (cfg = {}) => ({ type: "Area", config: cfg }),
138
+ Bar: (cfg = {}) => ({ type: "Bar", config: cfg }),
139
+ Pie: (cfg = {}) => ({ type: "Pie", config: cfg }),
140
+ }
141
+
142
+ const instance = {
143
+ LineChart: createChartFactory("line", "renderLineChart", true),
144
+ AreaChart: createChartFactory("area", "renderAreaChart", true),
145
+ BarChart: createChartFactory("bar", "renderBarChart", true),
146
+ PieChart: createChartFactory("pie", "renderPieChart", true),
147
+
148
+ ...baseMethods,
149
+
150
+ // Aliases for compatibility (renderX)
151
+ renderLineChart: createChartFactory("line", "renderLineChart", false),
152
+ renderAreaChart: createChartFactory("area", "renderAreaChart", false),
153
+ renderBarChart: createChartFactory("bar", "renderBarChart", false),
154
+ renderPieChart: createChartFactory("pie", "renderPieChart", false),
155
+ renderCartesianGrid: baseMethods.CartesianGrid,
156
+ renderXAxis: baseMethods.XAxis,
157
+ renderYAxis: baseMethods.YAxis,
158
+ renderLine: baseMethods.Line,
159
+ renderArea: baseMethods.Area,
160
+ renderBar: baseMethods.Bar,
161
+ renderPie: baseMethods.Pie,
162
+ renderTooltip: baseMethods.Tooltip,
163
+ renderBrush: baseMethods.Brush,
164
+
165
+ // Dots and Legend also return intention objects
166
+ // Processing happens in renderXxxChart which receives the children
167
+ renderDots: (config = {}) => ({ type: "Dots", config }),
168
+ renderLegend: (config = {}) => ({ type: "Legend", config }),
169
+ }
170
+
171
+ // Synchronize PascalCase names with camelCase aliases
172
+ instance.Dots = instance.renderDots
173
+ instance.Legend = instance.renderLegend
174
+
175
+ return instance
176
+ }
177
+
178
+ function createDelegator(typeKey) {
179
+ const firstCharIndex = 0
180
+ const restStartIndex = 1
181
+ const firstChar = typeKey.charAt(firstCharIndex)
182
+ const rest = typeKey.slice(restStartIndex)
183
+ const methodName = `render${firstChar.toUpperCase() + rest}Chart`
184
+
185
+ return function delegateToChartType(entity, params, api) {
186
+ if (!entity) return renderEmptyTemplate()
187
+ const chartType = api.getType(typeKey)
188
+ return chartType?.[methodName]
189
+ ? chartType[methodName](entity, params, api)
190
+ : renderEmptyTemplate()
191
+ }
192
+ }
193
+
194
+ function createLazyRenderer(entity, api, methodName) {
195
+ return function renderLazy(ctx) {
196
+ if (!entity) return renderEmptyTemplate()
197
+ const chartTypeName = ctx?.chartType || entity.type
198
+ const chartType = api.getType(chartTypeName)
199
+ return chartType?.[methodName]
200
+ ? chartType[methodName](entity, { config: ctx?.config || {} }, api)
201
+ : renderEmptyTemplate()
202
+ }
203
+ }
204
+
205
+ function createComponentRenderer(methodName, typeOverride = null) {
206
+ return function renderComponent(entity, { config = {} }, api) {
207
+ if (!entity) return renderEmptyTemplate()
208
+ const type = api.getType(typeOverride || entity.type)
209
+ return type?.[methodName]
210
+ ? type[methodName](entity, { config }, api)
211
+ : renderEmptyTemplate()
212
+ }
213
+ }
214
+
215
+ function renderMethodOnType(entity, methodName, params, api) {
216
+ const type = api.getType(entity.type)
217
+ return type?.[methodName]
218
+ ? type[methodName](entity, params, api)
219
+ : renderEmptyTemplate()
220
+ }
221
+
222
+ function renderEmptyTemplate() {
223
+ return svg``
224
+ }
225
+
226
+ function renderEmptyLazyTemplate() {
227
+ return renderEmptyTemplate
228
+ }
229
+
230
+ function getEmptyInstance() {
231
+ return {
232
+ renderLineChart: renderEmptyTemplate,
233
+ renderAreaChart: renderEmptyTemplate,
234
+ renderBarChart: renderEmptyTemplate,
235
+ renderPieChart: renderEmptyTemplate,
236
+ renderCartesianGrid: renderEmptyLazyTemplate,
237
+ renderXAxis: renderEmptyLazyTemplate,
238
+ renderYAxis: renderEmptyTemplate,
239
+ renderLegend: renderEmptyLazyTemplate,
240
+ renderLine: renderEmptyTemplate,
241
+ renderArea: renderEmptyTemplate,
242
+ renderBar: renderEmptyTemplate,
243
+ renderPie: renderEmptyTemplate,
244
+ renderDots: renderEmptyLazyTemplate,
245
+ renderTooltip: renderEmptyTemplate,
246
+ renderBrush: renderEmptyLazyTemplate,
247
+ // Composition Style
248
+ LineChart: renderEmptyTemplate,
249
+ AreaChart: renderEmptyTemplate,
250
+ BarChart: renderEmptyTemplate,
251
+ PieChart: renderEmptyTemplate,
252
+ CartesianGrid: renderEmptyLazyTemplate,
253
+ XAxis: renderEmptyLazyTemplate,
254
+ YAxis: renderEmptyTemplate,
255
+ Line: renderEmptyTemplate,
256
+ Area: renderEmptyTemplate,
257
+ Bar: renderEmptyTemplate,
258
+ Pie: renderEmptyTemplate,
259
+ Dots: renderEmptyLazyTemplate,
260
+ Tooltip: renderEmptyTemplate,
261
+ Brush: renderEmptyLazyTemplate,
262
+ Legend: renderEmptyLazyTemplate,
263
+ }
264
+ }
@@ -0,0 +1,181 @@
1
+ /* eslint-disable no-magic-numbers */
2
+
3
+ import { html, svg } from "@inglorious/web"
4
+
5
+ import { createTooltipComponent, renderTooltip } from "../component/tooltip.js"
6
+ import { formatNumber } from "../utils/data-utils.js"
7
+ import { calculatePieData } from "../utils/paths.js"
8
+ import { renderPieSectors } from "./pie.js"
9
+
10
+ export const donut = {
11
+ /**
12
+ * Top-level rendering entry point for donut charts.
13
+ * @param {import('../types/charts').ChartEntity} entity
14
+ * @param {import('@inglorious/web').Api} api
15
+ * @returns {import('lit-html').TemplateResult}
16
+ */
17
+ render(entity, api) {
18
+ if (!entity.data || entity.data.length === 0) {
19
+ return svg`<svg>...</svg>`
20
+ }
21
+
22
+ // dataKey and nameKey: like Recharts (flexible data access)
23
+ const dataKey = entity.dataKey ?? ((d) => d.value)
24
+ const nameKey = entity.nameKey ?? ((d) => d.label || d.name || "")
25
+
26
+ // startAngle, endAngle, paddingAngle, minAngle: like Recharts
27
+ const startAngle = entity.startAngle ?? 0
28
+ const endAngle = entity.endAngle ?? 360
29
+ const paddingAngle = entity.paddingAngle ?? 0
30
+ const minAngle = entity.minAngle ?? 0
31
+
32
+ const pieData = calculatePieData(
33
+ entity.data,
34
+ dataKey,
35
+ startAngle,
36
+ endAngle,
37
+ paddingAngle,
38
+ minAngle,
39
+ )
40
+
41
+ const labelPosition = entity.labelPosition ?? "tooltip" // Default to tooltip for donut
42
+
43
+ const outerPadding =
44
+ entity.outerPadding ??
45
+ (labelPosition === "tooltip" ? 50 : labelPosition === "inside" ? 20 : 60)
46
+
47
+ // outerRadius: like Recharts (default calculated from dimensions)
48
+ const outerRadius =
49
+ entity.outerRadius ??
50
+ Math.min(entity.width, entity.height) / 2 - outerPadding
51
+
52
+ // innerRadius: for donut, use innerRadiusRatio if provided, otherwise default to 0.6
53
+ const innerRadius =
54
+ entity.innerRadius ?? outerRadius * (entity.innerRadiusRatio ?? 0.6)
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 rect = svgEl.getBoundingClientRect()
97
+ const angle = (slice.startAngle + slice.endAngle) / 2
98
+ const angleOffset = angle - Math.PI / 2
99
+ const labelRadius = outerRadius * 1.1
100
+ const x = centerX + Math.cos(angleOffset) * labelRadius
101
+ const y = centerY + Math.sin(angleOffset) * labelRadius
102
+ // Use absolute value to handle both clockwise and counter-clockwise slices
103
+ const percentage =
104
+ (Math.abs(slice.endAngle - slice.startAngle) / (2 * Math.PI)) * 100
105
+
106
+ // Use nameKey to get label
107
+ const label = nameKey(slice.data)
108
+
109
+ api.notify(`#${entity.id}:tooltipShow`, {
110
+ label,
111
+ percentage,
112
+ value: slice.value,
113
+ color:
114
+ slice.data.color || entity.colors[index % entity.colors.length],
115
+ x: rect.left + x,
116
+ y: rect.top + y,
117
+ })
118
+ },
119
+ onSliceLeave: () => {
120
+ api.notify(`#${entity.id}:tooltipHide`)
121
+ },
122
+ })
123
+
124
+ // Center text for donut (optional feature)
125
+ // Wrapped in <g> for better organization and potential future composition
126
+ const centerText = entity.centerText
127
+ ? svg`
128
+ <g class="iw-chart-center-text">
129
+ <text
130
+ x=${centerX}
131
+ y=${centerY - 5}
132
+ text-anchor="middle"
133
+ font-size="1.125em"
134
+ font-weight="bold"
135
+ fill="#333"
136
+ >
137
+ ${entity.centerText}
138
+ </text>
139
+ <text
140
+ x=${centerX}
141
+ y=${centerY + 15}
142
+ text-anchor="middle"
143
+ font-size="0.75em"
144
+ fill="#777"
145
+ >
146
+ ${formatNumber(pieData.reduce((sum, d) => sum + d.value, 0))}
147
+ </text>
148
+ </g>
149
+ `
150
+ : ""
151
+
152
+ return html`
153
+ <div class="iw-chart">
154
+ <svg
155
+ width=${entity.width}
156
+ height=${entity.height}
157
+ viewBox="0 0 ${entity.width} ${entity.height}"
158
+ class="iw-chart-svg"
159
+ @mousemove=${(e) => {
160
+ if (!entity.tooltip) return
161
+ const rect = e.currentTarget.getBoundingClientRect()
162
+ api.notify(`#${entity.id}:tooltipMove`, {
163
+ x: e.clientX - rect.left + 15,
164
+ y: e.clientY - rect.top - 15,
165
+ })
166
+ }}
167
+ >
168
+ ${slices} ${centerText}
169
+ </svg>
170
+
171
+ ${renderTooltip(entity, {}, api)}
172
+ </div>
173
+ `
174
+ },
175
+
176
+ /**
177
+ * Composition sub-render for tooltip overlay.
178
+ * @type {(entity: import('../types/charts').ChartEntity, params: { config?: Record<string, any> }, api: import('@inglorious/web').Api) => (ctx: Record<string, any>) => import('lit-html').TemplateResult}
179
+ */
180
+ renderTooltip: createTooltipComponent(),
181
+ }