@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,268 @@
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 { pie } from "./pie.js"
8
+
9
+ describe("pie", () => {
10
+ let entity
11
+ let api
12
+
13
+ beforeEach(() => {
14
+ entity = {
15
+ id: "test-pie",
16
+ type: "pie",
17
+ data: [
18
+ { name: "Category A", value: 20 },
19
+ { name: "Category B", value: 35 },
20
+ { name: "Category C", value: 15 },
21
+ { name: "Category D", value: 10 },
22
+ ],
23
+ width: 500,
24
+ height: 400,
25
+ colors: ["#3b82f6", "#ef4444", "#10b981", "#f59e0b"],
26
+ showTooltip: true,
27
+ }
28
+
29
+ api = {
30
+ getEntity: vi.fn((id) => (id === "test-pie" ? entity : null)),
31
+ notify: vi.fn(),
32
+ getType: vi.fn().mockReturnValue({
33
+ renderTooltip: () => "",
34
+ }),
35
+ }
36
+ })
37
+
38
+ describe("render()", () => {
39
+ it("should render pie chart with data", () => {
40
+ const result = pie.render(entity, api)
41
+ const container = document.createElement("div")
42
+ render(result, container)
43
+
44
+ const svg = container.querySelector("svg")
45
+ expect(svg).toBeTruthy()
46
+ })
47
+
48
+ it("should handle empty data gracefully", () => {
49
+ entity.data = []
50
+
51
+ const result = pie.render(entity, api)
52
+ const container = document.createElement("div")
53
+ render(result, container)
54
+
55
+ expect(result).toBeDefined()
56
+ })
57
+ })
58
+
59
+ describe("renderPieChart()", () => {
60
+ it("should render pie chart with children", () => {
61
+ const children = [
62
+ pie.renderPie(
63
+ entity,
64
+ {
65
+ config: { dataKey: "value", nameKey: "name" },
66
+ },
67
+ api,
68
+ ),
69
+ ]
70
+
71
+ const result = pie.renderPieChart(
72
+ entity,
73
+ { children, config: { width: 500, height: 400 } },
74
+ api,
75
+ )
76
+ const container = document.createElement("div")
77
+ render(result, container)
78
+
79
+ const svg = container.querySelector("svg")
80
+ expect(svg).toBeTruthy()
81
+ expect(svg.getAttribute("width")).toBe("500")
82
+ expect(svg.getAttribute("height")).toBe("400")
83
+ })
84
+
85
+ it("should return error message if entity is missing", () => {
86
+ const result = pie.renderPieChart(null, { children: [] }, api)
87
+ const container = document.createElement("div")
88
+ render(result, container)
89
+
90
+ expect(container.textContent).toContain("Entity not found")
91
+ })
92
+
93
+ it("should use custom cx and cy when provided", () => {
94
+ const children = [
95
+ pie.renderPie(
96
+ entity,
97
+ {
98
+ config: { dataKey: "value", nameKey: "name" },
99
+ },
100
+ api,
101
+ ),
102
+ ]
103
+
104
+ const result = pie.renderPieChart(
105
+ entity,
106
+ {
107
+ children,
108
+ config: { width: 500, height: 400, cx: "35%", cy: "35%" },
109
+ },
110
+ api,
111
+ )
112
+ const container = document.createElement("div")
113
+ render(result, container)
114
+
115
+ const svg = container.querySelector("svg")
116
+ expect(svg).toBeTruthy()
117
+ })
118
+ })
119
+
120
+ describe("renderPie()", () => {
121
+ it("should return a function marked as isPie", () => {
122
+ const result = pie.renderPie(
123
+ entity,
124
+ {
125
+ config: { dataKey: "value", nameKey: "name" },
126
+ },
127
+ api,
128
+ )
129
+
130
+ expect(typeof result).toBe("function")
131
+ expect(result.isPie).toBe(true)
132
+ })
133
+
134
+ it("should render pie sectors when called with context", () => {
135
+ const pieFn = pie.renderPie(
136
+ entity,
137
+ {
138
+ config: { dataKey: "value", nameKey: "name" },
139
+ },
140
+ api,
141
+ )
142
+
143
+ const context = {
144
+ entity,
145
+ width: 500,
146
+ height: 400,
147
+ cx: 250,
148
+ cy: 200,
149
+ api,
150
+ colors: entity.colors,
151
+ }
152
+
153
+ const result = pieFn(context)
154
+ const container = document.createElement("div")
155
+ render(result, container)
156
+
157
+ // Should render paths for pie sectors
158
+ const paths = container.querySelectorAll("path")
159
+ expect(paths.length).toBeGreaterThan(0)
160
+ })
161
+
162
+ it("should support innerRadius for donut charts", () => {
163
+ const pieFn = pie.renderPie(
164
+ entity,
165
+ {
166
+ config: {
167
+ dataKey: "value",
168
+ nameKey: "name",
169
+ innerRadius: 50,
170
+ },
171
+ },
172
+ api,
173
+ )
174
+
175
+ expect(typeof pieFn).toBe("function")
176
+ expect(pieFn.isPie).toBe(true)
177
+ })
178
+
179
+ it("should support custom outerRadius", () => {
180
+ const pieFn = pie.renderPie(
181
+ entity,
182
+ {
183
+ config: {
184
+ dataKey: "value",
185
+ nameKey: "name",
186
+ outerRadius: 150,
187
+ },
188
+ },
189
+ api,
190
+ )
191
+
192
+ expect(typeof pieFn).toBe("function")
193
+ expect(pieFn.isPie).toBe(true)
194
+ })
195
+
196
+ it("should support paddingAngle", () => {
197
+ const pieFn = pie.renderPie(
198
+ entity,
199
+ {
200
+ config: {
201
+ dataKey: "value",
202
+ nameKey: "name",
203
+ paddingAngle: 5,
204
+ },
205
+ },
206
+ api,
207
+ )
208
+
209
+ expect(typeof pieFn).toBe("function")
210
+ expect(pieFn.isPie).toBe(true)
211
+ })
212
+
213
+ it("should support minAngle", () => {
214
+ const pieFn = pie.renderPie(
215
+ entity,
216
+ {
217
+ config: {
218
+ dataKey: "value",
219
+ nameKey: "name",
220
+ minAngle: 10,
221
+ },
222
+ },
223
+ api,
224
+ )
225
+
226
+ expect(typeof pieFn).toBe("function")
227
+ expect(pieFn.isPie).toBe(true)
228
+ })
229
+
230
+ it("should support cornerRadius", () => {
231
+ const pieFn = pie.renderPie(
232
+ entity,
233
+ {
234
+ config: {
235
+ dataKey: "value",
236
+ nameKey: "name",
237
+ cornerRadius: 10,
238
+ },
239
+ },
240
+ api,
241
+ )
242
+
243
+ expect(typeof pieFn).toBe("function")
244
+ expect(pieFn.isPie).toBe(true)
245
+ })
246
+
247
+ it("should support custom dataKey and nameKey", () => {
248
+ entity.data = [
249
+ { product: "Laptop", sales: 150 },
250
+ { product: "Phone", sales: 200 },
251
+ ]
252
+
253
+ const pieFn = pie.renderPie(
254
+ entity,
255
+ {
256
+ config: {
257
+ dataKey: "sales",
258
+ nameKey: "product",
259
+ },
260
+ },
261
+ api,
262
+ )
263
+
264
+ expect(typeof pieFn).toBe("function")
265
+ expect(pieFn.isPie).toBe(true)
266
+ })
267
+ })
268
+ })
@@ -0,0 +1,55 @@
1
+ import { svg } from "@inglorious/web"
2
+
3
+ /**
4
+ * Renders a single curve/path (primitive shape)
5
+ * Similar to Recharts Curve component
6
+ *
7
+ * @param {Object} params
8
+ * @param {string} params.d - Path data (SVG path string)
9
+ * @param {string} [params.stroke] - Stroke color
10
+ * @param {string} [params.fill] - Fill color
11
+ * @param {string} [params.fillOpacity] - Fill opacity
12
+ * @param {string} [params.strokeWidth] - Stroke width
13
+ * @param {string} [params.className] - CSS class
14
+ * @param {Function} [params.onMouseEnter] - Mouse enter handler
15
+ * @param {Function} [params.onMouseLeave] - Mouse leave handler
16
+ * @returns {import('lit-html').TemplateResult}
17
+ */
18
+ export function renderCurve({
19
+ d,
20
+ stroke,
21
+ fill = "none",
22
+ fillOpacity,
23
+ strokeWidth = "0.15625em",
24
+ className = "iw-chart-curve",
25
+ entityId,
26
+ onMouseEnter,
27
+ onMouseLeave,
28
+ }) {
29
+ const wrappedOnMouseEnter = onMouseEnter
30
+ ? (e) => {
31
+ onMouseEnter(e)
32
+ }
33
+ : undefined
34
+
35
+ const wrappedOnMouseLeave = onMouseLeave
36
+ ? (e) => {
37
+ onMouseLeave(e)
38
+ }
39
+ : undefined
40
+
41
+ return svg`
42
+ <path
43
+ d=${d}
44
+ stroke=${stroke}
45
+ fill=${fill}
46
+ fill-opacity=${fillOpacity}
47
+ stroke-width=${strokeWidth}
48
+ class=${className}
49
+ data-entity-id=${entityId || undefined}
50
+ style=${stroke ? `stroke: ${stroke} !important;` : undefined}
51
+ @mouseenter=${wrappedOnMouseEnter}
52
+ @mouseleave=${wrappedOnMouseLeave}
53
+ />
54
+ `
55
+ }
@@ -0,0 +1,104 @@
1
+ /* eslint-disable no-magic-numbers */
2
+
3
+ import { svg } from "@inglorious/web"
4
+
5
+ import { isValidNumber } from "../utils/data-utils.js"
6
+
7
+ /**
8
+ * Renders a single dot/circle (primitive shape)
9
+ * Similar to Recharts Dot component
10
+ *
11
+ * @param {Object} params
12
+ * @param {number} params.cx - Center X
13
+ * @param {number} params.cy - Center Y
14
+ * @param {number|string} [params.r] - Radius
15
+ * @param {string} [params.fill] - Fill color
16
+ * @param {string} [params.stroke] - Stroke color
17
+ * @param {string} [params.strokeWidth] - Stroke width
18
+ * @param {string} [params.className] - CSS class
19
+ * @param {Function} [params.onMouseEnter] - Mouse enter handler
20
+ * @param {Function} [params.onMouseLeave] - Mouse leave handler
21
+ * @returns {import('lit-html').TemplateResult}
22
+ */
23
+ export function renderDot({
24
+ cx,
25
+ cy,
26
+ r = "0.25em",
27
+ fill,
28
+ stroke = "white",
29
+ strokeWidth = "0.125em",
30
+ className = "iw-chart-dot",
31
+ onMouseEnter,
32
+ onMouseLeave,
33
+ }) {
34
+ if (cx == null || cy == null) {
35
+ return svg``
36
+ }
37
+
38
+ // Parse r to get numeric value and unit
39
+ let rValue = 0.25
40
+ let rUnit = ""
41
+
42
+ if (typeof r === "string") {
43
+ const match = r.match(/([\d.]+)([a-z%]*)/i)
44
+ if (match) {
45
+ rValue = parseFloat(match[1]) || 0.25
46
+ rUnit = match[2] || ""
47
+ } else {
48
+ rValue = parseFloat(r) || 0.25
49
+ }
50
+ } else if (typeof r === "number" && isValidNumber(r) && r > 0) {
51
+ rValue = r
52
+ }
53
+
54
+ // Ensure rValue is always valid
55
+ if (!isValidNumber(rValue) || rValue <= 0) {
56
+ rValue = 0.25
57
+ }
58
+
59
+ const rString = rValue.toString() + rUnit
60
+
61
+ // Handle mouse enter - increase radius
62
+ const handleMouseEnter = (e) => {
63
+ const circle = e.currentTarget
64
+ const currentR = circle.getAttribute("r")
65
+ const match = currentR.match(/([\d.]+)([a-z%]*)/i)
66
+ const currentRValue = match ? parseFloat(match[1]) : rValue
67
+ const currentRUnit = match ? match[2] : rUnit
68
+
69
+ // Store original if not already stored
70
+ if (!circle.getAttribute("data-original-r")) {
71
+ circle.setAttribute("data-original-r", currentR)
72
+ }
73
+
74
+ // Increase by 1.5x (0.25em -> 0.375em)
75
+ const newR = (currentRValue * 1.5).toString() + currentRUnit
76
+ circle.setAttribute("r", newR)
77
+
78
+ if (onMouseEnter) onMouseEnter(e)
79
+ }
80
+
81
+ // Handle mouse leave - restore original radius
82
+ const handleMouseLeave = (e) => {
83
+ const circle = e.currentTarget
84
+ const originalR = circle.getAttribute("data-original-r") || rString
85
+ circle.setAttribute("r", originalR)
86
+
87
+ if (onMouseLeave) onMouseLeave(e)
88
+ }
89
+
90
+ return svg`
91
+ <circle
92
+ cx=${cx}
93
+ cy=${cy}
94
+ r=${rString}
95
+ fill=${fill}
96
+ stroke=${stroke}
97
+ stroke-width=${strokeWidth}
98
+ class=${className}
99
+ data-original-r=${rString}
100
+ @mouseenter=${handleMouseEnter}
101
+ @mouseleave=${handleMouseLeave}
102
+ />
103
+ `
104
+ }
@@ -0,0 +1,46 @@
1
+ import { svg } from "@inglorious/web"
2
+
3
+ /**
4
+ * Renders a single rectangle (primitive shape)
5
+ * Similar to Recharts Rectangle component
6
+ *
7
+ * @param {Object} params
8
+ * @param {number} params.x - X position
9
+ * @param {number} params.y - Y position
10
+ * @param {number} params.width - Width
11
+ * @param {number} params.height - Height
12
+ * @param {string} [params.fill] - Fill color
13
+ * @param {string} [params.className] - CSS class
14
+ * @param {number|string} [params.rx] - Border radius X
15
+ * @param {number|string} [params.ry] - Border radius Y
16
+ * @param {Function} [params.onMouseEnter] - Mouse enter handler
17
+ * @param {Function} [params.onMouseLeave] - Mouse leave handler
18
+ * @returns {import('lit-html').TemplateResult}
19
+ */
20
+ export function renderRectangle({
21
+ x,
22
+ y,
23
+ width,
24
+ height,
25
+ fill,
26
+ className = "iw-chart-rectangle",
27
+ rx = "0.25em",
28
+ ry = "0.25em",
29
+ onMouseEnter,
30
+ onMouseLeave,
31
+ }) {
32
+ return svg`
33
+ <rect
34
+ x=${x}
35
+ y=${y}
36
+ width=${width}
37
+ height=${height}
38
+ fill=${fill}
39
+ class=${className}
40
+ rx=${rx}
41
+ ry=${ry}
42
+ @mouseenter=${onMouseEnter}
43
+ @mouseleave=${onMouseLeave}
44
+ />
45
+ `
46
+ }
@@ -0,0 +1,58 @@
1
+ /* eslint-disable no-magic-numbers */
2
+
3
+ import { svg } from "@inglorious/web"
4
+
5
+ import { generateArcPath } from "../utils/paths.js"
6
+
7
+ /**
8
+ * Renders a single sector (pie slice) - primitive shape
9
+ * Similar to Recharts Sector component
10
+ *
11
+ * @param {Object} params
12
+ * @param {number} params.innerRadius - Inner radius
13
+ * @param {number} params.outerRadius - Outer radius
14
+ * @param {number} params.startAngle - Start angle in radians
15
+ * @param {number} params.endAngle - End angle in radians
16
+ * @param {number} params.centerX - Center X
17
+ * @param {number} params.centerY - Center Y
18
+ * @param {string} [params.fill] - Fill color
19
+ * @param {string} [params.className] - CSS class
20
+ * @param {number} [params.cornerRadius] - Corner radius for rounded edges
21
+ * @param {Function} [params.onMouseEnter] - Mouse enter handler
22
+ * @param {Function} [params.onMouseLeave] - Mouse leave handler
23
+ * @param {number|string} [params.dataIndex] - Data index for tracking
24
+ * @returns {import('lit-html').TemplateResult}
25
+ */
26
+ export function renderSector({
27
+ innerRadius,
28
+ outerRadius,
29
+ startAngle,
30
+ endAngle,
31
+ centerX,
32
+ centerY,
33
+ fill,
34
+ className = "iw-chart-sector",
35
+ cornerRadius = 0,
36
+ onMouseEnter,
37
+ onMouseLeave,
38
+ dataIndex,
39
+ }) {
40
+ return svg`
41
+ <g transform="translate(${centerX}, ${centerY})">
42
+ <path
43
+ d=${generateArcPath(
44
+ innerRadius,
45
+ outerRadius,
46
+ startAngle,
47
+ endAngle,
48
+ cornerRadius,
49
+ )}
50
+ fill=${fill}
51
+ class=${className}
52
+ data-slice-index=${dataIndex}
53
+ @mouseenter=${onMouseEnter}
54
+ @mouseleave=${onMouseLeave}
55
+ />
56
+ </g>
57
+ `
58
+ }
@@ -0,0 +1,25 @@
1
+ import { svg } from "@inglorious/web"
2
+
3
+ /**
4
+ * @typedef {import('../types/charts').ChartEntity} ChartEntity
5
+ * @typedef {import('@inglorious/web').Api} Api
6
+ * @typedef {import('lit-html').TemplateResult} TemplateResult
7
+ */
8
+
9
+ /**
10
+ * Renders the chart component.
11
+ * @param {ChartEntity} entity
12
+ * @param {Api} api
13
+ * @returns {TemplateResult}
14
+ */
15
+ export function render(entity, api) {
16
+ const chart = api.getType(entity.type)
17
+ if (!chart) {
18
+ return svg`<text x="50%" y="50%" text-anchor="middle" fill="#999">Unknown chart type</text>`
19
+ }
20
+ const renderType = chart.render
21
+ if (!renderType) {
22
+ return svg`<text x="50%" y="50%" text-anchor="middle" fill="#999">Chart renderer not found</text>`
23
+ }
24
+ return renderType(entity, api)
25
+ }
package/src/theme.css ADDED
@@ -0,0 +1,90 @@
1
+ .iw-chart {
2
+ .iw-chart-svg {
3
+ background-color: white;
4
+ border-radius: 0.5em;
5
+ }
6
+
7
+ .iw-chart-svg {
8
+ isolation: isolate;
9
+ }
10
+
11
+ /* Generic hover styles for lines and curves */
12
+ .iw-chart-line:hover,
13
+ .iw-chart-curve:hover {
14
+ stroke-width: 0.21875em;
15
+ }
16
+
17
+ .iw-chart-point {
18
+ &:hover {
19
+ r: 0.375em;
20
+ filter: brightness(1.2);
21
+ }
22
+ }
23
+
24
+ .iw-chart-bar,
25
+ .iw-chart-bar-rectangle {
26
+ &:hover {
27
+ opacity: 0.9;
28
+ filter: brightness(1.1);
29
+ }
30
+ }
31
+
32
+ .iw-chart-area {
33
+ &:hover {
34
+ opacity: 0.9;
35
+ filter: brightness(1.1);
36
+ }
37
+ }
38
+
39
+ .iw-chart-pie-slice,
40
+ .iw-chart-donut-slice {
41
+ &:hover {
42
+ opacity: 0.9;
43
+ filter: brightness(1.15);
44
+ }
45
+ }
46
+
47
+ .iw-chart-modal {
48
+ background: white;
49
+ border: 1px solid #ccc;
50
+ border-radius: 0.5em;
51
+ padding: 0.75em;
52
+ box-shadow: 0 0.125em 0.5em rgba(0, 0, 0, 0.15);
53
+ min-width: 8.75em;
54
+ font-size: 0.8125em;
55
+
56
+ .iw-chart-modal-header {
57
+ gap: 0.5em;
58
+ margin-bottom: 0.5em;
59
+ padding-bottom: 0.5em;
60
+ border-bottom: 1px solid #eee;
61
+ }
62
+
63
+ .iw-chart-modal-color {
64
+ width: 0.75em;
65
+ height: 0.75em;
66
+ border-radius: 0.125em;
67
+ }
68
+
69
+ .iw-chart-modal-label {
70
+ font-weight: 600;
71
+ color: #333;
72
+ font-size: 0.8125em;
73
+ }
74
+
75
+ .iw-chart-modal-body {
76
+ gap: 0.25em;
77
+ }
78
+
79
+ .iw-chart-modal-value {
80
+ font-size: 1em;
81
+ font-weight: 700;
82
+ color: #333;
83
+ }
84
+
85
+ .iw-chart-modal-percentage {
86
+ font-size: 0.75em;
87
+ color: #999;
88
+ }
89
+ }
90
+ }