@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,166 @@
1
+ /* eslint-disable no-magic-numbers */
2
+
3
+ import { parseDimension } from "./data-utils.js"
4
+ import { calculatePadding } from "./padding.js"
5
+ import { createScales } from "./scales.js"
6
+
7
+ /**
8
+ * Creates a shared context for composition mode charts.
9
+ * Calculates scales, dimensions, and provides a context object for child components.
10
+ *
11
+ * This function unifies the context creation logic for line, area, and bar charts.
12
+ * Follows the standard signature pattern: render<Sub>(entity, props, api)
13
+ *
14
+ * @param {any} entity - Chart entity with data
15
+ * @param {Object} props - Configuration options
16
+ * @param {number|string} [props.width] - Chart width (overrides entity.width)
17
+ * @param {number|string} [props.height] - Chart height (overrides entity.height)
18
+ * @param {Object} [props.padding] - Chart padding (overrides calculated padding)
19
+ * @param {Set<string>|string[]|null} [props.usedDataKeys] - Data keys to use for Y-scale calculation (composition mode)
20
+ * @param {string} [props.chartType="line"] - Chart type ("line", "area", "bar")
21
+ * @param {any} api - Web API instance (not used, but follows standard signature)
22
+ * @returns {Object} Context object with xScale, yScale, dimensions, and entity
23
+ */
24
+ // eslint-disable-next-line no-unused-vars
25
+ export function createSharedContext(entity, props = {}, api) {
26
+ if (!entity || !entity.data) {
27
+ throw new Error("Entity and entity.data are required")
28
+ }
29
+
30
+ // Extract props with defaults
31
+ const {
32
+ width: configWidth,
33
+ height: configHeight,
34
+ padding: configPadding,
35
+ usedDataKeys: configDataKeys,
36
+ chartType = "line",
37
+ stacked = false,
38
+ } = props
39
+
40
+ // Calculate dimensions
41
+ const width =
42
+ parseDimension(configWidth) || parseDimension(entity.width) || 800
43
+ const height =
44
+ parseDimension(configHeight) || parseDimension(entity.height) || 400
45
+ const padding = configPadding || calculatePadding(width, height)
46
+
47
+ // Convert dataKeys array to Set if needed
48
+ const usedDataKeys =
49
+ configDataKeys instanceof Set
50
+ ? configDataKeys
51
+ : Array.isArray(configDataKeys)
52
+ ? new Set(configDataKeys)
53
+ : null
54
+
55
+ // CRITICAL: Y scale should ALWAYS use original (unfiltered) data
56
+ // The brush zoom should only affect X-axis, not Y-axis
57
+ // This ensures consistent Y-axis scale regardless of brush selection
58
+ // Use original entity data for Y scale calculation (not filtered)
59
+ const dataForYExtent = entity.data
60
+
61
+ // Calculate maximum value for Y-axis scaling (global max across ALL data)
62
+ const maxValue = getExtent(dataForYExtent, usedDataKeys, stacked)
63
+
64
+ // Create data structure for scale calculation
65
+ // For X scale: use original data (not filtered) to preserve original x/date values
66
+ // For Y scale: use maxValue calculated from ALL data (not filtered)
67
+ // This ensures xScale has correct domain based on original data, and yScale has [0, maxValue]
68
+ // The brush zoom will be applied later by adjusting xScale.domain([startIndex, endIndex])
69
+ // Note: dataForYScale is just a placeholder structure for createYScale
70
+ const dataForYScale = dataForYExtent.map((d, i) => ({
71
+ x: i,
72
+ y: maxValue,
73
+ }))
74
+
75
+ // Create entities with correct data for each scale
76
+ // X scale uses original data to preserve x/date values
77
+ const entityForXScale = {
78
+ ...entity,
79
+ data: entity.data, // Use original data for X scale
80
+ width,
81
+ height,
82
+ padding,
83
+ }
84
+
85
+ // Y scale uses transformed data with maxValue
86
+ const entityForYScale = {
87
+ ...entity,
88
+ data: dataForYScale,
89
+ width,
90
+ height,
91
+ padding,
92
+ }
93
+
94
+ // Create scales separately: X scale uses original data, Y scale uses transformed data
95
+ // For X scale, we need to ensure it uses original data even if brush is enabled
96
+ // So we temporarily disable brush filtering for X scale creation
97
+ const entityForXScaleNoBrush = {
98
+ ...entityForXScale,
99
+ brush: entityForXScale.brush
100
+ ? { ...entityForXScale.brush, enabled: false }
101
+ : undefined,
102
+ }
103
+ const { xScale } = createScales(entityForXScaleNoBrush, chartType)
104
+ const { yScale } = createScales(entityForYScale, chartType)
105
+
106
+ // Create context with the correct scales
107
+ const context = {
108
+ xScale,
109
+ yScale,
110
+ dimensions: { width, height, padding },
111
+ entity,
112
+ }
113
+
114
+ // Return enhanced context with original entity reference
115
+ return {
116
+ ...context,
117
+ dimensions: { width, height, padding },
118
+ entity, // Keep reference to original entity (not transformed data)
119
+ chartType, // Include chartType in context for lazy components
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Calculates the maximum value (extent) from entity data.
125
+ * If dataKeys are provided (composition mode), only considers those keys.
126
+ * Otherwise considers all numeric values (config-first mode).
127
+ *
128
+ * @param {any[]} data - Chart data array
129
+ * @param {Set<string>|null} usedDataKeys - Set of data keys to consider (composition mode)
130
+ * @returns {number} Maximum value found
131
+ */
132
+ function getExtent(data, usedDataKeys, stacked = false) {
133
+ if (!data || data.length === 0) return 0
134
+
135
+ const values = data.flatMap((d) => {
136
+ if (usedDataKeys && usedDataKeys.size > 0) {
137
+ // Composition mode: only use values from dataKeys used in lines/areas
138
+ // If stacked, use the sum across keys per datum to determine max Y.
139
+ const keys = Array.from(usedDataKeys)
140
+ if (stacked) {
141
+ const sum = keys.reduce((acc, dataKey) => {
142
+ const value = d[dataKey]
143
+ return acc + (typeof value === "number" ? value : 0)
144
+ }, 0)
145
+ return [sum].filter((v) => v > 0)
146
+ }
147
+
148
+ return keys
149
+ .map((dataKey) => {
150
+ const value = d[dataKey]
151
+ return typeof value === "number" ? value : 0
152
+ })
153
+ .filter((v) => v > 0)
154
+ } else {
155
+ // Config-first mode: use all numeric values (excluding name, x, date)
156
+ return Object.entries(d)
157
+ .filter(
158
+ ([key, value]) =>
159
+ !["name", "x", "date"].includes(key) && typeof value === "number",
160
+ )
161
+ .map(([, value]) => value)
162
+ }
163
+ })
164
+
165
+ return values.length > 0 ? Math.max(...values) : 0
166
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, expect, it } from "vitest"
5
+
6
+ import { createSharedContext } from "./shared-context.js"
7
+
8
+ describe("createSharedContext", () => {
9
+ const mockApi = {}
10
+
11
+ describe("basic functionality", () => {
12
+ it("should create context with default dimensions", () => {
13
+ const entity = {
14
+ id: "test",
15
+ type: "line",
16
+ data: [
17
+ { name: "Jan", value: 100 },
18
+ { name: "Feb", value: 200 },
19
+ ],
20
+ }
21
+
22
+ const context = createSharedContext(entity, {}, mockApi)
23
+
24
+ expect(context.xScale).toBeDefined()
25
+ expect(context.yScale).toBeDefined()
26
+ expect(context.dimensions.width).toBe(800)
27
+ expect(context.dimensions.height).toBe(400)
28
+ expect(context.dimensions.padding).toBeDefined()
29
+ expect(context.entity).toBe(entity)
30
+ })
31
+
32
+ it("should use entity dimensions if provided", () => {
33
+ const entity = {
34
+ id: "test",
35
+ type: "line",
36
+ data: [{ value: 100 }],
37
+ width: 1000,
38
+ height: 500,
39
+ }
40
+
41
+ const context = createSharedContext(entity, {}, mockApi)
42
+
43
+ expect(context.dimensions.width).toBe(1000)
44
+ expect(context.dimensions.height).toBe(500)
45
+ })
46
+
47
+ it("should override dimensions from props", () => {
48
+ const entity = {
49
+ id: "test",
50
+ type: "line",
51
+ data: [{ value: 100 }],
52
+ width: 800,
53
+ height: 400,
54
+ }
55
+
56
+ const context = createSharedContext(
57
+ entity,
58
+ { width: 1200, height: 600 },
59
+ mockApi,
60
+ )
61
+
62
+ expect(context.dimensions.width).toBe(1200)
63
+ expect(context.dimensions.height).toBe(600)
64
+ })
65
+
66
+ it("should throw error if entity is missing", () => {
67
+ expect(() => {
68
+ createSharedContext(null, {}, mockApi)
69
+ }).toThrow("Entity and entity.data are required")
70
+ })
71
+
72
+ it("should throw error if entity.data is missing", () => {
73
+ expect(() => {
74
+ createSharedContext({ id: "test" }, {}, mockApi)
75
+ }).toThrow("Entity and entity.data are required")
76
+ })
77
+ })
78
+
79
+ describe("Y-scale calculation", () => {
80
+ it("should calculate Y scale from all numeric values (config-first mode)", () => {
81
+ const entity = {
82
+ id: "test",
83
+ type: "line",
84
+ data: [
85
+ { name: "Jan", value: 100, other: 50 },
86
+ { name: "Feb", value: 200, other: 75 },
87
+ { name: "Mar", value: 150, other: 100 },
88
+ ],
89
+ }
90
+
91
+ const context = createSharedContext(entity, {}, mockApi)
92
+
93
+ // Y scale should include max of all numeric values (200 from value, 100 from other)
94
+ const domain = context.yScale.domain()
95
+ expect(domain[1]).toBeGreaterThanOrEqual(200)
96
+ })
97
+
98
+ it("should calculate Y scale from specific dataKeys (composition mode)", () => {
99
+ const entity = {
100
+ id: "test",
101
+ type: "line",
102
+ data: [
103
+ { name: "Jan", productA: 100, productB: 200 },
104
+ { name: "Feb", productA: 150, productB: 250 },
105
+ ],
106
+ }
107
+
108
+ const context = createSharedContext(
109
+ entity,
110
+ { usedDataKeys: new Set(["productA"]) },
111
+ mockApi,
112
+ )
113
+
114
+ // Y scale should only consider productA values (max 150)
115
+ const domain = context.yScale.domain()
116
+ expect(domain[1]).toBeGreaterThanOrEqual(150)
117
+ expect(domain[1]).toBeLessThan(200) // Should not include productB
118
+ })
119
+
120
+ it("should handle empty data", () => {
121
+ const entity = {
122
+ id: "test",
123
+ type: "line",
124
+ data: [],
125
+ }
126
+
127
+ const context = createSharedContext(entity, {}, mockApi)
128
+
129
+ expect(context.yScale).toBeDefined()
130
+ const domain = context.yScale.domain()
131
+ expect(domain[1]).toBeGreaterThanOrEqual(0)
132
+ })
133
+
134
+ it("should calculate Y scale from sum of dataKeys when stacked is true", () => {
135
+ const entity = {
136
+ id: "test",
137
+ type: "area",
138
+ data: [
139
+ { name: "Jan", uv: 4000, pv: 2400, amt: 2400 },
140
+ { name: "Feb", uv: 3000, pv: 1398, amt: 2210 },
141
+ ],
142
+ }
143
+
144
+ const context = createSharedContext(
145
+ entity,
146
+ {
147
+ usedDataKeys: ["uv", "pv", "amt"],
148
+ stacked: true,
149
+ chartType: "area",
150
+ },
151
+ mockApi,
152
+ )
153
+
154
+ const domain = context.yScale.domain()
155
+ // Jan sum is 8800, Feb sum is 6608
156
+ expect(domain[1]).toBeGreaterThanOrEqual(8800)
157
+ })
158
+ })
159
+
160
+ describe("chart types", () => {
161
+ it("should create context for line chart", () => {
162
+ const entity = {
163
+ id: "test",
164
+ type: "line",
165
+ data: [{ value: 100 }],
166
+ }
167
+
168
+ const context = createSharedContext(
169
+ entity,
170
+ { chartType: "line" },
171
+ mockApi,
172
+ )
173
+
174
+ expect(context.xScale).toBeDefined()
175
+ expect(context.yScale).toBeDefined()
176
+ })
177
+
178
+ it("should create context for area chart", () => {
179
+ const entity = {
180
+ id: "test",
181
+ type: "area",
182
+ data: [{ value: 100 }],
183
+ }
184
+
185
+ const context = createSharedContext(
186
+ entity,
187
+ { chartType: "area" },
188
+ mockApi,
189
+ )
190
+
191
+ expect(context.xScale).toBeDefined()
192
+ expect(context.yScale).toBeDefined()
193
+ })
194
+ })
195
+
196
+ describe("dataKeys handling", () => {
197
+ it("should accept dataKeys as Set", () => {
198
+ const entity = {
199
+ id: "test",
200
+ type: "line",
201
+ data: [
202
+ { name: "Jan", productA: 100, productB: 200 },
203
+ { name: "Feb", productA: 150, productB: 250 },
204
+ ],
205
+ }
206
+
207
+ const context = createSharedContext(
208
+ entity,
209
+ { usedDataKeys: new Set(["productA", "productB"]) },
210
+ mockApi,
211
+ )
212
+
213
+ expect(context.yScale).toBeDefined()
214
+ const domain = context.yScale.domain()
215
+ expect(domain[1]).toBeGreaterThanOrEqual(250)
216
+ })
217
+
218
+ it("should accept dataKeys as array", () => {
219
+ const entity = {
220
+ id: "test",
221
+ type: "line",
222
+ data: [
223
+ { name: "Jan", productA: 100, productB: 200 },
224
+ { name: "Feb", productA: 150, productB: 250 },
225
+ ],
226
+ }
227
+
228
+ const context = createSharedContext(
229
+ entity,
230
+ { usedDataKeys: ["productA"] },
231
+ mockApi,
232
+ )
233
+
234
+ expect(context.yScale).toBeDefined()
235
+ })
236
+ })
237
+ })
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Tooltip event handlers utilities
3
+ * Creates reusable onMouseEnter, onMouseLeave, and onMouseMove handlers for chart elements
4
+ */
5
+
6
+ // Tooltip offset from cursor position
7
+ const TOOLTIP_OFFSET = 15
8
+ // Estimated tooltip width (can be adjusted based on actual tooltip size)
9
+ const TOOLTIP_ESTIMATED_WIDTH = 140
10
+ // Estimated tooltip height
11
+ const TOOLTIP_ESTIMATED_HEIGHT = 60
12
+
13
+ /**
14
+ * Creates tooltip event handlers for chart elements
15
+ * @param {Object} params
16
+ * @param {any} params.entity - Chart entity
17
+ * @param {import('@inglorious/web').Api} params.api - Web API instance
18
+ * @param {Object} params.tooltipData - Tooltip data
19
+ * @param {string} params.tooltipData.label - Tooltip label
20
+ * @param {number} params.tooltipData.value - Tooltip value
21
+ * @param {string} params.tooltipData.color - Tooltip color
22
+ * @returns {{ onMouseEnter: Function, onMouseLeave: Function }}
23
+ */
24
+ export function createTooltipHandlers({ entity, api, tooltipData }) {
25
+ const onMouseEnter = (e) => {
26
+ if (!entity.showTooltip) return
27
+ const svgElement = e.currentTarget.closest("svg")
28
+ const svgRect = svgElement.getBoundingClientRect()
29
+ const containerElement =
30
+ svgElement.closest(".iw-chart") || svgElement.parentElement
31
+ const containerRect = containerElement.getBoundingClientRect()
32
+
33
+ const relativeX = e.clientX - svgRect.left
34
+ const relativeY = e.clientY - svgRect.top
35
+
36
+ const { x, y } = calculateTooltipPosition(
37
+ relativeX,
38
+ relativeY,
39
+ svgRect,
40
+ containerRect,
41
+ )
42
+
43
+ api.notify(`#${entity.id}:tooltipShow`, {
44
+ label: tooltipData.label,
45
+ value: tooltipData.value,
46
+ color: tooltipData.color,
47
+ x,
48
+ y,
49
+ })
50
+ }
51
+
52
+ const onMouseLeave = () => {
53
+ if (!entity.showTooltip) return
54
+ api.notify(`#${entity.id}:tooltipHide`)
55
+ }
56
+
57
+ return { onMouseEnter, onMouseLeave }
58
+ }
59
+
60
+ /**
61
+ * Creates a mouse move handler for tooltip positioning
62
+ * @param {Object} params
63
+ * @param {any} params.entity - Chart entity
64
+ * @param {import('@inglorious/web').Api} params.api - Web API instance
65
+ * @returns {Function} Mouse move event handler
66
+ */
67
+ export function createTooltipMoveHandler({ entity, api }) {
68
+ return (e) => {
69
+ if (!entity.tooltip) return
70
+ const svgElement = e.currentTarget
71
+ const svgRect = svgElement.getBoundingClientRect()
72
+ const containerElement =
73
+ svgElement.closest(".iw-chart") || svgElement.parentElement
74
+ const containerRect = containerElement.getBoundingClientRect()
75
+
76
+ const relativeX = e.clientX - svgRect.left
77
+ const relativeY = e.clientY - svgRect.top
78
+
79
+ const { x, y } = calculateTooltipPosition(
80
+ relativeX,
81
+ relativeY,
82
+ svgRect,
83
+ containerRect,
84
+ )
85
+
86
+ api.notify(`#${entity.id}:tooltipMove`, {
87
+ x,
88
+ y,
89
+ })
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Calculates tooltip position, adjusting if it would be cut off
95
+ * @param {number} x - X position relative to SVG
96
+ * @param {number} y - Y position relative to SVG
97
+ * @param {DOMRect} svgRect - SVG bounding rect
98
+ * @param {DOMRect} containerRect - Container bounding rect (for overflow check)
99
+ * @returns {{ x: number, y: number }}
100
+ */
101
+ function calculateTooltipPosition(x, y, svgRect, containerRect) {
102
+ let tooltipX = x + TOOLTIP_OFFSET
103
+ let tooltipY = y - TOOLTIP_OFFSET
104
+
105
+ // Check if tooltip would be cut off on the right side
106
+ const tooltipRightEdge = svgRect.left + tooltipX + TOOLTIP_ESTIMATED_WIDTH
107
+ const containerRightEdge = containerRect.right
108
+
109
+ if (tooltipRightEdge > containerRightEdge) {
110
+ // Position tooltip to the left of cursor instead
111
+ tooltipX = x - TOOLTIP_ESTIMATED_WIDTH - TOOLTIP_OFFSET
112
+ // Ensure it doesn't go negative
113
+ const MIN_X_POSITION = 0
114
+ if (tooltipX < MIN_X_POSITION) {
115
+ tooltipX = TOOLTIP_OFFSET
116
+ }
117
+ }
118
+
119
+ // Check if tooltip would be cut off on the bottom
120
+ const tooltipBottomEdge = svgRect.top + tooltipY + TOOLTIP_ESTIMATED_HEIGHT
121
+ const containerBottomEdge = containerRect.bottom
122
+
123
+ if (tooltipBottomEdge > containerBottomEdge) {
124
+ // Position tooltip above cursor
125
+ tooltipY = y - TOOLTIP_ESTIMATED_HEIGHT - TOOLTIP_OFFSET
126
+ }
127
+
128
+ return { x: tooltipX, y: tooltipY }
129
+ }