@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
|
@@ -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
|
+
})
|
package/src/handlers.js
ADDED
|
@@ -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
|
+
}
|