@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,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { svg } from "@inglorious/web"
|
|
5
|
+
import { render } from "@inglorious/web/test"
|
|
6
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
7
|
+
|
|
8
|
+
import { renderLegend } from "./legend.js"
|
|
9
|
+
|
|
10
|
+
describe("renderLegend", () => {
|
|
11
|
+
let entity
|
|
12
|
+
let props
|
|
13
|
+
let api
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
entity = {
|
|
17
|
+
id: "test-chart",
|
|
18
|
+
type: "line",
|
|
19
|
+
data: [],
|
|
20
|
+
width: 800,
|
|
21
|
+
height: 400,
|
|
22
|
+
padding: { top: 20, right: 50, bottom: 30, left: 50 },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
props = {
|
|
26
|
+
series: [
|
|
27
|
+
{ name: "Product A", color: "#3b82f6" },
|
|
28
|
+
{ name: "Product B", color: "#ef4444" },
|
|
29
|
+
{ name: "Product C", color: "#10b981" },
|
|
30
|
+
],
|
|
31
|
+
colors: ["#3b82f6", "#ef4444", "#10b981"],
|
|
32
|
+
width: 800,
|
|
33
|
+
padding: { top: 20, right: 50, bottom: 30, left: 50 },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
api = {
|
|
37
|
+
notify: vi.fn(),
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe("basic rendering", () => {
|
|
42
|
+
it("should render legend with series", () => {
|
|
43
|
+
const result = renderLegend(entity, props, api)
|
|
44
|
+
const container = document.createElement("div")
|
|
45
|
+
// Wrap in SVG since renderLegend returns SVG fragment
|
|
46
|
+
render(svg`<svg>${result}</svg>`, container)
|
|
47
|
+
|
|
48
|
+
const legend = container.querySelector("g.iw-chart-legend-wrapper")
|
|
49
|
+
expect(legend).toBeTruthy()
|
|
50
|
+
|
|
51
|
+
const items = container.querySelectorAll("g.iw-chart-legend-item")
|
|
52
|
+
expect(items.length).toBe(3)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("should render legend items with colors and labels", () => {
|
|
56
|
+
const result = renderLegend(entity, props, api)
|
|
57
|
+
const container = document.createElement("div")
|
|
58
|
+
render(svg`<svg>${result}</svg>`, container)
|
|
59
|
+
|
|
60
|
+
const items = container.querySelectorAll("g.iw-chart-legend-item")
|
|
61
|
+
items.forEach((item, index) => {
|
|
62
|
+
const rect = item.querySelector("rect")
|
|
63
|
+
const text = item.querySelector("text")
|
|
64
|
+
|
|
65
|
+
expect(rect).toBeTruthy()
|
|
66
|
+
expect(text).toBeTruthy()
|
|
67
|
+
expect(rect.getAttribute("fill")).toBe(props.series[index].color)
|
|
68
|
+
expect(text.textContent).toContain(props.series[index].name)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe("with empty series", () => {
|
|
74
|
+
it("should return empty template for empty series", () => {
|
|
75
|
+
props.series = []
|
|
76
|
+
|
|
77
|
+
const result = renderLegend(entity, props, api)
|
|
78
|
+
const container = document.createElement("div")
|
|
79
|
+
render(svg`<svg>${result}</svg>`, container)
|
|
80
|
+
|
|
81
|
+
// Should return empty template
|
|
82
|
+
const legend = container.querySelector("g.iw-chart-legend-wrapper")
|
|
83
|
+
expect(legend).toBeFalsy()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe("with default colors", () => {
|
|
88
|
+
it("should use default colors when series color is not provided", () => {
|
|
89
|
+
props.series = [{ name: "Series A" }, { name: "Series B" }]
|
|
90
|
+
props.colors = ["#3b82f6", "#ef4444"]
|
|
91
|
+
|
|
92
|
+
const result = renderLegend(entity, props, api)
|
|
93
|
+
const container = document.createElement("div")
|
|
94
|
+
render(svg`<svg>${result}</svg>`, container)
|
|
95
|
+
|
|
96
|
+
const items = container.querySelectorAll("g.iw-chart-legend-item")
|
|
97
|
+
expect(items.length).toBe(2)
|
|
98
|
+
|
|
99
|
+
const firstRect = items[0].querySelector("rect")
|
|
100
|
+
expect(firstRect.getAttribute("fill")).toBe("#3b82f6")
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { html } from "@inglorious/web"
|
|
2
|
+
|
|
3
|
+
import { formatNumber } from "../utils/data-utils.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders the chart tooltip overlay.
|
|
7
|
+
* Reused by cartesian charts (line, area, bar).
|
|
8
|
+
*
|
|
9
|
+
* @param {import('../types/charts').ChartEntity} entity
|
|
10
|
+
* @param {Object} props
|
|
11
|
+
* @param {any} api
|
|
12
|
+
* @returns {import('lit-html').TemplateResult}
|
|
13
|
+
*/
|
|
14
|
+
// eslint-disable-next-line no-unused-vars
|
|
15
|
+
export function renderTooltip(entity, props, api) {
|
|
16
|
+
if (!entity?.tooltip) {
|
|
17
|
+
return html``
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return html`
|
|
21
|
+
<div
|
|
22
|
+
class="iw-chart-modal"
|
|
23
|
+
style="left:${entity.tooltipX}px; top:${entity.tooltipY}px"
|
|
24
|
+
>
|
|
25
|
+
<div class="iw-chart-modal-header">
|
|
26
|
+
<span
|
|
27
|
+
class="iw-chart-modal-color"
|
|
28
|
+
style="background-color: ${entity.tooltip.color};"
|
|
29
|
+
></span>
|
|
30
|
+
<span class="iw-chart-modal-label">${entity.tooltip.label}</span>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="iw-chart-modal-body">
|
|
33
|
+
<div class="iw-chart-modal-value">
|
|
34
|
+
${formatNumber(entity.tooltip.value)}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a tooltip component for composition mode (Recharts-style)
|
|
43
|
+
* This factory function returns a component function that can be used in chart composition
|
|
44
|
+
*
|
|
45
|
+
* @returns {Function} Component function that accepts (entity, props, api) and returns a composition function
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* // In line.js, area.js, bar.js:
|
|
49
|
+
* import { createTooltipComponent } from "../component/tooltip.js"
|
|
50
|
+
*
|
|
51
|
+
* export const line = {
|
|
52
|
+
* renderTooltip: createTooltipComponent(),
|
|
53
|
+
* }
|
|
54
|
+
*/
|
|
55
|
+
export function createTooltipComponent() {
|
|
56
|
+
return (entity, props, api) => {
|
|
57
|
+
const tooltipFn = (ctx) => {
|
|
58
|
+
const entityFromContext = ctx.entity || entity
|
|
59
|
+
return renderTooltip(entityFromContext, {}, api)
|
|
60
|
+
}
|
|
61
|
+
// Mark as tooltip component for stable identification during processing
|
|
62
|
+
tooltipFn.isTooltip = true
|
|
63
|
+
return tooltipFn
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { render } from "@inglorious/web/test"
|
|
5
|
+
import { describe, expect, it } from "vitest"
|
|
6
|
+
|
|
7
|
+
import { renderTooltip } from "./tooltip.js"
|
|
8
|
+
|
|
9
|
+
describe("renderTooltip", () => {
|
|
10
|
+
const mockApi = {}
|
|
11
|
+
|
|
12
|
+
it("should render tooltip when entity.tooltip is provided", () => {
|
|
13
|
+
const entity = {
|
|
14
|
+
id: "test",
|
|
15
|
+
data: [{ name: "Jan", value: 100 }],
|
|
16
|
+
tooltip: {
|
|
17
|
+
label: "Jan",
|
|
18
|
+
value: 100,
|
|
19
|
+
color: "#3b82f6",
|
|
20
|
+
},
|
|
21
|
+
tooltipX: 100,
|
|
22
|
+
tooltipY: 200,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const template = renderTooltip(entity, {}, mockApi)
|
|
26
|
+
const container = document.createElement("div")
|
|
27
|
+
render(template, container)
|
|
28
|
+
|
|
29
|
+
const tooltip = container.querySelector(".iw-chart-modal")
|
|
30
|
+
expect(tooltip).toBeTruthy()
|
|
31
|
+
expect(tooltip.textContent).toContain("Jan")
|
|
32
|
+
expect(tooltip.textContent).toContain("100")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("should not render tooltip when entity.tooltip is null", () => {
|
|
36
|
+
const entity = {
|
|
37
|
+
id: "test",
|
|
38
|
+
data: [{ name: "Jan", value: 100 }],
|
|
39
|
+
tooltip: null,
|
|
40
|
+
tooltipX: 100,
|
|
41
|
+
tooltipY: 200,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const template = renderTooltip(entity, {}, mockApi)
|
|
45
|
+
const container = document.createElement("div")
|
|
46
|
+
render(template, container)
|
|
47
|
+
|
|
48
|
+
const tooltip = container.querySelector(".iw-chart-modal")
|
|
49
|
+
expect(tooltip).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("should position tooltip at entity.tooltipX and entity.tooltipY", () => {
|
|
53
|
+
const entity = {
|
|
54
|
+
id: "test",
|
|
55
|
+
data: [{ name: "Jan", value: 100 }],
|
|
56
|
+
tooltip: {
|
|
57
|
+
label: "Jan",
|
|
58
|
+
value: 100,
|
|
59
|
+
},
|
|
60
|
+
tooltipX: 150,
|
|
61
|
+
tooltipY: 250,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const template = renderTooltip(entity, {}, mockApi)
|
|
65
|
+
const container = document.createElement("div")
|
|
66
|
+
render(template, container)
|
|
67
|
+
|
|
68
|
+
const tooltip = container.querySelector(".iw-chart-modal")
|
|
69
|
+
expect(tooltip).toBeTruthy()
|
|
70
|
+
expect(tooltip.style.left).toBe("150px")
|
|
71
|
+
expect(tooltip.style.top).toBe("250px")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("should render tooltip with formatted number", () => {
|
|
75
|
+
const entity = {
|
|
76
|
+
id: "test",
|
|
77
|
+
data: [{ name: "Jan", value: 100 }],
|
|
78
|
+
tooltip: {
|
|
79
|
+
label: "Jan",
|
|
80
|
+
value: 1234.56,
|
|
81
|
+
color: "#3b82f6",
|
|
82
|
+
},
|
|
83
|
+
tooltipX: 100,
|
|
84
|
+
tooltipY: 200,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const template = renderTooltip(entity, {}, mockApi)
|
|
88
|
+
const container = document.createElement("div")
|
|
89
|
+
render(template, container)
|
|
90
|
+
|
|
91
|
+
const tooltip = container.querySelector(".iw-chart-modal")
|
|
92
|
+
expect(tooltip).toBeTruthy()
|
|
93
|
+
// formatNumber uses ",.2f" by default, so 1234.56 becomes "1,234.56"
|
|
94
|
+
expect(tooltip.textContent).toContain("1,234.56")
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/* eslint-disable no-magic-numbers */
|
|
2
|
+
|
|
3
|
+
import { repeat, svg } from "@inglorious/web"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ensureValidNumber,
|
|
7
|
+
formatDate,
|
|
8
|
+
formatNumber,
|
|
9
|
+
isValidNumber,
|
|
10
|
+
} from "../utils/data-utils.js"
|
|
11
|
+
import { calculateXTicks } from "../utils/scales.js"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {any} entity
|
|
15
|
+
* @param {Object} props
|
|
16
|
+
* @param {import('d3-scale').ScaleBand|import('d3-scale').ScaleLinear|import('d3-scale').ScaleTime} props.xScale
|
|
17
|
+
* @param {import('d3-scale').ScaleLinear} props.yScale
|
|
18
|
+
* @param {number} props.width
|
|
19
|
+
* @param {number} props.height
|
|
20
|
+
* @param {Object} props.padding
|
|
21
|
+
* @param {any} api
|
|
22
|
+
* @returns {import('lit-html').TemplateResult}
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// eslint-disable-next-line no-unused-vars
|
|
26
|
+
export function renderXAxis(entity, props, api) {
|
|
27
|
+
const { xScale, yScale, width, height, padding } = props
|
|
28
|
+
|
|
29
|
+
if (xScale.bandwidth) {
|
|
30
|
+
// Following Recharts logic: for scaleBand, use the domain directly
|
|
31
|
+
// and calculate the center as scale(category) + bandwidth() / 2
|
|
32
|
+
const allCategories = xScale.domain()
|
|
33
|
+
if (allCategories.length === 0) {
|
|
34
|
+
return svg`<g class="iw-chart-xAxis"></g>`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Limit number of ticks to avoid overlapping labels
|
|
38
|
+
// Similar to Recharts behavior: show fewer ticks when there are many categories
|
|
39
|
+
let categories = allCategories
|
|
40
|
+
if (allCategories.length > 20) {
|
|
41
|
+
// Calculate optimal number of ticks based on available width
|
|
42
|
+
// Estimate ~60px per label to avoid overlap
|
|
43
|
+
const availableWidth =
|
|
44
|
+
(width || 800) - (padding?.left || 0) - (padding?.right || 0)
|
|
45
|
+
const maxTicks = Math.max(5, Math.floor(availableWidth / 60))
|
|
46
|
+
const step = Math.ceil(allCategories.length / maxTicks)
|
|
47
|
+
categories = allCategories.filter((_, i) => i % step === 0)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let xAxisY = height - padding.bottom
|
|
51
|
+
if (yScale) {
|
|
52
|
+
const domain = yScale.domain()
|
|
53
|
+
if (domain[0] < 0) {
|
|
54
|
+
const zeroY = yScale(0)
|
|
55
|
+
if (isValidNumber(zeroY)) {
|
|
56
|
+
xAxisY = zeroY
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Ensure xAxisY is a valid number
|
|
61
|
+
if (!isValidNumber(xAxisY)) {
|
|
62
|
+
const fallbackY = height - (padding?.bottom || 0)
|
|
63
|
+
xAxisY = ensureValidNumber(fallbackY, height || 0)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Offset for scaleBand: bandwidth() / 2 (as in Recharts)
|
|
67
|
+
// bandwidth() returns the available band width (without internal padding)
|
|
68
|
+
// Following Recharts logic exactly: offsetForBand = bandwidth() / 2
|
|
69
|
+
const bandwidth = xScale.bandwidth()
|
|
70
|
+
if (!isValidNumber(bandwidth) || bandwidth <= 0) {
|
|
71
|
+
// If bandwidth is not valid, don't render the axis
|
|
72
|
+
return svg`<g class="iw-chart-xAxis"></g>`
|
|
73
|
+
}
|
|
74
|
+
const offsetForBand = bandwidth / 2
|
|
75
|
+
|
|
76
|
+
return svg`
|
|
77
|
+
<g class="iw-chart-xAxis">
|
|
78
|
+
<!-- X Axis Line -->
|
|
79
|
+
<line
|
|
80
|
+
x1=${padding?.left || 0}
|
|
81
|
+
y1=${xAxisY}
|
|
82
|
+
x2=${(width || 0) - (padding?.right || 0)}
|
|
83
|
+
y2=${xAxisY}
|
|
84
|
+
stroke="#ddd"
|
|
85
|
+
stroke-width="0.0625em"
|
|
86
|
+
class="iw-chart-xAxis-line"
|
|
87
|
+
/>
|
|
88
|
+
${repeat(
|
|
89
|
+
categories,
|
|
90
|
+
(cat) => cat,
|
|
91
|
+
(cat) => {
|
|
92
|
+
// Following Recharts: coordinate = scale(category) + offset
|
|
93
|
+
// where offset = bandwidth() / 2 for scaleBand
|
|
94
|
+
// xScale(cat) returns the initial position of the band (including external padding)
|
|
95
|
+
// We add bandwidth() / 2 to get the center of the band
|
|
96
|
+
const scaled = xScale(cat)
|
|
97
|
+
if (scaled == null || !isValidNumber(scaled)) {
|
|
98
|
+
return svg``
|
|
99
|
+
}
|
|
100
|
+
const coordinate = scaled + offsetForBand
|
|
101
|
+
// Ensure coordinate is a valid number
|
|
102
|
+
if (!isValidNumber(coordinate)) {
|
|
103
|
+
return svg``
|
|
104
|
+
}
|
|
105
|
+
return svg`
|
|
106
|
+
<g class="iw-chart-xAxis-tick">
|
|
107
|
+
<!-- Tick line (vertical line) - uses the same coordinate -->
|
|
108
|
+
<line
|
|
109
|
+
x1=${coordinate}
|
|
110
|
+
y1=${xAxisY}
|
|
111
|
+
x2=${coordinate}
|
|
112
|
+
y2=${xAxisY + 5}
|
|
113
|
+
stroke="#ccc"
|
|
114
|
+
stroke-width="0.0625em"
|
|
115
|
+
/>
|
|
116
|
+
<!-- Label - usa a mesma coordinate -->
|
|
117
|
+
<text
|
|
118
|
+
x=${coordinate}
|
|
119
|
+
y=${xAxisY + 20}
|
|
120
|
+
text-anchor="middle"
|
|
121
|
+
font-size="0.6875em"
|
|
122
|
+
fill="#777"
|
|
123
|
+
class="iw-chart-xAxis-tick-label"
|
|
124
|
+
>
|
|
125
|
+
${cat}
|
|
126
|
+
</text>
|
|
127
|
+
</g>
|
|
128
|
+
`
|
|
129
|
+
},
|
|
130
|
+
)}
|
|
131
|
+
</g>
|
|
132
|
+
`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Calculate ticks using helper function
|
|
136
|
+
const ticks = xScale.bandwidth
|
|
137
|
+
? xScale.domain()
|
|
138
|
+
: calculateXTicks(entity.data, xScale)
|
|
139
|
+
|
|
140
|
+
let xAxisY = height - padding.bottom
|
|
141
|
+
if (yScale) {
|
|
142
|
+
const yDomain = yScale.domain()
|
|
143
|
+
if (yDomain[0] < 0) {
|
|
144
|
+
const zeroY = yScale(0)
|
|
145
|
+
if (isValidNumber(zeroY)) {
|
|
146
|
+
xAxisY = zeroY
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Ensure xAxisY is a valid number
|
|
151
|
+
if (!isValidNumber(xAxisY)) {
|
|
152
|
+
const fallbackY = height - (padding?.bottom || 0)
|
|
153
|
+
xAxisY = ensureValidNumber(fallbackY, height || 0)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// If entity has xLabels, use them for display (for categorical data)
|
|
157
|
+
const useLabels = entity.xLabels && Array.isArray(entity.xLabels)
|
|
158
|
+
|
|
159
|
+
return svg`
|
|
160
|
+
<g class="iw-chart-xAxis">
|
|
161
|
+
<!-- X Axis Line -->
|
|
162
|
+
<line
|
|
163
|
+
x1=${padding?.left || 0}
|
|
164
|
+
y1=${xAxisY}
|
|
165
|
+
x2=${(width || 0) - (padding?.right || 0)}
|
|
166
|
+
y2=${xAxisY}
|
|
167
|
+
stroke="#ddd"
|
|
168
|
+
stroke-width="0.0625em"
|
|
169
|
+
class="iw-chart-xAxis-line"
|
|
170
|
+
/>
|
|
171
|
+
${repeat(
|
|
172
|
+
ticks,
|
|
173
|
+
(t) => t,
|
|
174
|
+
(t, i) => {
|
|
175
|
+
const x = xScale(t)
|
|
176
|
+
// Ensure x is a valid number
|
|
177
|
+
if (!isValidNumber(x)) {
|
|
178
|
+
return svg``
|
|
179
|
+
}
|
|
180
|
+
// Use custom labels if available, otherwise format the tick value
|
|
181
|
+
const label =
|
|
182
|
+
useLabels && entity.xLabels[i] !== undefined
|
|
183
|
+
? entity.xLabels[i]
|
|
184
|
+
: entity.xAxisType === "time"
|
|
185
|
+
? formatDate(t)
|
|
186
|
+
: formatNumber(t)
|
|
187
|
+
|
|
188
|
+
return svg`
|
|
189
|
+
<g class="iw-chart-xAxis-tick">
|
|
190
|
+
<line
|
|
191
|
+
x1=${x}
|
|
192
|
+
y1=${xAxisY}
|
|
193
|
+
x2=${x}
|
|
194
|
+
y2=${xAxisY + 5}
|
|
195
|
+
stroke="#ccc"
|
|
196
|
+
stroke-width="0.0625em"
|
|
197
|
+
/>
|
|
198
|
+
<text
|
|
199
|
+
x=${x}
|
|
200
|
+
y=${xAxisY + 20}
|
|
201
|
+
text-anchor="middle"
|
|
202
|
+
font-size="0.6875em"
|
|
203
|
+
fill="#777"
|
|
204
|
+
class="iw-chart-xAxis-tick-label"
|
|
205
|
+
>${label}</text>
|
|
206
|
+
</g>
|
|
207
|
+
`
|
|
208
|
+
},
|
|
209
|
+
)}
|
|
210
|
+
</g>
|
|
211
|
+
`
|
|
212
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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 { renderXAxis } from "./x-axis.js"
|
|
8
|
+
|
|
9
|
+
describe("renderXAxis", () => {
|
|
10
|
+
let entity
|
|
11
|
+
let props
|
|
12
|
+
let api
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
entity = {
|
|
16
|
+
id: "test-chart",
|
|
17
|
+
type: "line",
|
|
18
|
+
data: [
|
|
19
|
+
{ x: 0, y: 50 },
|
|
20
|
+
{ x: 1, y: 150 },
|
|
21
|
+
{ x: 2, y: 120 },
|
|
22
|
+
],
|
|
23
|
+
width: 800,
|
|
24
|
+
height: 400,
|
|
25
|
+
padding: { top: 20, right: 50, bottom: 30, left: 50 },
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
props = {
|
|
29
|
+
xScale: vi.fn((x) => 50 + x * 200),
|
|
30
|
+
yScale: vi.fn((y) => 370 - (y / 200) * 350),
|
|
31
|
+
width: 800,
|
|
32
|
+
height: 400,
|
|
33
|
+
padding: { top: 20, right: 50, bottom: 30, left: 50 },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
props.xScale.domain = vi.fn(() => [0, 2])
|
|
37
|
+
props.xScale.range = vi.fn(() => [50, 650])
|
|
38
|
+
props.xScale.bandwidth = vi.fn(() => 200)
|
|
39
|
+
|
|
40
|
+
props.yScale.domain = vi.fn(() => [0, 200])
|
|
41
|
+
props.yScale.range = vi.fn(() => [370, 20])
|
|
42
|
+
|
|
43
|
+
api = {
|
|
44
|
+
notify: vi.fn(),
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe("with scaleBand (categorical)", () => {
|
|
49
|
+
it("should render X axis with band scale", () => {
|
|
50
|
+
entity.data = [
|
|
51
|
+
{ label: "Jan", value: 100 },
|
|
52
|
+
{ label: "Feb", value: 150 },
|
|
53
|
+
{ label: "Mar", value: 120 },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
const mockBandScale = vi.fn((cat) => {
|
|
57
|
+
const map = { Jan: 100, Feb: 350, Mar: 600 }
|
|
58
|
+
return map[cat] || 0
|
|
59
|
+
})
|
|
60
|
+
mockBandScale.domain = vi.fn(() => ["Jan", "Feb", "Mar"])
|
|
61
|
+
mockBandScale.range = vi.fn(() => [50, 750])
|
|
62
|
+
mockBandScale.bandwidth = vi.fn(() => 200)
|
|
63
|
+
|
|
64
|
+
props.xScale = mockBandScale
|
|
65
|
+
|
|
66
|
+
const result = renderXAxis(entity, props, api)
|
|
67
|
+
const container = document.createElement("div")
|
|
68
|
+
render(result, container)
|
|
69
|
+
|
|
70
|
+
const axisLine = container.querySelector("line")
|
|
71
|
+
expect(axisLine).toBeTruthy()
|
|
72
|
+
|
|
73
|
+
const ticks = container.querySelectorAll("g.iw-chart-xAxis-tick")
|
|
74
|
+
expect(ticks.length).toBe(3)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe("with scaleLinear (numeric)", () => {
|
|
79
|
+
it("should render X axis with linear scale", () => {
|
|
80
|
+
const result = renderXAxis(entity, props, api)
|
|
81
|
+
const container = document.createElement("div")
|
|
82
|
+
render(result, container)
|
|
83
|
+
|
|
84
|
+
const axisLine = container.querySelector("line")
|
|
85
|
+
expect(axisLine).toBeTruthy()
|
|
86
|
+
|
|
87
|
+
const ticks = container.querySelectorAll("g.iw-chart-xAxis-tick")
|
|
88
|
+
expect(ticks.length).toBeGreaterThan(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("should format integer ticks without decimals", () => {
|
|
92
|
+
const result = renderXAxis(entity, props, api)
|
|
93
|
+
const container = document.createElement("div")
|
|
94
|
+
render(result, container)
|
|
95
|
+
|
|
96
|
+
const labels = container.querySelectorAll(".iw-chart-xAxis-tick-label")
|
|
97
|
+
labels.forEach((label) => {
|
|
98
|
+
const text = label.textContent.trim()
|
|
99
|
+
// Should not contain ".00" for integers
|
|
100
|
+
if (text.match(/^\d+$/)) {
|
|
101
|
+
expect(text).not.toContain(".00")
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe("with xLabels (custom labels)", () => {
|
|
108
|
+
it("should use custom xLabels when provided", () => {
|
|
109
|
+
entity.xLabels = ["Q1", "Q2", "Q3"]
|
|
110
|
+
entity.data = [
|
|
111
|
+
{ x: 0, y: 50 },
|
|
112
|
+
{ x: 1, y: 150 },
|
|
113
|
+
{ x: 2, y: 120 },
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
// Mock calculateXTicks to return [0, 1, 2]
|
|
117
|
+
const result = renderXAxis(entity, props, api)
|
|
118
|
+
const container = document.createElement("div")
|
|
119
|
+
render(result, container)
|
|
120
|
+
|
|
121
|
+
const labels = container.querySelectorAll(".iw-chart-xAxis-tick-label")
|
|
122
|
+
// Should use xLabels when available
|
|
123
|
+
expect(labels.length).toBeGreaterThan(0)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe("with time axis", () => {
|
|
128
|
+
it("should format dates when xAxisType is time", () => {
|
|
129
|
+
entity.xAxisType = "time"
|
|
130
|
+
entity.data = [
|
|
131
|
+
{ date: "2024-01-01", value: 100 },
|
|
132
|
+
{ date: "2024-01-02", value: 150 },
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
props.xScale.domain = vi.fn(() => [
|
|
136
|
+
new Date("2024-01-01"),
|
|
137
|
+
new Date("2024-01-02"),
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
const result = renderXAxis(entity, props, api)
|
|
141
|
+
const container = document.createElement("div")
|
|
142
|
+
render(result, container)
|
|
143
|
+
|
|
144
|
+
const axisLine = container.querySelector("line")
|
|
145
|
+
expect(axisLine).toBeTruthy()
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/* eslint-disable no-magic-numbers */
|
|
2
|
+
|
|
3
|
+
import { repeat, svg } from "@inglorious/web"
|
|
4
|
+
|
|
5
|
+
import { formatNumber, isValidNumber } from "../utils/data-utils.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {any} entity
|
|
9
|
+
* @param {Object} props
|
|
10
|
+
* @param {import('d3-scale').ScaleLinear} props.yScale
|
|
11
|
+
* @param {number} props.height
|
|
12
|
+
* @param {Object} props.padding
|
|
13
|
+
* @param {Array} props.customTicks
|
|
14
|
+
* @param {any} api
|
|
15
|
+
* @returns {import('lit-html').TemplateResult}
|
|
16
|
+
*/
|
|
17
|
+
// eslint-disable-next-line no-unused-vars
|
|
18
|
+
export function renderYAxis(entity, props, api) {
|
|
19
|
+
const { yScale, height, padding, customTicks } = props
|
|
20
|
+
// Use custom ticks if provided, otherwise use scale ticks
|
|
21
|
+
const ticks =
|
|
22
|
+
customTicks && Array.isArray(customTicks) ? customTicks : yScale.ticks(5)
|
|
23
|
+
|
|
24
|
+
// Ensure height and padding are valid numbers
|
|
25
|
+
const axisLineY2 = height - padding.bottom
|
|
26
|
+
if (!isValidNumber(axisLineY2)) {
|
|
27
|
+
return svg``
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return svg`
|
|
31
|
+
<g class="iw-chart-yAxis">
|
|
32
|
+
<!-- Y Axis Line -->
|
|
33
|
+
<line
|
|
34
|
+
x1=${padding.left}
|
|
35
|
+
y1=${padding.top}
|
|
36
|
+
x2=${padding.left}
|
|
37
|
+
y2=${axisLineY2}
|
|
38
|
+
stroke="#ddd"
|
|
39
|
+
stroke-width="0.0625em"
|
|
40
|
+
class="iw-chart-yAxis-line"
|
|
41
|
+
/>
|
|
42
|
+
${repeat(
|
|
43
|
+
ticks,
|
|
44
|
+
(t) => t,
|
|
45
|
+
(t) => {
|
|
46
|
+
const y = yScale(t)
|
|
47
|
+
// Ensure y is a valid number
|
|
48
|
+
if (!isValidNumber(y)) {
|
|
49
|
+
return svg``
|
|
50
|
+
}
|
|
51
|
+
return svg`
|
|
52
|
+
<g class="iw-chart-yAxis-tick">
|
|
53
|
+
<line
|
|
54
|
+
x1=${padding.left}
|
|
55
|
+
y1=${y}
|
|
56
|
+
x2=${padding.left - 5}
|
|
57
|
+
y2=${y}
|
|
58
|
+
stroke="#ccc"
|
|
59
|
+
stroke-width="0.0625em"
|
|
60
|
+
/>
|
|
61
|
+
<text
|
|
62
|
+
x=${padding.left - 10}
|
|
63
|
+
y=${y + 4}
|
|
64
|
+
text-anchor="end"
|
|
65
|
+
font-size="0.6875em"
|
|
66
|
+
fill="#777"
|
|
67
|
+
class="iw-chart-yAxis-tick-label"
|
|
68
|
+
>
|
|
69
|
+
${formatNumber(t)}
|
|
70
|
+
</text>
|
|
71
|
+
</g>
|
|
72
|
+
`
|
|
73
|
+
},
|
|
74
|
+
)}
|
|
75
|
+
</g>
|
|
76
|
+
`
|
|
77
|
+
}
|