@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,264 @@
1
+ /* eslint-disable no-magic-numbers */
2
+ import { svg } from "@inglorious/web"
3
+
4
+ import { isValidNumber } from "../utils/data-utils.js"
5
+ import { createXScale } from "../utils/scales.js"
6
+
7
+ /**
8
+ * Brush Component - allows zooming and panning on cartesian charts
9
+ * Similar to Recharts Brush component
10
+ *
11
+ * @param {any} entity - Chart entity
12
+ * @param {Object} props
13
+ * @param {import('d3-scale').ScaleLinear|import('d3-scale').ScaleTime|import('d3-scale').ScaleBand} props.xScale - X scale
14
+ * @param {number} props.width - Chart width
15
+ * @param {number} props.height - Chart height
16
+ * @param {Object} props.padding - Chart padding
17
+ * @param {number} [props.height=30] - Brush height (default 30)
18
+ * @param {string} [props.dataKey] - Data key for X axis
19
+ * @param {any} api - Web API instance
20
+ * @returns {import('lit-html').TemplateResult}
21
+ */
22
+ export function renderBrush(entity, props, api) {
23
+ // Note: xScale from props is not used - we create our own brushXScale
24
+ // to ensure it always represents the full unfiltered data
25
+ const { width, height, padding, brushHeight = 30 } = props
26
+
27
+ // CRITICAL: Use original entity data for brush calculations
28
+ // In Config mode, entity may have filtered data, but brush needs original data
29
+ // The createBrushComponent already passes ctx.fullEntity which has original data
30
+ // But we need to ensure entity.brush is on the correct entity (the one with original data)
31
+ // For brush state management, we need to use the entity that has the brush state
32
+ // This is typically the original entity, not the filtered one
33
+
34
+ // Initialize state: ensures we have a valid selection range on first render
35
+ if (!entity.brush) {
36
+ entity.brush = {
37
+ startIndex: 0,
38
+ endIndex: entity.data ? entity.data.length - 1 : 0,
39
+ enabled: true,
40
+ }
41
+ }
42
+
43
+ // Ensure brush.enabled is true if brush exists
44
+ if (entity.brush && entity.brush.enabled === undefined) {
45
+ entity.brush.enabled = true
46
+ }
47
+
48
+ // Ensure startIndex and endIndex are defined
49
+ if (
50
+ entity.brush.startIndex === undefined ||
51
+ entity.brush.endIndex === undefined
52
+ ) {
53
+ entity.brush.startIndex = 0
54
+ entity.brush.endIndex = entity.data ? entity.data.length - 1 : 0
55
+ }
56
+
57
+ if (!entity.brush.enabled || !entity.data || entity.data.length === 0) {
58
+ return svg``
59
+ }
60
+
61
+ // Use entity.data directly - this should be original data from ctx.fullEntity
62
+ const brushData = entity.data
63
+ const brushAreaWidth = width - padding.left - padding.right
64
+ const brushAreaX = padding.left
65
+
66
+ // Refined positioning: ensure it stays below the main chart area
67
+ // Position brush at the bottom of the SVG, just below the main chart
68
+ // The main chart ends at `height`, so brush should start right after it
69
+ // Use height (chart height) as the base, not totalHeight
70
+ const brushY = height
71
+
72
+ // CRITICAL: Create a "clean" fixed scale for the Brush based on TOTAL data
73
+ // The xScale from props may have a filtered domain (zoom applied)
74
+ // The Brush always needs a scale that represents the full unfiltered data
75
+ // This prevents the Brush from "jumping" when the chart is zoomed
76
+ // Always use index-based scale for brush (simpler and more reliable)
77
+ const brushXScale = createXScale(brushData, width, padding)
78
+ // IMPORTANT: Force domain to always be the full index range [0, length-1]
79
+ // This ensures the Brush scale is completely independent of the chart's zoom
80
+ brushXScale.domain([0, brushData.length - 1])
81
+
82
+ /** Maps data index to pixel X coordinate */
83
+ // Simplified: since brushXScale.domain is always [0, length-1], we can directly use the index
84
+ const getXPosition = (index) => {
85
+ const safeIndex = Math.max(0, Math.min(index, brushData.length - 1))
86
+ // brushXScale is always linear with domain [0, length-1], so we can use index directly
87
+ return brushXScale(safeIndex)
88
+ }
89
+
90
+ const startX = getXPosition(entity.brush.startIndex)
91
+ const endX = getXPosition(entity.brush.endIndex)
92
+
93
+ if (!isValidNumber(startX) || !isValidNumber(endX)) {
94
+ return svg``
95
+ }
96
+
97
+ // Visual width of the selection area
98
+ const brushWidth = endX - startX
99
+
100
+ const handleMouseDown = (e, action) => {
101
+ e.preventDefault()
102
+ e.stopPropagation()
103
+
104
+ const svgElement = e.currentTarget.closest("svg")
105
+ if (!svgElement) return
106
+
107
+ const svgRect = svgElement.getBoundingClientRect()
108
+ const startMouseX = e.clientX - svgRect.left
109
+
110
+ // Snapshots: Capture current state at the start of interaction to prevent "drifting"
111
+ const initialStartIndex = entity.brush.startIndex
112
+ const initialEndIndex = entity.brush.endIndex
113
+ const selectionSize = initialEndIndex - initialStartIndex
114
+ const totalIndices = brushData.length - 1
115
+
116
+ const handleMouseMove = (moveEvent) => {
117
+ const currentMouseX = moveEvent.clientX - svgRect.left
118
+ // Important: deltaX is the mouse movement in pixels since the start of drag
119
+ const deltaX = currentMouseX - startMouseX
120
+
121
+ if (action === "pan") {
122
+ // 1. Calculate how much the mouse moved in pixels
123
+ // 2. Transform pixel movement into "index delta" using the real proportion
124
+ // Uses percentage of displacement relative to the total Brush area
125
+ // This is mathematically more stable than pixelsPerIndex
126
+ const indexDelta = Math.round((deltaX / brushAreaWidth) * totalIndices)
127
+
128
+ let nextStart = initialStartIndex + indexDelta
129
+ let nextEnd = initialEndIndex + indexDelta
130
+
131
+ // 3. Clamping (lock the limits)
132
+ if (nextStart < 0) {
133
+ nextStart = 0
134
+ nextEnd = selectionSize
135
+ }
136
+ if (nextEnd > totalIndices) {
137
+ nextEnd = totalIndices
138
+ nextStart = totalIndices - selectionSize
139
+ }
140
+
141
+ // Only notify if indices actually changed to optimize rendering
142
+ if (
143
+ nextStart !== entity.brush.startIndex ||
144
+ nextEnd !== entity.brush.endIndex
145
+ ) {
146
+ entity.brush.startIndex = nextStart
147
+ entity.brush.endIndex = nextEnd
148
+ api.notify(`#${entity.id}:update`)
149
+ }
150
+ } else if (action === "resize-left" || action === "resize-right") {
151
+ // For resize, we use the same proportion logic as pan
152
+ // This avoids index "jumps" when the user clicks on the handle
153
+ // Calculates the movement delta in indices and applies it to the initial index
154
+ const indexDelta = Math.round((deltaX / brushAreaWidth) * totalIndices)
155
+
156
+ if (action === "resize-left") {
157
+ // Keep endIndex fixed and move only startIndex
158
+ let nextStart = initialStartIndex + indexDelta
159
+
160
+ // Clamping: cannot be less than 0 nor greater than endIndex
161
+ if (nextStart < 0) {
162
+ nextStart = 0
163
+ } else if (nextStart >= initialEndIndex) {
164
+ nextStart = initialEndIndex - 1
165
+ }
166
+
167
+ if (nextStart !== entity.brush.startIndex) {
168
+ entity.brush.startIndex = nextStart
169
+ api.notify(`#${entity.id}:update`)
170
+ }
171
+ } else {
172
+ // resize-right: keep startIndex fixed and move only endIndex
173
+ let nextEnd = initialEndIndex + indexDelta
174
+
175
+ // Clamping: cannot be greater than totalIndices nor less than startIndex
176
+ if (nextEnd > totalIndices) {
177
+ nextEnd = totalIndices
178
+ } else if (nextEnd <= initialStartIndex) {
179
+ nextEnd = initialStartIndex + 1
180
+ }
181
+
182
+ if (nextEnd !== entity.brush.endIndex) {
183
+ entity.brush.endIndex = nextEnd
184
+ api.notify(`#${entity.id}:update`)
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ const handleMouseUp = () => {
191
+ document.removeEventListener("mousemove", handleMouseMove)
192
+ document.removeEventListener("mouseup", handleMouseUp)
193
+ }
194
+ document.addEventListener("mousemove", handleMouseMove)
195
+ document.addEventListener("mouseup", handleMouseUp)
196
+ }
197
+
198
+ const result = svg`
199
+ <g class="iw-chart-brush" transform="translate(0, ${brushY})">
200
+ <rect x=${brushAreaX} y=0 width=${brushAreaWidth} height=${brushHeight} fill="#f5f5f5" stroke="#ddd" />
201
+
202
+ <g class="iw-chart-brush-preview" style="pointer-events: none;">
203
+ <path
204
+ d="${brushData
205
+ .map((d, i) => {
206
+ const x = getXPosition(i)
207
+ const value = d.value ?? d.y ?? 0
208
+ const maxValue =
209
+ Math.max(...brushData.map((dd) => dd.value ?? dd.y ?? 0)) || 1
210
+ const y = brushHeight - 2 - (value / maxValue) * (brushHeight - 4)
211
+ return `M${x},${brushHeight - 2} L${x},${y}`
212
+ })
213
+ .join(" ")}"
214
+ stroke="#8884d8" stroke-width="1" opacity="0.3"
215
+ />
216
+ </g>
217
+
218
+ <rect x=${startX} y=2 width=${brushWidth} height=${brushHeight - 4} fill="#8884d8" fill-opacity="0.3" stroke="#8884d8"
219
+ style="cursor: move;" @mousedown=${(e) => handleMouseDown(e, "pan")} />
220
+
221
+ <rect x=${startX - 4} y=0 width=8 height=${brushHeight} fill="#8884d8" stroke="#fff"
222
+ style="cursor: ew-resize;" @mousedown=${(e) => handleMouseDown(e, "resize-left")} />
223
+ <rect x=${endX - 4} y=0 width=8 height=${brushHeight} fill="#8884d8" stroke="#fff"
224
+ style="cursor: ew-resize;" @mousedown=${(e) => handleMouseDown(e, "resize-right")} />
225
+ </g>`
226
+
227
+ // Add identification flag to the result object
228
+ result.isBrush = true
229
+ return result
230
+ }
231
+
232
+ /**
233
+ * Creates the Brush component function
234
+ * * @param {Object} defaultConfig
235
+ * @returns {Function}
236
+ */
237
+ export function createBrushComponent(defaultConfig = {}) {
238
+ return (entity, props, api) => {
239
+ const brushFn = (ctx) => {
240
+ const entityFromContext = ctx.fullEntity || ctx.entity || entity
241
+ const config = { ...defaultConfig, ...(props.config || {}) }
242
+
243
+ const result = renderBrush(
244
+ entityFromContext,
245
+ {
246
+ xScale: ctx.xScale,
247
+ ...ctx.dimensions,
248
+ totalHeight: ctx.totalHeight,
249
+ dataKey: config.dataKey || entityFromContext.dataKey || "name",
250
+ brushHeight: config.height || 30,
251
+ },
252
+ api,
253
+ )
254
+
255
+ // Ensure the returned TemplateResult also carries the flag
256
+ if (result) result.isBrush = true
257
+ return result
258
+ }
259
+
260
+ // Crucial for identification in Config Style mode
261
+ brushFn.isBrush = true
262
+ return brushFn
263
+ }
264
+ }
@@ -0,0 +1,33 @@
1
+ import { svg } from "@inglorious/web"
2
+
3
+ /**
4
+ * Renders an empty state message when there's no data
5
+ * @param {any} entity
6
+ * @param {Object} props
7
+ * @param {number} props.width - Chart width
8
+ * @param {number} props.height - Chart height
9
+ * @param {string} [props.message="No data"] - Message to display
10
+ * @param {any} api
11
+ * @returns {import('lit-html').TemplateResult}
12
+ */
13
+ // eslint-disable-next-line no-unused-vars
14
+ export function renderEmptyState(entity, props, api) {
15
+ const { width, height, message = "No data" } = props
16
+ return svg`
17
+ <svg
18
+ width=${width}
19
+ height=${height}
20
+ viewBox="0 0 ${width} ${height}"
21
+ >
22
+ <text
23
+ x="50%"
24
+ y="50%"
25
+ text-anchor="middle"
26
+ fill="#999"
27
+ font-size="0.875em"
28
+ >
29
+ ${message}
30
+ </text>
31
+ </svg>
32
+ `
33
+ }
@@ -0,0 +1,81 @@
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 { renderEmptyState } from "./empty-state.js"
8
+
9
+ describe("renderEmptyState", () => {
10
+ let entity
11
+ let props
12
+ let api
13
+
14
+ beforeEach(() => {
15
+ entity = {
16
+ id: "test-chart",
17
+ type: "line",
18
+ data: [],
19
+ width: 800,
20
+ height: 400,
21
+ }
22
+
23
+ props = {
24
+ message: "No data available",
25
+ }
26
+
27
+ api = {
28
+ notify: vi.fn(),
29
+ }
30
+ })
31
+
32
+ describe("basic rendering", () => {
33
+ it("should render empty state message", () => {
34
+ props.width = 800
35
+ props.height = 400
36
+
37
+ const result = renderEmptyState(entity, props, api)
38
+ const container = document.createElement("div")
39
+ render(result, container)
40
+
41
+ const svg = container.querySelector("svg")
42
+ expect(svg).toBeTruthy()
43
+
44
+ const text = container.querySelector("text")
45
+ expect(text).toBeTruthy()
46
+ expect(text.textContent).toContain("No data available")
47
+ })
48
+
49
+ it("should use default message when not provided", () => {
50
+ props.width = 800
51
+ props.height = 400
52
+ delete props.message
53
+
54
+ const result = renderEmptyState(entity, props, api)
55
+ const container = document.createElement("div")
56
+ render(result, container)
57
+
58
+ const svg = container.querySelector("svg")
59
+ expect(svg).toBeTruthy()
60
+
61
+ const text = container.querySelector("text")
62
+ expect(text).toBeTruthy()
63
+ expect(text.textContent).toBeTruthy()
64
+ })
65
+ })
66
+
67
+ describe("with custom message", () => {
68
+ it("should render custom message", () => {
69
+ props.width = 800
70
+ props.height = 400
71
+ props.message = "Custom empty message"
72
+
73
+ const result = renderEmptyState(entity, props, api)
74
+ const container = document.createElement("div")
75
+ render(result, container)
76
+
77
+ const text = container.querySelector("text")
78
+ expect(text.textContent).toContain("Custom empty message")
79
+ })
80
+ })
81
+ })
@@ -0,0 +1,123 @@
1
+ /* eslint-disable no-magic-numbers */
2
+
3
+ import { repeat, svg } from "@inglorious/web"
4
+
5
+ import { isValidNumber } from "../utils/data-utils.js"
6
+ import { calculateXTicks } from "../utils/scales.js"
7
+
8
+ /**
9
+ * Grid Component - renders independent grid
10
+ * Receives scales from context, does not decide layout
11
+ *
12
+ * @param {any} entity - Chart entity with data
13
+ * @param {Object} props
14
+ * @param {import('d3-scale').ScaleBand|import('d3-scale').ScaleLinear|import('d3-scale').ScaleTime} props.xScale
15
+ * @param {import('d3-scale').ScaleLinear} props.yScale
16
+ * @param {number} props.width
17
+ * @param {number} props.height
18
+ * @param {Object} props.padding
19
+ * @param {Array} props.customYTicks
20
+ * @param {any} api
21
+ * @returns {import('lit-html').TemplateResult}
22
+ */
23
+ // eslint-disable-next-line no-unused-vars
24
+ export function renderGrid(entity, props, api) {
25
+ const { xScale, yScale, width, height, padding, customYTicks } = props
26
+ // Use entity.data if available, otherwise fallback to scale ticks
27
+ const data = entity?.data
28
+
29
+ // For band scales (bar charts), limit ticks to match X-axis behavior
30
+ let xTicks
31
+ if (xScale.bandwidth) {
32
+ const allCategories = xScale.domain()
33
+ // Apply same limiting logic as renderXAxis to match grid with axis ticks
34
+ if (allCategories.length > 20) {
35
+ // Calculate optimal number of ticks based on available width
36
+ // Estimate ~60px per label to avoid overlap (same as X-axis)
37
+ const availableWidth =
38
+ (width || 800) - (padding?.left || 0) - (padding?.right || 0)
39
+ const maxTicks = Math.max(5, Math.floor(availableWidth / 60))
40
+ const step = Math.ceil(allCategories.length / maxTicks)
41
+ xTicks = allCategories.filter((_, i) => i % step === 0)
42
+ } else {
43
+ xTicks = allCategories
44
+ }
45
+ } else {
46
+ // For linear/time scales, use calculateXTicks (same as X-axis)
47
+ xTicks =
48
+ data && data.length > 0
49
+ ? calculateXTicks(data, xScale)
50
+ : xScale.ticks
51
+ ? xScale.ticks(5)
52
+ : xScale.domain()
53
+ }
54
+
55
+ // Use custom Y ticks if provided, otherwise use scale ticks
56
+ const yTicks =
57
+ customYTicks && Array.isArray(customYTicks) ? customYTicks : yScale.ticks(5)
58
+
59
+ return svg`
60
+ <g class="iw-chart-cartesian-grid">
61
+ <g class="iw-chart-cartesian-grid-horizontal">
62
+ ${repeat(
63
+ yTicks,
64
+ (t) => t,
65
+ (t) => {
66
+ const y = yScale(t)
67
+ return svg`
68
+ <line
69
+ stroke-dasharray="3 3"
70
+ stroke="#ccc"
71
+ fill="none"
72
+ x=${padding.left}
73
+ y=${padding.top}
74
+ width=${width - padding.left - padding.right}
75
+ height=${height - padding.top - padding.bottom}
76
+ x1=${padding.left}
77
+ y1=${y}
78
+ x2=${width - padding.right}
79
+ y2=${y}
80
+ />
81
+ `
82
+ },
83
+ )}
84
+ </g>
85
+ <g class="iw-chart-cartesian-grid-vertical">
86
+ ${repeat(
87
+ xTicks,
88
+ (t) => t,
89
+ (t) => {
90
+ // For band scales (bar charts), center the grid line in the middle of the band
91
+ // For linear/time scales, use the tick position directly
92
+ const x = xScale.bandwidth
93
+ ? xScale(t) + xScale.bandwidth() / 2
94
+ : xScale(t)
95
+ // Only render if x is a valid number within the range
96
+ if (
97
+ !isValidNumber(x) ||
98
+ x < padding.left ||
99
+ x > width - padding.right
100
+ ) {
101
+ return svg``
102
+ }
103
+ return svg`
104
+ <line
105
+ stroke-dasharray="3 3"
106
+ stroke="#ccc"
107
+ fill="none"
108
+ x=${padding.left}
109
+ y=${padding.top}
110
+ width=${width - padding.left - padding.right}
111
+ height=${height - padding.top - padding.bottom}
112
+ x1=${x}
113
+ y1=${padding.top}
114
+ x2=${x}
115
+ y2=${height - padding.bottom}
116
+ />
117
+ `
118
+ },
119
+ )}
120
+ </g>
121
+ </g>
122
+ `
123
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { render } from "@inglorious/web/test"
5
+ import { describe, expect, it } from "vitest"
6
+
7
+ import { renderGrid } from "./grid.js"
8
+
9
+ describe("renderGrid", () => {
10
+ const mockApi = {}
11
+ const mockEntity = {
12
+ id: "test",
13
+ data: [
14
+ { x: 0, y: 10 },
15
+ { x: 1, y: 20 },
16
+ { x: 2, y: 30 },
17
+ ],
18
+ }
19
+
20
+ const createMockScales = () => {
21
+ const linearScale = (domain, range) => {
22
+ const scale = (value) => {
23
+ const [d0, d1] = domain
24
+ const [r0, r1] = range
25
+ return ((value - d0) / (d1 - d0)) * (r1 - r0) + r0
26
+ }
27
+ scale.domain = () => domain
28
+ scale.range = () => range
29
+ scale.ticks = (count) => {
30
+ const step = (domain[1] - domain[0]) / count
31
+ return Array.from({ length: count + 1 }, (_, i) => domain[0] + step * i)
32
+ }
33
+ return scale
34
+ }
35
+
36
+ return {
37
+ xScale: linearScale([0, 2], [50, 750]),
38
+ yScale: linearScale([0, 30], [350, 50]),
39
+ }
40
+ }
41
+
42
+ it("should render grid with horizontal and vertical lines", () => {
43
+ const { xScale, yScale } = createMockScales()
44
+ const props = {
45
+ xScale,
46
+ yScale,
47
+ width: 800,
48
+ height: 400,
49
+ padding: { top: 20, right: 50, bottom: 30, left: 50 },
50
+ }
51
+
52
+ const template = renderGrid(mockEntity, props, mockApi)
53
+ const container = document.createElement("div")
54
+ render(template, container)
55
+
56
+ const horizontalLines = container.querySelectorAll(
57
+ ".iw-chart-cartesian-grid-horizontal line",
58
+ )
59
+ const verticalLines = container.querySelectorAll(
60
+ ".iw-chart-cartesian-grid-vertical line",
61
+ )
62
+
63
+ expect(horizontalLines.length).toBeGreaterThan(0)
64
+ expect(verticalLines.length).toBeGreaterThan(0)
65
+ })
66
+
67
+ it("should use custom Y ticks if provided", () => {
68
+ const { xScale, yScale } = createMockScales()
69
+ const props = {
70
+ xScale,
71
+ yScale,
72
+ width: 800,
73
+ height: 400,
74
+ padding: { top: 20, right: 50, bottom: 30, left: 50 },
75
+ customYTicks: [0, 10, 20, 30],
76
+ }
77
+
78
+ const template = renderGrid(mockEntity, props, mockApi)
79
+ const container = document.createElement("div")
80
+ render(template, container)
81
+
82
+ const horizontalLines = container.querySelectorAll(
83
+ ".iw-chart-cartesian-grid-horizontal line",
84
+ )
85
+
86
+ expect(horizontalLines.length).toBe(4)
87
+ })
88
+
89
+ it("should handle band scale for X axis", () => {
90
+ const bandScale = (domain, range) => {
91
+ const [r0, r1] = range
92
+ const bandwidth = (r1 - r0) / domain.length
93
+ const scale = (value) => {
94
+ const index = domain.indexOf(value)
95
+ return r0 + index * bandwidth
96
+ }
97
+ scale.domain = () => domain
98
+ scale.range = () => range
99
+ scale.bandwidth = () => bandwidth
100
+ return scale
101
+ }
102
+
103
+ const { yScale } = createMockScales()
104
+ const xScale = bandScale(["A", "B", "C"], [50, 750])
105
+ const props = {
106
+ xScale,
107
+ yScale,
108
+ width: 800,
109
+ height: 400,
110
+ padding: { top: 20, right: 50, bottom: 30, left: 50 },
111
+ }
112
+
113
+ const template = renderGrid(mockEntity, props, mockApi)
114
+ const container = document.createElement("div")
115
+ render(template, container)
116
+
117
+ const verticalLines = container.querySelectorAll(
118
+ ".iw-chart-cartesian-grid-vertical line",
119
+ )
120
+
121
+ expect(verticalLines.length).toBeGreaterThan(0)
122
+ })
123
+ })
@@ -0,0 +1,76 @@
1
+ /* eslint-disable no-magic-numbers */
2
+
3
+ import { repeat, svg } from "@inglorious/web"
4
+
5
+ /**
6
+ * Legend Component - renders independent legend
7
+ * Receives data and colors, does not decide layout
8
+ *
9
+ * @param {any} entity
10
+ * @param {Object} props
11
+ * @param {any[]} props.series
12
+ * @param {string[]} props.colors
13
+ * @param {number} props.width
14
+ * @param {Object} props.padding
15
+ * @param {any} api
16
+ * @returns {import('lit-html').TemplateResult}
17
+ */
18
+ // eslint-disable-next-line no-unused-vars
19
+ export function renderLegend(entity, props, api) {
20
+ const { series, colors, width, padding } = props
21
+ if (!series || series.length === 0) {
22
+ return svg``
23
+ }
24
+
25
+ const legendY = padding.top / 2
26
+ const squareSize = 12
27
+ const gap = 8
28
+ const itemGap = 40
29
+
30
+ const totalWidth = series.reduce((acc, s) => {
31
+ const label = s.name || s.label || `Series ${series.indexOf(s) + 1}`
32
+ return acc + squareSize + gap + label.length * 6 + itemGap
33
+ }, 0)
34
+
35
+ const startX = (width - totalWidth) / 2
36
+ let currentX = startX
37
+
38
+ return svg`
39
+ <g class="iw-chart-legend-wrapper">
40
+ ${repeat(
41
+ series,
42
+ (s, i) => i,
43
+ (s, i) => {
44
+ const color = s.color || colors[i % colors.length]
45
+ const label = s.name || s.label || `Series ${i + 1}`
46
+
47
+ const item = svg`
48
+ <g class="iw-chart-legend-item">
49
+ <rect
50
+ x=${currentX}
51
+ y=${legendY - squareSize / 2}
52
+ width=${squareSize}
53
+ height=${squareSize}
54
+ fill=${color}
55
+ rx="0.125em"
56
+ ry="0.125em"
57
+ />
58
+ <text
59
+ x=${currentX + squareSize + gap}
60
+ y=${legendY + 4}
61
+ text-anchor="start"
62
+ font-size="0.75em"
63
+ fill="#333"
64
+ class="iw-chart-legend-item-text"
65
+ >
66
+ ${label}
67
+ </text>
68
+ </g>
69
+ `
70
+ currentX += squareSize + gap + label.length * 6 + itemGap
71
+ return item
72
+ },
73
+ )}
74
+ </g>
75
+ `
76
+ }