@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,279 @@
|
|
|
1
|
+
import {
|
|
2
|
+
arc,
|
|
3
|
+
area,
|
|
4
|
+
curveLinear,
|
|
5
|
+
curveMonotoneX,
|
|
6
|
+
line,
|
|
7
|
+
pie,
|
|
8
|
+
stack,
|
|
9
|
+
} from "d3-shape"
|
|
10
|
+
|
|
11
|
+
import { getDataPointX, getDataPointY } from "./data-utils.js"
|
|
12
|
+
|
|
13
|
+
// Fallback Y value when scale returns NaN
|
|
14
|
+
const FALLBACK_Y_VALUE = 0
|
|
15
|
+
|
|
16
|
+
export function generateLinePath(data, xScale, yScale, curveType = "linear") {
|
|
17
|
+
const curve = curveType === "monotone" ? curveMonotoneX : curveLinear
|
|
18
|
+
const lineGenerator = line()
|
|
19
|
+
.x((d, i) => {
|
|
20
|
+
// Always pass index as fallback to ensure we have a valid x value
|
|
21
|
+
const xVal = getDataPointX(d, i)
|
|
22
|
+
const scaled = xScale(xVal)
|
|
23
|
+
if (isNaN(scaled)) {
|
|
24
|
+
console.warn("[generateLinePath] NaN x value:", {
|
|
25
|
+
d,
|
|
26
|
+
i,
|
|
27
|
+
xVal,
|
|
28
|
+
scaled,
|
|
29
|
+
xScaleDomain: xScale.domain(),
|
|
30
|
+
xScaleRange: xScale.range(),
|
|
31
|
+
})
|
|
32
|
+
// Fallback to index if scaled is NaN
|
|
33
|
+
return xScale(i)
|
|
34
|
+
}
|
|
35
|
+
return scaled
|
|
36
|
+
})
|
|
37
|
+
.y((d) => {
|
|
38
|
+
const yVal = getDataPointY(d)
|
|
39
|
+
const scaled = yScale(yVal)
|
|
40
|
+
if (isNaN(scaled)) {
|
|
41
|
+
console.warn("[generateLinePath] NaN y value:", {
|
|
42
|
+
d,
|
|
43
|
+
yVal,
|
|
44
|
+
scaled,
|
|
45
|
+
yScaleDomain: yScale.domain(),
|
|
46
|
+
yScaleRange: yScale.range(),
|
|
47
|
+
})
|
|
48
|
+
// Fallback to 0 if scaled is NaN
|
|
49
|
+
return yScale(FALLBACK_Y_VALUE)
|
|
50
|
+
}
|
|
51
|
+
return scaled
|
|
52
|
+
})
|
|
53
|
+
.curve(curve)
|
|
54
|
+
return lineGenerator(data)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* eslint-disable no-magic-numbers */
|
|
58
|
+
export function generateAreaPath(
|
|
59
|
+
data,
|
|
60
|
+
xScale,
|
|
61
|
+
yScale,
|
|
62
|
+
baseValue = 0,
|
|
63
|
+
curveType = "linear",
|
|
64
|
+
) {
|
|
65
|
+
const curve = curveType === "monotone" ? curveMonotoneX : curveLinear
|
|
66
|
+
const areaGenerator = area()
|
|
67
|
+
.x((d, i) => {
|
|
68
|
+
// Always pass index as fallback to ensure we have a valid x value
|
|
69
|
+
const xVal = getDataPointX(d, i)
|
|
70
|
+
const scaled = xScale(xVal)
|
|
71
|
+
if (isNaN(scaled)) {
|
|
72
|
+
// Fallback to index if scaled is NaN
|
|
73
|
+
return xScale(i)
|
|
74
|
+
}
|
|
75
|
+
return scaled
|
|
76
|
+
})
|
|
77
|
+
.y0(() => yScale(baseValue))
|
|
78
|
+
.y1((d) => {
|
|
79
|
+
const yVal = getDataPointY(d)
|
|
80
|
+
const scaled = yScale(yVal)
|
|
81
|
+
if (isNaN(scaled)) {
|
|
82
|
+
// Fallback to 0 if scaled is NaN
|
|
83
|
+
return yScale(0)
|
|
84
|
+
}
|
|
85
|
+
return scaled
|
|
86
|
+
})
|
|
87
|
+
.curve(curve)
|
|
88
|
+
return areaGenerator(data)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Generate stacked area path with dynamic y0 and y1 values
|
|
93
|
+
* @param {any[]} data - Data points
|
|
94
|
+
* @param {import('d3-scale').ScaleLinear|import('d3-scale').ScaleTime} xScale - X scale
|
|
95
|
+
* @param {import('d3-scale').ScaleLinear} yScale - Y scale
|
|
96
|
+
* @param {Array<[number, number]>} stackedData - Array of [y0, y1] tuples for each data point
|
|
97
|
+
* @param {string} curveType - Curve type ("linear" or "monotone")
|
|
98
|
+
* @returns {string} SVG path string
|
|
99
|
+
*/
|
|
100
|
+
export function generateStackedAreaPath(
|
|
101
|
+
data,
|
|
102
|
+
xScale,
|
|
103
|
+
yScale,
|
|
104
|
+
stackedData,
|
|
105
|
+
curveType = "linear",
|
|
106
|
+
) {
|
|
107
|
+
const curve = curveType === "monotone" ? curveMonotoneX : curveLinear
|
|
108
|
+
const areaGenerator = area()
|
|
109
|
+
.x((d) => xScale(getDataPointX(d, null)))
|
|
110
|
+
.y0((d, i) => yScale(stackedData[i]?.[0] ?? 0))
|
|
111
|
+
.y1((d, i) => yScale(stackedData[i]?.[1] ?? 0))
|
|
112
|
+
.curve(curve)
|
|
113
|
+
return areaGenerator(data)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Calculate stacked data for multiple series
|
|
118
|
+
* Returns array where each element is an array of [y0, y1] tuples for each data point
|
|
119
|
+
* @param {any[]} seriesData - Array of series objects with values arrays
|
|
120
|
+
* @param {Function} valueAccessor - Function to extract value from data point
|
|
121
|
+
* @returns {Array<Array<[number, number]>>} Stacked data for each series
|
|
122
|
+
*/
|
|
123
|
+
export function calculateStackedData(
|
|
124
|
+
seriesData,
|
|
125
|
+
valueAccessor = (d) => getDataPointY(d),
|
|
126
|
+
) {
|
|
127
|
+
if (!seriesData || seriesData.length === 0) {
|
|
128
|
+
return []
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Get all series values
|
|
132
|
+
const allSeriesValues = seriesData.map((series) =>
|
|
133
|
+
Array.isArray(series.values) ? series.values : [series],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
// Find maximum length to handle different series lengths
|
|
137
|
+
const maxLength = Math.max(...allSeriesValues.map((s) => s.length))
|
|
138
|
+
|
|
139
|
+
// Create data structure for d3-shape stack
|
|
140
|
+
// Each entry represents one x position with values from all series
|
|
141
|
+
const data = []
|
|
142
|
+
for (let i = 0; i < maxLength; i++) {
|
|
143
|
+
const entry = {}
|
|
144
|
+
allSeriesValues.forEach((values, seriesIndex) => {
|
|
145
|
+
const point = values[i]
|
|
146
|
+
entry[seriesIndex] = point ? valueAccessor(point) : 0
|
|
147
|
+
})
|
|
148
|
+
data.push(entry)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Create stack generator
|
|
152
|
+
const keys = seriesData.map((_, i) => i)
|
|
153
|
+
const stackGenerator = stack()
|
|
154
|
+
.keys(keys)
|
|
155
|
+
.value((d, key) => d[key] ?? 0)
|
|
156
|
+
|
|
157
|
+
// Generate stacked data
|
|
158
|
+
const stacked = stackGenerator(data)
|
|
159
|
+
|
|
160
|
+
// Transform to array of [y0, y1] tuples for each series
|
|
161
|
+
return stacked.map((series) => series.map((point) => [point[0], point[1]]))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Calculates pie data with support for startAngle, endAngle, paddingAngle, and minAngle
|
|
166
|
+
* @param {any[]} data - The data array
|
|
167
|
+
* @param {Function} valueAccessor - Function to extract value from data (default: d => d.value)
|
|
168
|
+
* @param {number} startAngle - Start angle in degrees (default: 0)
|
|
169
|
+
* @param {number} endAngle - End angle in degrees (default: 360)
|
|
170
|
+
* @param {number} paddingAngle - Padding angle between sectors in degrees (default: 0)
|
|
171
|
+
* @param {number} minAngle - Minimum angle for each sector in degrees (default: 0)
|
|
172
|
+
* @returns {any[]} Pie data with calculated angles
|
|
173
|
+
*/
|
|
174
|
+
export function calculatePieData(
|
|
175
|
+
data,
|
|
176
|
+
valueAccessor = (d) => d.value,
|
|
177
|
+
startAngle = 0,
|
|
178
|
+
endAngle = 360,
|
|
179
|
+
paddingAngle = 0,
|
|
180
|
+
minAngle = 0,
|
|
181
|
+
) {
|
|
182
|
+
// Calculate total angle range
|
|
183
|
+
const deltaAngle = endAngle - startAngle
|
|
184
|
+
const absDeltaAngle = Math.abs(deltaAngle)
|
|
185
|
+
const sign = Math.sign(deltaAngle) || 1
|
|
186
|
+
|
|
187
|
+
// Count non-zero items
|
|
188
|
+
const notZeroItemCount = data.filter(
|
|
189
|
+
(entry) => valueAccessor(entry) !== 0,
|
|
190
|
+
).length
|
|
191
|
+
|
|
192
|
+
// Calculate total padding angle
|
|
193
|
+
const totalPaddingAngle =
|
|
194
|
+
absDeltaAngle >= 360
|
|
195
|
+
? notZeroItemCount * paddingAngle
|
|
196
|
+
: (notZeroItemCount - 1) * paddingAngle
|
|
197
|
+
|
|
198
|
+
// Calculate real total angle (subtract minAngle for each non-zero item and padding)
|
|
199
|
+
const minAngleRad = Math.abs(minAngle) * (Math.PI / 180)
|
|
200
|
+
const realTotalAngle =
|
|
201
|
+
absDeltaAngle * (Math.PI / 180) -
|
|
202
|
+
notZeroItemCount * minAngleRad -
|
|
203
|
+
totalPaddingAngle * (Math.PI / 180)
|
|
204
|
+
|
|
205
|
+
// Calculate sum of values
|
|
206
|
+
const sum = data.reduce((result, entry) => {
|
|
207
|
+
const val = valueAccessor(entry)
|
|
208
|
+
return result + (typeof val === "number" ? val : 0)
|
|
209
|
+
}, 0)
|
|
210
|
+
|
|
211
|
+
// Build pie data manually to apply minAngle correctly
|
|
212
|
+
if (sum > 0) {
|
|
213
|
+
const startAngleRad = (startAngle - 90) * (Math.PI / 180) // Offset by -90° (SVG starts at top)
|
|
214
|
+
const paddingAngleRad = paddingAngle * (Math.PI / 180)
|
|
215
|
+
|
|
216
|
+
let currentAngle = startAngleRad
|
|
217
|
+
|
|
218
|
+
return data.map((entry, i) => {
|
|
219
|
+
const val = valueAccessor(entry)
|
|
220
|
+
const isZero = val === 0 || typeof val !== "number"
|
|
221
|
+
|
|
222
|
+
const percent = isZero ? 0 : val / sum
|
|
223
|
+
const sliceAngle = isZero ? 0 : minAngleRad + percent * realTotalAngle
|
|
224
|
+
|
|
225
|
+
const sliceStartAngle = currentAngle
|
|
226
|
+
const sliceEndAngle = currentAngle + sign * sliceAngle
|
|
227
|
+
|
|
228
|
+
// Move to next slice (add padding if not last)
|
|
229
|
+
if (!isZero && i < data.length - 1) {
|
|
230
|
+
currentAngle = sliceEndAngle + sign * paddingAngleRad
|
|
231
|
+
} else {
|
|
232
|
+
currentAngle = sliceEndAngle
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
data: entry,
|
|
237
|
+
value: isZero ? 0 : val,
|
|
238
|
+
index: i,
|
|
239
|
+
startAngle: sliceStartAngle,
|
|
240
|
+
endAngle: sliceEndAngle,
|
|
241
|
+
padAngle: isZero ? 0 : paddingAngleRad,
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Fallback to d3-shape if no data or sum is 0
|
|
247
|
+
const pieGenerator = pie()
|
|
248
|
+
.value(valueAccessor)
|
|
249
|
+
.sort(null)
|
|
250
|
+
.startAngle((startAngle - 90) * (Math.PI / 180))
|
|
251
|
+
.endAngle((endAngle - 90) * (Math.PI / 180))
|
|
252
|
+
.padAngle(paddingAngle * (Math.PI / 180))
|
|
253
|
+
|
|
254
|
+
return pieGenerator(data)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Generates SVG arc path with support for cornerRadius
|
|
259
|
+
* @param {number} innerRadius - Inner radius
|
|
260
|
+
* @param {number} outerRadius - Outer radius
|
|
261
|
+
* @param {number} startAngle - Start angle in radians
|
|
262
|
+
* @param {number} endAngle - End angle in radians
|
|
263
|
+
* @param {number} cornerRadius - Corner radius for rounded edges (default: 0)
|
|
264
|
+
* @returns {string} SVG path string
|
|
265
|
+
*/
|
|
266
|
+
export function generateArcPath(
|
|
267
|
+
innerRadius,
|
|
268
|
+
outerRadius,
|
|
269
|
+
startAngle,
|
|
270
|
+
endAngle,
|
|
271
|
+
cornerRadius = 0,
|
|
272
|
+
) {
|
|
273
|
+
return arc()
|
|
274
|
+
.innerRadius(innerRadius)
|
|
275
|
+
.outerRadius(outerRadius)
|
|
276
|
+
.startAngle(startAngle)
|
|
277
|
+
.endAngle(endAngle)
|
|
278
|
+
.cornerRadius(cornerRadius)()
|
|
279
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Processes declarative child objects (intention objects) into rendered functions
|
|
3
|
+
* Converts { type: 'XAxis', config } into a rendered function
|
|
4
|
+
*
|
|
5
|
+
* @param {Object} child - Child object with type and config properties
|
|
6
|
+
* @param {Object} entity - Chart entity
|
|
7
|
+
* @param {string} chartTypeName - Chart type name
|
|
8
|
+
* @param {Object} api - Web API instance
|
|
9
|
+
* @returns {Function|null}
|
|
10
|
+
*/
|
|
11
|
+
export function processDeclarativeChild(child, entity, chartTypeName, api) {
|
|
12
|
+
if (
|
|
13
|
+
child &&
|
|
14
|
+
typeof child === "object" &&
|
|
15
|
+
child.type &&
|
|
16
|
+
child.config !== undefined
|
|
17
|
+
) {
|
|
18
|
+
const chartType = api.getType(chartTypeName)
|
|
19
|
+
const methodName = `render${child.type}`
|
|
20
|
+
|
|
21
|
+
if (chartType?.[methodName]) {
|
|
22
|
+
// Execute the render method to get the actual component function (e.g., brushFn)
|
|
23
|
+
const rendered = chartType[methodName](
|
|
24
|
+
entity,
|
|
25
|
+
{ config: child.config },
|
|
26
|
+
api,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// Inject flag based on the declarative object TYPE
|
|
30
|
+
// This ensures the returned function has the correct flag for identification
|
|
31
|
+
if (typeof rendered === "function") {
|
|
32
|
+
if (child.type === "Brush") rendered.isBrush = true
|
|
33
|
+
if (child.type === "XAxis" || child.type === "YAxis")
|
|
34
|
+
rendered.isAxis = true
|
|
35
|
+
if (child.type === "CartesianGrid") rendered.isGrid = true
|
|
36
|
+
if (child.type === "Line") rendered.isLine = true
|
|
37
|
+
if (child.type === "Dots") rendered.isDots = true
|
|
38
|
+
if (child.type === "Tooltip") rendered.isTooltip = true
|
|
39
|
+
if (child.type === "Legend") rendered.isLegend = true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return rendered
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return child
|
|
46
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/* eslint-disable no-magic-numbers */
|
|
2
|
+
|
|
3
|
+
import { extent } from "d3-array"
|
|
4
|
+
import { scaleBand, scaleLinear, scaleTime } from "d3-scale"
|
|
5
|
+
|
|
6
|
+
import { getDataPointX, getDataPointY, isMultiSeries } from "./data-utils.js"
|
|
7
|
+
|
|
8
|
+
export function createYScale(data, height, padding, isStacked = false) {
|
|
9
|
+
let values
|
|
10
|
+
if (isMultiSeries(data)) {
|
|
11
|
+
if (isStacked) {
|
|
12
|
+
// For stacked areas, calculate the sum of all series at each point
|
|
13
|
+
const allSeriesValues = data.map((series) =>
|
|
14
|
+
Array.isArray(series.values) ? series.values : [series],
|
|
15
|
+
)
|
|
16
|
+
const maxLength = Math.max(...allSeriesValues.map((s) => s.length))
|
|
17
|
+
|
|
18
|
+
// Calculate stacked sums for each x position
|
|
19
|
+
values = []
|
|
20
|
+
for (let i = 0; i < maxLength; i++) {
|
|
21
|
+
let sum = 0
|
|
22
|
+
allSeriesValues.forEach((seriesValues) => {
|
|
23
|
+
const point = seriesValues[i]
|
|
24
|
+
if (point) {
|
|
25
|
+
sum += point.y ?? point.value ?? 0
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
values.push(sum)
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
// For non-stacked (line charts), use all individual values
|
|
32
|
+
values = data.flatMap((series) =>
|
|
33
|
+
Array.isArray(series.values)
|
|
34
|
+
? series.values.map((v) => v.y ?? v.value ?? 0)
|
|
35
|
+
: [series.y ?? series.value ?? 0],
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
// Single series: extract directly
|
|
40
|
+
values = data.map((d) => getDataPointY(d))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const [minVal, maxVal] = extent(values)
|
|
44
|
+
// Always start at 0 to avoid visual distortions
|
|
45
|
+
const min = Math.min(0, minVal ?? 0)
|
|
46
|
+
const max = maxVal ?? 100
|
|
47
|
+
return scaleLinear()
|
|
48
|
+
.domain([min, max])
|
|
49
|
+
.nice() // Make domain "nice" (round to nice numbers like 1000 instead of 980)
|
|
50
|
+
.range([height - padding.bottom, padding.top])
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createXScale(data, width, padding) {
|
|
54
|
+
// Handle multi-series data (array of series with values)
|
|
55
|
+
let values
|
|
56
|
+
if (isMultiSeries(data)) {
|
|
57
|
+
// Extract all x values from all series
|
|
58
|
+
values = data.flatMap((series) =>
|
|
59
|
+
series.values ? series.values.map((v) => v.x ?? v.date) : [],
|
|
60
|
+
)
|
|
61
|
+
} else {
|
|
62
|
+
// Single series: extract directly
|
|
63
|
+
// getDataPointX returns d.x ?? d.date ?? fallback (index)
|
|
64
|
+
// So if data has no x/date, it will use index as fallback
|
|
65
|
+
values = data.map((d, i) => {
|
|
66
|
+
const xVal = getDataPointX(d, i)
|
|
67
|
+
// If getDataPointX returns the fallback (index), ensure it's a valid number
|
|
68
|
+
return xVal != null && !isNaN(xVal) ? xVal : i
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Filter out null/undefined values and ensure we have valid numbers
|
|
73
|
+
const validValues = values.filter((v) => v != null && !isNaN(v))
|
|
74
|
+
|
|
75
|
+
if (validValues.length === 0) {
|
|
76
|
+
// If no valid values found, use index-based domain
|
|
77
|
+
// This happens when data has no x/date fields and getDataPointX fails
|
|
78
|
+
return scaleLinear()
|
|
79
|
+
.domain([0, Math.max(0, data.length - 1)])
|
|
80
|
+
.range([padding.left, width - padding.right])
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const [minVal, maxVal] = extent(validValues)
|
|
84
|
+
if (minVal == null || maxVal == null || isNaN(minVal) || isNaN(maxVal)) {
|
|
85
|
+
console.warn(
|
|
86
|
+
"[createXScale] Invalid extent, using default domain [0, data.length-1]",
|
|
87
|
+
{ minVal, maxVal, values, validValues },
|
|
88
|
+
)
|
|
89
|
+
return scaleLinear()
|
|
90
|
+
.domain([0, data.length - 1])
|
|
91
|
+
.range([padding.left, width - padding.right])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return scaleLinear()
|
|
95
|
+
.domain([minVal, maxVal])
|
|
96
|
+
.range([padding.left, width - padding.right])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createTimeScale(data, width, padding) {
|
|
100
|
+
const dates = data.map((d) => new Date(d.date ?? d.x))
|
|
101
|
+
const [minDate, maxDate] = extent(dates)
|
|
102
|
+
return scaleTime()
|
|
103
|
+
.domain([minDate, maxDate])
|
|
104
|
+
.range([padding.left, width - padding.right])
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createOrdinalScale(categories, width, padding) {
|
|
108
|
+
return scaleBand()
|
|
109
|
+
.domain(categories)
|
|
110
|
+
.range([padding.left, width - padding.right])
|
|
111
|
+
.padding(0.1)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get filtered data based on brush state
|
|
116
|
+
* @param {any} entity - Chart entity
|
|
117
|
+
* @returns {any[]} Filtered data array
|
|
118
|
+
*/
|
|
119
|
+
export function getFilteredData(entity) {
|
|
120
|
+
if (!entity.brush?.enabled || !entity.data) {
|
|
121
|
+
return entity.data
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const startIndex = entity.brush.startIndex ?? 0
|
|
125
|
+
const endIndex = entity.brush.endIndex ?? entity.data.length - 1
|
|
126
|
+
|
|
127
|
+
return entity.data.slice(startIndex, endIndex + 1)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Helper to create scales based on chart type
|
|
132
|
+
* Returns { xScale, yScale }
|
|
133
|
+
*/
|
|
134
|
+
export function createScales(entity, chartType) {
|
|
135
|
+
// Use filtered data if brush is enabled
|
|
136
|
+
const isZoomable = chartType === "line" || chartType === "area"
|
|
137
|
+
const dataForScales = isZoomable ? entity.data : getFilteredData(entity)
|
|
138
|
+
|
|
139
|
+
// Area charts use stacked scale only if entity.stacked is true
|
|
140
|
+
// Default to false (non-stacked) for area charts
|
|
141
|
+
const isStacked = chartType === "area" && entity.stacked === true
|
|
142
|
+
const yScale = createYScale(
|
|
143
|
+
dataForScales,
|
|
144
|
+
entity.height,
|
|
145
|
+
entity.padding,
|
|
146
|
+
isStacked,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
let xScale
|
|
150
|
+
if (chartType === "bar") {
|
|
151
|
+
const categories = dataForScales.map((d) => d.label || d.name || d.category)
|
|
152
|
+
xScale = createOrdinalScale(categories, entity.width, entity.padding)
|
|
153
|
+
} else {
|
|
154
|
+
// Line chart or others that use linear/time scale
|
|
155
|
+
xScale =
|
|
156
|
+
entity.xAxisType === "time"
|
|
157
|
+
? createTimeScale(dataForScales, entity.width, entity.padding)
|
|
158
|
+
: createXScale(dataForScales, entity.width, entity.padding)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { xScale, yScale }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @typedef {Object} CartesianContext
|
|
166
|
+
* @property {import('d3-scale').ScaleBand|import('d3-scale').ScaleLinear|import('d3-scale').ScaleTime} xScale
|
|
167
|
+
* @property {import('d3-scale').ScaleLinear} yScale
|
|
168
|
+
* @property {Object} dimensions
|
|
169
|
+
* @property {number} dimensions.width
|
|
170
|
+
* @property {number} dimensions.height
|
|
171
|
+
* @property {Object} dimensions.padding
|
|
172
|
+
* @property {any} entity
|
|
173
|
+
*/
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create cartesian context with scales, dimensions, and entity
|
|
177
|
+
* @param {any} entity
|
|
178
|
+
* @param {string} chartType
|
|
179
|
+
* @returns {CartesianContext}
|
|
180
|
+
*/
|
|
181
|
+
export function createCartesianContext(entity, chartType) {
|
|
182
|
+
const { xScale, yScale } = createScales(entity, chartType)
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
xScale,
|
|
186
|
+
yScale,
|
|
187
|
+
dimensions: {
|
|
188
|
+
width: entity.width,
|
|
189
|
+
height: entity.height,
|
|
190
|
+
padding: entity.padding,
|
|
191
|
+
},
|
|
192
|
+
entity,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Calculate X-axis ticks based on data
|
|
198
|
+
* For few data points (≤15), shows all actual x values
|
|
199
|
+
* For many data points, uses scale ticks
|
|
200
|
+
*
|
|
201
|
+
* @param {any[]} data - Chart data
|
|
202
|
+
* @param {import('d3-scale').ScaleLinear|import('d3-scale').ScaleTime} xScale - X scale
|
|
203
|
+
* @returns {number[]|Date[]} Array of tick values
|
|
204
|
+
*/
|
|
205
|
+
export function calculateXTicks(data, xScale) {
|
|
206
|
+
if (!data || data.length === 0) {
|
|
207
|
+
return xScale.ticks ? xScale.ticks(5) : xScale.domain()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Extract all unique x values from data using Set for O(n) performance
|
|
211
|
+
// This avoids O(n²) complexity from includes() in a loop
|
|
212
|
+
const uniqueXValues = new Set()
|
|
213
|
+
if (isMultiSeries(data)) {
|
|
214
|
+
// Multi-series: extract from values arrays
|
|
215
|
+
data.forEach((series) => {
|
|
216
|
+
if (series.values) {
|
|
217
|
+
series.values.forEach((v) => {
|
|
218
|
+
const xVal = v.x ?? v.date
|
|
219
|
+
if (xVal != null) {
|
|
220
|
+
uniqueXValues.add(xVal)
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
} else {
|
|
226
|
+
// Single series: extract directly
|
|
227
|
+
// Use index as fallback when x/date is not present (for categorical data)
|
|
228
|
+
data.forEach((d, i) => {
|
|
229
|
+
const xVal = getDataPointX(d, i)
|
|
230
|
+
if (xVal != null) {
|
|
231
|
+
uniqueXValues.add(xVal)
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Convert Set to sorted array
|
|
237
|
+
const allXValues = Array.from(uniqueXValues).sort((a, b) => a - b)
|
|
238
|
+
|
|
239
|
+
// If we have few data points (≤15), show all actual x values
|
|
240
|
+
// Otherwise use D3's automatic tick calculation (like Recharts)
|
|
241
|
+
if (xScale.ticks && allXValues.length <= 15) {
|
|
242
|
+
return allXValues
|
|
243
|
+
}
|
|
244
|
+
if (xScale.ticks) {
|
|
245
|
+
// Let D3 automatically choose optimal ticks (default behavior, like Recharts)
|
|
246
|
+
// D3 will choose nice intervals automatically based on the scale
|
|
247
|
+
return xScale.ticks()
|
|
248
|
+
}
|
|
249
|
+
return xScale.domain()
|
|
250
|
+
}
|