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