@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,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
|
+
}
|