@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,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cartesian layout utilities
|
|
3
|
+
* Common layout logic for cartesian charts (area, line, bar)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { html, svg } from "@inglorious/web"
|
|
7
|
+
|
|
8
|
+
import { renderBrush } from "../component/brush.js"
|
|
9
|
+
import { renderEmptyState } from "../component/empty-state.js"
|
|
10
|
+
import { renderGrid } from "../component/grid.js"
|
|
11
|
+
import { renderLegend } from "../component/legend.js"
|
|
12
|
+
import { renderTooltip } from "../component/tooltip.js"
|
|
13
|
+
import { renderXAxis } from "../component/x-axis.js"
|
|
14
|
+
import { renderYAxis } from "../component/y-axis.js"
|
|
15
|
+
import { isMultiSeries } from "./data-utils.js"
|
|
16
|
+
import { createCartesianContext } from "./scales.js"
|
|
17
|
+
import { createTooltipMoveHandler } from "./tooltip-handlers.js"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Renders the common cartesian chart layout
|
|
21
|
+
* @param {any} entity - Chart entity
|
|
22
|
+
* @param {Object} props
|
|
23
|
+
* @param {string} props.chartType - Chart type ("area", "line", "bar")
|
|
24
|
+
* @param {import('lit-html').TemplateResult} props.chartContent - Chart-specific content (areas, lines, bars)
|
|
25
|
+
* @param {boolean} [props.showLegend] - Whether to show legend (defaults to entity.showLegend for multi-series)
|
|
26
|
+
* @param {import('@inglorious/web').Api} api - Web API instance
|
|
27
|
+
* @returns {import('lit-html').TemplateResult} Complete chart HTML
|
|
28
|
+
*/
|
|
29
|
+
export function renderCartesianLayout(entity, props, api) {
|
|
30
|
+
const { chartType, chartContent, showLegend = undefined } = props || {}
|
|
31
|
+
// Check for empty state
|
|
32
|
+
// eslint-disable-next-line no-magic-numbers
|
|
33
|
+
if (!entity.data || entity.data.length === 0) {
|
|
34
|
+
return html`
|
|
35
|
+
<div class="iw-chart">
|
|
36
|
+
${renderEmptyState(
|
|
37
|
+
entity,
|
|
38
|
+
{
|
|
39
|
+
width: entity.width,
|
|
40
|
+
height: entity.height,
|
|
41
|
+
},
|
|
42
|
+
api,
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check if brush is enabled and adjust height accordingly
|
|
49
|
+
// eslint-disable-next-line no-magic-numbers
|
|
50
|
+
const brushHeight = entity.brush?.enabled ? 60 : 0
|
|
51
|
+
// eslint-disable-next-line no-magic-numbers
|
|
52
|
+
const chartHeight = entity.height || 400
|
|
53
|
+
const totalHeight = chartHeight + brushHeight
|
|
54
|
+
|
|
55
|
+
// Create context with scales and dimensions
|
|
56
|
+
const context = createCartesianContext(entity, chartType)
|
|
57
|
+
let { xScale, yScale, dimensions } = context
|
|
58
|
+
const { width, height, padding } = dimensions
|
|
59
|
+
|
|
60
|
+
// Apply zoom if brush is enabled (similar to composition mode)
|
|
61
|
+
if (entity.brush?.enabled && entity.brush.startIndex !== undefined) {
|
|
62
|
+
const { startIndex, endIndex } = entity.brush
|
|
63
|
+
xScale = xScale.copy().domain([startIndex, endIndex])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Independent components - declarative composition
|
|
67
|
+
const grid = entity.showGrid
|
|
68
|
+
? renderGrid(
|
|
69
|
+
entity,
|
|
70
|
+
{
|
|
71
|
+
xScale,
|
|
72
|
+
yScale,
|
|
73
|
+
width,
|
|
74
|
+
height,
|
|
75
|
+
padding,
|
|
76
|
+
},
|
|
77
|
+
api,
|
|
78
|
+
)
|
|
79
|
+
: svg``
|
|
80
|
+
|
|
81
|
+
const xAxis = renderXAxis(
|
|
82
|
+
entity,
|
|
83
|
+
{
|
|
84
|
+
xScale,
|
|
85
|
+
yScale,
|
|
86
|
+
width,
|
|
87
|
+
height,
|
|
88
|
+
padding,
|
|
89
|
+
},
|
|
90
|
+
api,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const yAxis = renderYAxis(
|
|
94
|
+
entity,
|
|
95
|
+
{
|
|
96
|
+
yScale,
|
|
97
|
+
height,
|
|
98
|
+
padding,
|
|
99
|
+
},
|
|
100
|
+
api,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// Legend - only for multiple series
|
|
104
|
+
const shouldShowLegend =
|
|
105
|
+
showLegend !== undefined
|
|
106
|
+
? showLegend
|
|
107
|
+
: isMultiSeries(entity.data) && entity.showLegend
|
|
108
|
+
const legend = shouldShowLegend
|
|
109
|
+
? renderLegend(
|
|
110
|
+
entity,
|
|
111
|
+
{
|
|
112
|
+
series: entity.data,
|
|
113
|
+
colors: entity.colors,
|
|
114
|
+
width,
|
|
115
|
+
padding,
|
|
116
|
+
},
|
|
117
|
+
api,
|
|
118
|
+
)
|
|
119
|
+
: svg``
|
|
120
|
+
|
|
121
|
+
// Brush - render if enabled in config mode
|
|
122
|
+
const brush = entity.brush?.enabled
|
|
123
|
+
? renderBrush(
|
|
124
|
+
entity,
|
|
125
|
+
{
|
|
126
|
+
xScale,
|
|
127
|
+
width,
|
|
128
|
+
height,
|
|
129
|
+
padding,
|
|
130
|
+
// eslint-disable-next-line no-magic-numbers
|
|
131
|
+
brushHeight: entity.brush.height || 30,
|
|
132
|
+
dataKey: entity.dataKey || "name",
|
|
133
|
+
},
|
|
134
|
+
api,
|
|
135
|
+
)
|
|
136
|
+
: svg``
|
|
137
|
+
|
|
138
|
+
// SVG container
|
|
139
|
+
const svgContent = svg`
|
|
140
|
+
<svg
|
|
141
|
+
width=${width}
|
|
142
|
+
height=${totalHeight}
|
|
143
|
+
viewBox="0 0 ${width} ${totalHeight}"
|
|
144
|
+
class="iw-chart-svg"
|
|
145
|
+
@mousemove=${createTooltipMoveHandler({ entity, api })}
|
|
146
|
+
>
|
|
147
|
+
${grid}
|
|
148
|
+
${xAxis}
|
|
149
|
+
${yAxis}
|
|
150
|
+
${chartContent}
|
|
151
|
+
${legend}
|
|
152
|
+
${brush}
|
|
153
|
+
</svg>
|
|
154
|
+
`
|
|
155
|
+
|
|
156
|
+
return html`
|
|
157
|
+
<div
|
|
158
|
+
class="iw-chart"
|
|
159
|
+
style="display: block; margin: 0; padding: 0; position: relative; width: 100%; box-sizing: border-box;"
|
|
160
|
+
>
|
|
161
|
+
${svgContent} ${renderTooltip(entity, {}, api)}
|
|
162
|
+
</div>
|
|
163
|
+
`
|
|
164
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart utilities
|
|
3
|
+
* Functions for chart creation and configuration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { area } from "../cartesian/area.js"
|
|
7
|
+
import { bar } from "../cartesian/bar.js"
|
|
8
|
+
import { line } from "../cartesian/line.js"
|
|
9
|
+
import * as handlers from "../handlers.js"
|
|
10
|
+
import { donut } from "../polar/donut.js"
|
|
11
|
+
import { pie } from "../polar/pie.js"
|
|
12
|
+
import { render } from "../template.js"
|
|
13
|
+
|
|
14
|
+
export const areaChart = combineRenderer(area)
|
|
15
|
+
export const barChart = combineRenderer(bar)
|
|
16
|
+
export const lineChart = combineRenderer(line)
|
|
17
|
+
export const pieChart = combineRenderer(pie)
|
|
18
|
+
export const donutChart = combineRenderer(donut)
|
|
19
|
+
|
|
20
|
+
// Export chart object for declarative helpers (composition style)
|
|
21
|
+
export { chart } from "../index.js"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Combines handlers, template, and chart-specific renderer into a complete chart type.
|
|
25
|
+
* @param {Object} chartRenderer - Chart-specific renderer (e.g., { renderChart })
|
|
26
|
+
* @returns {Object} Combined chart object with handlers, template, and renderer methods
|
|
27
|
+
*/
|
|
28
|
+
function combineRenderer(chartRenderer) {
|
|
29
|
+
return { ...handlers, render, ...chartRenderer }
|
|
30
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/* eslint-disable no-magic-numbers */
|
|
2
|
+
|
|
3
|
+
const DEFAULT_COLORS = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"]
|
|
4
|
+
|
|
5
|
+
const EXTENDED_COLORS = [
|
|
6
|
+
"#3b82f6",
|
|
7
|
+
"#ef4444",
|
|
8
|
+
"#10b981",
|
|
9
|
+
"#f59e0b",
|
|
10
|
+
"#8b5cf6",
|
|
11
|
+
"#ec4899",
|
|
12
|
+
"#06b6d4",
|
|
13
|
+
"#84cc16",
|
|
14
|
+
"#f97316",
|
|
15
|
+
"#6366f1",
|
|
16
|
+
"#14b8a6",
|
|
17
|
+
"#a855f7",
|
|
18
|
+
"#eab308",
|
|
19
|
+
"#22c55e",
|
|
20
|
+
"#3b82f6",
|
|
21
|
+
"#64748b",
|
|
22
|
+
"#f43f5e",
|
|
23
|
+
"#8b5cf6",
|
|
24
|
+
"#0ea5e9",
|
|
25
|
+
"#06b6d4",
|
|
26
|
+
"#10b981",
|
|
27
|
+
"#f59e0b",
|
|
28
|
+
"#ef4444",
|
|
29
|
+
"#6366f1",
|
|
30
|
+
"#ec4899",
|
|
31
|
+
"#14b8a6",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {number} count
|
|
36
|
+
* @param {string[]} [customColors]
|
|
37
|
+
* @returns {string[]}
|
|
38
|
+
*/
|
|
39
|
+
export function generateColors(count, customColors = null) {
|
|
40
|
+
const baseColors = customColors || EXTENDED_COLORS
|
|
41
|
+
|
|
42
|
+
if (count <= baseColors.length) {
|
|
43
|
+
return baseColors.slice(0, count)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const colors = [...baseColors]
|
|
47
|
+
const needed = count - baseColors.length
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < needed; i++) {
|
|
50
|
+
const baseIndex = i % baseColors.length
|
|
51
|
+
const baseColor = baseColors[baseIndex]
|
|
52
|
+
const variation = Math.floor(i / baseColors.length) + 1
|
|
53
|
+
|
|
54
|
+
const hex = baseColor.replace("#", "")
|
|
55
|
+
const r = parseInt(hex.substr(0, 2), 16)
|
|
56
|
+
const g = parseInt(hex.substr(2, 2), 16)
|
|
57
|
+
const b = parseInt(hex.substr(4, 2), 16)
|
|
58
|
+
|
|
59
|
+
const factor = 0.7 + variation * 0.1
|
|
60
|
+
const newR = Math.min(255, Math.floor(r * factor))
|
|
61
|
+
const newG = Math.min(255, Math.floor(g * factor))
|
|
62
|
+
const newB = Math.min(255, Math.floor(b * factor))
|
|
63
|
+
|
|
64
|
+
colors.push(
|
|
65
|
+
`#${newR.toString(16).padStart(2, "0")}${newG.toString(16).padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return colors
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @returns {string[]}
|
|
74
|
+
*/
|
|
75
|
+
export function getDefaultColors() {
|
|
76
|
+
return [...DEFAULT_COLORS]
|
|
77
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/* eslint-disable no-magic-numbers */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Data utilities
|
|
5
|
+
* Functions for data manipulation and formatting
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { format } from "d3-format"
|
|
9
|
+
import { timeFormat } from "d3-time-format"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format a number with the specified format
|
|
13
|
+
* @param {number} value - Number to format
|
|
14
|
+
* @param {string} [fmt] - Format string (d3-format syntax). If not provided, uses smart formatting (integers without decimals)
|
|
15
|
+
* @returns {string} Formatted number string
|
|
16
|
+
*/
|
|
17
|
+
export function formatNumber(value, fmt) {
|
|
18
|
+
if (fmt) {
|
|
19
|
+
return format(fmt)(value)
|
|
20
|
+
}
|
|
21
|
+
// Smart formatting: if integer, show without decimals; otherwise show 2 decimals
|
|
22
|
+
if (Number.isInteger(value)) {
|
|
23
|
+
return format(",")(value) // No decimals for integers
|
|
24
|
+
}
|
|
25
|
+
return format(",.2f")(value) // 2 decimals for non-integers
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Format a date with the specified format
|
|
30
|
+
* @param {Date|string|number} date - Date to format
|
|
31
|
+
* @param {string} [fmt="%Y-%m-%d"] - Format string (d3-time-format syntax)
|
|
32
|
+
* @returns {string} Formatted date string
|
|
33
|
+
*/
|
|
34
|
+
export function formatDate(date, fmt = "%Y-%m-%d") {
|
|
35
|
+
return timeFormat(fmt)(new Date(date))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Gets values from a series object
|
|
40
|
+
* @param {any} series - Series object that may have a values array or be a single value
|
|
41
|
+
* @returns {any[]} Array of values
|
|
42
|
+
*/
|
|
43
|
+
export function getSeriesValues(series) {
|
|
44
|
+
return Array.isArray(series.values) ? series.values : [series]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Checks if the data represents multiple series
|
|
49
|
+
* @param {any[]} data - Chart data array
|
|
50
|
+
* @returns {boolean} True if data represents multiple series (has values array)
|
|
51
|
+
*/
|
|
52
|
+
export function isMultiSeries(data) {
|
|
53
|
+
return (
|
|
54
|
+
Array.isArray(data) && data.length > 0 && Array.isArray(data[0]?.values)
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Gets the X coordinate value from a data point
|
|
60
|
+
* @param {any} d - Data point object
|
|
61
|
+
* @param {any} [fallback=0] - Fallback value if x/date is not found
|
|
62
|
+
* @returns {any} X coordinate value (x, date, or fallback)
|
|
63
|
+
*/
|
|
64
|
+
export function getDataPointX(d, fallback = 0) {
|
|
65
|
+
return d?.x ?? d?.date ?? fallback
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Gets the Y coordinate value from a data point
|
|
70
|
+
* @param {any} d - Data point object
|
|
71
|
+
* @param {any} [fallback=0] - Fallback value if y/value is not found
|
|
72
|
+
* @returns {any} Y coordinate value (y, value, or fallback)
|
|
73
|
+
*/
|
|
74
|
+
export function getDataPointY(d, fallback = 0) {
|
|
75
|
+
return d?.y ?? d?.value ?? fallback
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Gets the label from a data point
|
|
80
|
+
* @param {any} d - Data point object
|
|
81
|
+
* @param {string} [fallback="Value"] - Fallback label if x/date is not found
|
|
82
|
+
* @returns {string} Label value (x, date, or fallback)
|
|
83
|
+
*/
|
|
84
|
+
export function getDataPointLabel(d, fallback = "Value") {
|
|
85
|
+
return d?.x ?? d?.date ?? fallback
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Checks if a value is a valid number (not NaN and finite)
|
|
90
|
+
* @param {any} value - Value to check
|
|
91
|
+
* @returns {boolean} True if value is a valid number
|
|
92
|
+
*/
|
|
93
|
+
export function isValidNumber(value) {
|
|
94
|
+
return typeof value === "number" && !isNaN(value) && isFinite(value)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Ensures a value is a valid number, returning a fallback if not
|
|
99
|
+
* @param {any} value - Value to validate
|
|
100
|
+
* @param {number} [fallback=0] - Fallback value if validation fails
|
|
101
|
+
* @returns {number} Valid number value
|
|
102
|
+
*/
|
|
103
|
+
export function ensureValidNumber(value, fallback = 0) {
|
|
104
|
+
return isValidNumber(value) ? value : fallback
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Transforms entity data to standardized format with x, y, and name properties.
|
|
109
|
+
* Used for rendering lines and areas in composition mode.
|
|
110
|
+
*
|
|
111
|
+
* @param {any} entity - Chart entity with data
|
|
112
|
+
* @param {string} dataKey - Data key to extract values from
|
|
113
|
+
* @returns {Array|null} Transformed data array or null if entity/data is invalid
|
|
114
|
+
*/
|
|
115
|
+
export function getTransformedData(entity, dataKey) {
|
|
116
|
+
if (!entity || !entity.data) return null
|
|
117
|
+
|
|
118
|
+
// Transform data to use indices for x (like Recharts does with categorical data)
|
|
119
|
+
// Ensure y is always a valid number (handles negatives, NaN, etc.)
|
|
120
|
+
return entity.data.map((d, i) => {
|
|
121
|
+
const yValue =
|
|
122
|
+
d[dataKey] !== undefined
|
|
123
|
+
? d[dataKey]
|
|
124
|
+
: d.y !== undefined
|
|
125
|
+
? d.y
|
|
126
|
+
: d.value !== undefined
|
|
127
|
+
? d.value
|
|
128
|
+
: 0
|
|
129
|
+
const y = typeof yValue === "number" && !isNaN(yValue) ? yValue : 0
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
x: i, // Use index for positioning
|
|
133
|
+
y,
|
|
134
|
+
name: d[dataKey] || d.name || d.x || d.date || i, // Keep name for labels
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parses a dimension value (width/height) from config or entity
|
|
141
|
+
* Returns numeric value if possible, undefined otherwise
|
|
142
|
+
* @param {number|string|undefined} value - Dimension value to parse
|
|
143
|
+
* @returns {number|undefined} Parsed numeric value or undefined
|
|
144
|
+
*/
|
|
145
|
+
export function parseDimension(value) {
|
|
146
|
+
if (typeof value === "number") return value
|
|
147
|
+
if (typeof value === "string") {
|
|
148
|
+
// Try to parse as number (e.g., "800" -> 800)
|
|
149
|
+
const num = parseFloat(value)
|
|
150
|
+
if (!isNaN(num) && !value.includes("%") && !value.includes("px")) {
|
|
151
|
+
return num
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return undefined
|
|
155
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ensureValidNumber,
|
|
8
|
+
formatDate,
|
|
9
|
+
formatNumber,
|
|
10
|
+
getDataPointLabel,
|
|
11
|
+
getDataPointX,
|
|
12
|
+
getDataPointY,
|
|
13
|
+
getSeriesValues,
|
|
14
|
+
getTransformedData,
|
|
15
|
+
isMultiSeries,
|
|
16
|
+
isValidNumber,
|
|
17
|
+
parseDimension,
|
|
18
|
+
} from "./data-utils.js"
|
|
19
|
+
|
|
20
|
+
describe("data-utils", () => {
|
|
21
|
+
describe("formatNumber", () => {
|
|
22
|
+
it("should format number with default format", () => {
|
|
23
|
+
expect(formatNumber(1234.56)).toBe("1,234.56")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("should format number with custom format", () => {
|
|
27
|
+
expect(formatNumber(1234.56, ".0f")).toBe("1235")
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe("formatDate", () => {
|
|
32
|
+
it("should format date with default format", () => {
|
|
33
|
+
const date = new Date("2024-01-15")
|
|
34
|
+
const result = formatDate(date)
|
|
35
|
+
expect(result).toContain("2024")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("should format date with custom format", () => {
|
|
39
|
+
const date = new Date("2024-01-15")
|
|
40
|
+
const result = formatDate(date, "%Y-%m-%d")
|
|
41
|
+
expect(result).toBe("2024-01-15")
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe("getSeriesValues", () => {
|
|
46
|
+
it("should return values array if present", () => {
|
|
47
|
+
const series = { name: "Series A", values: [{ x: 0, y: 10 }] }
|
|
48
|
+
expect(getSeriesValues(series)).toEqual([{ x: 0, y: 10 }])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("should wrap single value in array", () => {
|
|
52
|
+
const series = { x: 0, y: 10 }
|
|
53
|
+
expect(getSeriesValues(series)).toEqual([{ x: 0, y: 10 }])
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe("isMultiSeries", () => {
|
|
58
|
+
it("should return true for multi-series data", () => {
|
|
59
|
+
const data = [
|
|
60
|
+
{ name: "Series A", values: [{ x: 0, y: 10 }] },
|
|
61
|
+
{ name: "Series B", values: [{ x: 0, y: 20 }] },
|
|
62
|
+
]
|
|
63
|
+
expect(isMultiSeries(data)).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("should return false for single series data", () => {
|
|
67
|
+
const data = [
|
|
68
|
+
{ x: 0, y: 10 },
|
|
69
|
+
{ x: 1, y: 20 },
|
|
70
|
+
]
|
|
71
|
+
expect(isMultiSeries(data)).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("should return false for empty array", () => {
|
|
75
|
+
expect(isMultiSeries([])).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe("getDataPointX", () => {
|
|
80
|
+
it("should return x value if present", () => {
|
|
81
|
+
expect(getDataPointX({ x: 5 })).toBe(5)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("should return date value if x is not present", () => {
|
|
85
|
+
expect(getDataPointX({ date: "2024-01-01" })).toBe("2024-01-01")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("should return fallback if neither x nor date present", () => {
|
|
89
|
+
expect(getDataPointX({ value: 10 }, 0)).toBe(0)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe("getDataPointY", () => {
|
|
94
|
+
it("should return y value if present", () => {
|
|
95
|
+
expect(getDataPointY({ y: 100 })).toBe(100)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("should return value if y is not present", () => {
|
|
99
|
+
expect(getDataPointY({ value: 200 })).toBe(200)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("should return fallback if neither y nor value present", () => {
|
|
103
|
+
expect(getDataPointY({ name: "Jan" }, 0)).toBe(0)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe("getDataPointLabel", () => {
|
|
108
|
+
it("should return x value if present", () => {
|
|
109
|
+
expect(getDataPointLabel({ x: "Jan" })).toBe("Jan")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("should return date value if x is not present", () => {
|
|
113
|
+
expect(getDataPointLabel({ date: "2024-01-01" })).toBe("2024-01-01")
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("should return fallback if neither x nor date present", () => {
|
|
117
|
+
expect(getDataPointLabel({ value: 10 }, "Value")).toBe("Value")
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe("isValidNumber", () => {
|
|
122
|
+
it("should return true for valid numbers", () => {
|
|
123
|
+
expect(isValidNumber(100)).toBe(true)
|
|
124
|
+
expect(isValidNumber(0)).toBe(true)
|
|
125
|
+
expect(isValidNumber(-50)).toBe(true)
|
|
126
|
+
expect(isValidNumber(3.14)).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("should return false for invalid numbers", () => {
|
|
130
|
+
expect(isValidNumber(NaN)).toBe(false)
|
|
131
|
+
expect(isValidNumber(Infinity)).toBe(false)
|
|
132
|
+
expect(isValidNumber("100")).toBe(false)
|
|
133
|
+
expect(isValidNumber(null)).toBe(false)
|
|
134
|
+
expect(isValidNumber(undefined)).toBe(false)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe("ensureValidNumber", () => {
|
|
139
|
+
it("should return value if valid", () => {
|
|
140
|
+
expect(ensureValidNumber(100)).toBe(100)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("should return fallback if invalid", () => {
|
|
144
|
+
expect(ensureValidNumber(NaN, 0)).toBe(0)
|
|
145
|
+
expect(ensureValidNumber("100", 0)).toBe(0)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe("parseDimension", () => {
|
|
150
|
+
it("should return number if already a number", () => {
|
|
151
|
+
expect(parseDimension(800)).toBe(800)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("should parse numeric string", () => {
|
|
155
|
+
expect(parseDimension("800")).toBe(800)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("should return undefined for percentage strings", () => {
|
|
159
|
+
expect(parseDimension("100%")).toBeUndefined()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("should return undefined for pixel strings", () => {
|
|
163
|
+
expect(parseDimension("800px")).toBeUndefined()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("should return undefined for non-numeric strings", () => {
|
|
167
|
+
expect(parseDimension("auto")).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe("getTransformedData", () => {
|
|
172
|
+
it("should transform data with dataKey", () => {
|
|
173
|
+
const entity = {
|
|
174
|
+
id: "test",
|
|
175
|
+
data: [
|
|
176
|
+
{ name: "Jan", value: 100 },
|
|
177
|
+
{ name: "Feb", value: 200 },
|
|
178
|
+
],
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const result = getTransformedData(entity, "value")
|
|
182
|
+
|
|
183
|
+
// When dataKey exists, name fallback uses d[dataKey] first, then d.name
|
|
184
|
+
// So name will be the value (100, 200) if dataKey exists
|
|
185
|
+
expect(result).toEqual([
|
|
186
|
+
{ x: 0, y: 100, name: 100 },
|
|
187
|
+
{ x: 1, y: 200, name: 200 },
|
|
188
|
+
])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("should fallback to y or value if dataKey not found", () => {
|
|
192
|
+
const entity = {
|
|
193
|
+
id: "test",
|
|
194
|
+
data: [{ name: "Jan", y: 100 }],
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const result = getTransformedData(entity, "missingKey")
|
|
198
|
+
|
|
199
|
+
expect(result).toEqual([{ x: 0, y: 100, name: "Jan" }])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("should return null if entity is missing", () => {
|
|
203
|
+
expect(getTransformedData(null, "value")).toBeNull()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it("should return null if entity.data is missing", () => {
|
|
207
|
+
expect(getTransformedData({ id: "test" }, "value")).toBeNull()
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts dataKeys from chart children components automatically.
|
|
3
|
+
* This allows the chart to determine which data fields to use for Y-axis scaling
|
|
4
|
+
* without requiring explicit dataKeys configuration.
|
|
5
|
+
*
|
|
6
|
+
* @param {Array|Function} children - Array of chart child components (Line, Area, Bar, etc.)
|
|
7
|
+
* @returns {Array<string>} Array of unique dataKeys found in the children
|
|
8
|
+
*/
|
|
9
|
+
export function extractDataKeysFromChildren(children) {
|
|
10
|
+
const dataKeys = new Set()
|
|
11
|
+
const childrenArray = Array.isArray(children) ? children : [children]
|
|
12
|
+
|
|
13
|
+
for (const child of childrenArray) {
|
|
14
|
+
if (typeof child === "function") {
|
|
15
|
+
if (child.dataKey) {
|
|
16
|
+
dataKeys.add(child.dataKey)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return Array.from(dataKeys)
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/* eslint-disable no-magic-numbers */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calculate padding based on chart dimensions
|
|
5
|
+
* @param {number} [width=800] - Chart width
|
|
6
|
+
* @param {number} [height=400] - Chart height
|
|
7
|
+
* @returns {Object} Padding object with top, right, bottom, left
|
|
8
|
+
*/
|
|
9
|
+
export function calculatePadding(width = 800, height = 400) {
|
|
10
|
+
return {
|
|
11
|
+
top: Math.max(20, height * 0.05),
|
|
12
|
+
right: Math.max(20, width * 0.05),
|
|
13
|
+
bottom: Math.max(40, height * 0.1),
|
|
14
|
+
left: Math.max(50, width * 0.1),
|
|
15
|
+
}
|
|
16
|
+
}
|