@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.
- package/LICENSE +9 -0
- package/README.md +554 -0
- package/package.json +64 -0
- package/src/base.css +86 -0
- package/src/cartesian/area.js +392 -0
- package/src/cartesian/area.test.js +366 -0
- package/src/cartesian/bar.js +445 -0
- package/src/cartesian/bar.test.js +346 -0
- package/src/cartesian/line.js +823 -0
- package/src/cartesian/line.test.js +177 -0
- package/src/chart.test.js +444 -0
- package/src/component/brush.js +264 -0
- package/src/component/empty-state.js +33 -0
- package/src/component/empty-state.test.js +81 -0
- package/src/component/grid.js +123 -0
- package/src/component/grid.test.js +123 -0
- package/src/component/legend.js +76 -0
- package/src/component/legend.test.js +103 -0
- package/src/component/tooltip.js +65 -0
- package/src/component/tooltip.test.js +96 -0
- package/src/component/x-axis.js +212 -0
- package/src/component/x-axis.test.js +148 -0
- package/src/component/y-axis.js +77 -0
- package/src/component/y-axis.test.js +107 -0
- package/src/handlers.js +150 -0
- package/src/index.js +264 -0
- package/src/polar/donut.js +181 -0
- package/src/polar/donut.test.js +152 -0
- package/src/polar/pie.js +758 -0
- package/src/polar/pie.test.js +268 -0
- package/src/shape/curve.js +55 -0
- package/src/shape/dot.js +104 -0
- package/src/shape/rectangle.js +46 -0
- package/src/shape/sector.js +58 -0
- package/src/template.js +25 -0
- package/src/theme.css +90 -0
- package/src/utils/cartesian-layout.js +164 -0
- package/src/utils/chart-utils.js +30 -0
- package/src/utils/colors.js +77 -0
- package/src/utils/data-utils.js +155 -0
- package/src/utils/data-utils.test.js +210 -0
- package/src/utils/extract-data-keys.js +22 -0
- package/src/utils/padding.js +16 -0
- package/src/utils/paths.js +279 -0
- package/src/utils/process-declarative-child.js +46 -0
- package/src/utils/scales.js +250 -0
- package/src/utils/shared-context.js +166 -0
- package/src/utils/shared-context.test.js +237 -0
- package/src/utils/tooltip-handlers.js +129 -0
package/src/polar/pie.js
ADDED
|
@@ -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
|
+
}
|