Skip to content

Commit 41ef810

Browse files
committed
feat(chart): add new component
1 parent 43930a2 commit 41ef810

24 files changed

+1561
-0
lines changed

src/components/chart/chart.scss

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @prop --chart-background-color: Defines the background color of the chart. Defaults to `transparent` for _most_ chart types.
3+
* @prop --chart-axis-line-color: Defines color of the axis lines. Defaults to `--contrast-900`. Note that lines have opacity as well, and get opaque on hover.
4+
* @prop --chart-item-border-radius: Defines the roundness of corners of items in a chart. Defaults to different values depending on the chart type. Does not have any effect on `pie` and `doughnut` types.
5+
* @prop --chart-stacked-item-divider-color: Defines the color that visually separates stacked chart items, for example for a `stacked-bar` chart. Defaults to `rgb(var(--color-white), 0.6)`.
6+
*/
7+
8+
*,
9+
*:before,
10+
*:after {
11+
box-sizing: border-box;
12+
}
13+
14+
:host(limel-chart) {
15+
--chart-axis-line-color: var(
16+
--limel-chart-axis-line-color,
17+
rgb(var(--contrast-900))
18+
);
19+
isolation: isolate;
20+
position: relative;
21+
box-sizing: border-box;
22+
padding: var(--limel-chart-padding);
23+
}
24+
25+
.chart {
26+
position: relative;
27+
flex-grow: 1;
28+
width: 100%;
29+
height: 100%;
30+
31+
&:has(.item:hover),
32+
&:has(.item:focus-visible) {
33+
.item {
34+
opacity: 0.4;
35+
}
36+
}
37+
}
38+
39+
.item {
40+
transition:
41+
background-color 0.2s ease,
42+
opacity 0.4s ease;
43+
cursor: help;
44+
mix-blend-mode: hard-light;
45+
46+
&:focus-visible,
47+
&:hover {
48+
opacity: 1 !important;
49+
}
50+
}
51+
52+
@import './partial-styles/_bar-gantt-scatter';
53+
@import './partial-styles/_pie-doughnut';
54+
@import './partial-styles/_stacked-bar';
55+
@import './partial-styles/_axises';

src/components/chart/chart.tsx

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import { Component, h, Prop, Watch } from '@stencil/core';
2+
import { ChartItem } from './chart.types';
3+
import { createRandomString } from '../../util/random-string';
4+
5+
const PERCENT = 100;
6+
const DEFAULT_AXIS_INCREMENT = 10;
7+
8+
/**
9+
* A chart is a graphical representation of data, in which
10+
* visual symbols such as such bars, dots, lines, or slices, represent
11+
* each data point, in comparison to others.
12+
*
13+
* @exampleComponent limel-example-chart-stacked-bar
14+
* @exampleComponent limel-example-chart-orientation
15+
* @exampleComponent limel-example-chart-max-value
16+
* @exampleComponent limel-example-chart-type-bar
17+
* @exampleComponent limel-example-chart-type-scatter
18+
* @exampleComponent limel-example-chart-type-doughnut
19+
* @exampleComponent limel-example-chart-type-pie
20+
* @exampleComponent limel-example-chart-type-gantt
21+
* @exampleComponent limel-example-chart-multi-axis
22+
* @exampleComponent limel-example-chart-styling
23+
* @Beta
24+
*/
25+
26+
@Component({
27+
tag: 'limel-chart',
28+
shadow: true,
29+
styleUrl: 'chart.scss',
30+
})
31+
export class Chart {
32+
/**
33+
* List of items in the chart,
34+
* each representing a data point.
35+
*/
36+
@Prop()
37+
public items!: ChartItem[];
38+
39+
/**
40+
* Defines how items are visualized in the chart.
41+
*/
42+
@Prop({ reflect: true })
43+
public type?: 'bar' | 'stacked-bar' | 'pie' | 'doughnut' | 'scatter' =
44+
'stacked-bar';
45+
46+
/**
47+
* Defines whether the chart is intended to be displayed wide or tall.
48+
* Does not have any effect on chart types which generate circular forms.
49+
*/
50+
@Prop({ reflect: true })
51+
public orientation?: 'landscape' | 'portrait' = 'landscape';
52+
53+
/**
54+
* Specifies the range that items' values could be in.
55+
* This is used in calculation of the size of the items in the chart.
56+
* When not provided, the sum of all values in the items will be considered as the range.
57+
*/
58+
@Prop({ reflect: true })
59+
public maxValue?: number;
60+
61+
/**
62+
* Specifies the increment for the axis lines.
63+
*/
64+
@Prop({ reflect: true })
65+
public axisIncrement?: number = DEFAULT_AXIS_INCREMENT;
66+
67+
/**
68+
*
69+
*/
70+
@Prop({ reflect: true })
71+
public legend?: boolean = true;
72+
73+
private rangeData: {
74+
minRange: number;
75+
maxRange: number;
76+
totalRange: number;
77+
};
78+
79+
public componentWillLoad() {
80+
this.recalculateRangeData();
81+
}
82+
83+
public render() {
84+
if (!this.items || this.items.length === 0) {
85+
return;
86+
}
87+
88+
return (
89+
<div class="chart">
90+
{this.renderAxises()}
91+
{this.renderItems()}
92+
</div>
93+
);
94+
}
95+
96+
private renderAxises() {
97+
if (this.type !== 'bar' && this.type !== 'scatter') {
98+
return;
99+
}
100+
101+
const { minRange, maxRange } = this.rangeData;
102+
103+
const lines = [];
104+
105+
const adjustedMinRange =
106+
Math.floor(minRange / this.axisIncrement) * this.axisIncrement;
107+
const adjustedMaxRange =
108+
Math.ceil(maxRange / this.axisIncrement) * this.axisIncrement;
109+
110+
for (
111+
let value = adjustedMinRange;
112+
value <= adjustedMaxRange;
113+
value += this.axisIncrement
114+
) {
115+
lines.push(
116+
<div
117+
class={{
118+
'axis-line': true,
119+
'zero-line': value === 0,
120+
}}
121+
role="presentation"
122+
>
123+
<span>{value}</span>
124+
</div>,
125+
);
126+
}
127+
128+
return (
129+
<div class="axises" role="presentation">
130+
{lines}
131+
</div>
132+
);
133+
}
134+
135+
private renderItems() {
136+
const { minRange, totalRange } = this.rangeData;
137+
138+
let cumulativeOffset = 0;
139+
140+
return this.items.map((item, index) => {
141+
const itemId = createRandomString();
142+
143+
const normalizedStart =
144+
(((item.startValue ?? 0) - minRange) / totalRange) * PERCENT;
145+
const normalizedEnd =
146+
((item.value - minRange) / totalRange) * PERCENT;
147+
const size = normalizedEnd - normalizedStart;
148+
149+
let offset = normalizedStart;
150+
151+
if (this.type === 'pie' || this.type === 'doughnut') {
152+
offset = cumulativeOffset;
153+
cumulativeOffset += size;
154+
}
155+
156+
return [
157+
<span
158+
style={{
159+
'--limel-chart-item-color': item.color,
160+
'--limel-chart-item-offset': `${offset}`,
161+
'--limel-chart-item-size': `${size}`,
162+
'--limel-chart-item-index': `${index + 1}`,
163+
}}
164+
class={{
165+
item: true,
166+
'has-start-value': item.startValue !== undefined,
167+
}}
168+
key={itemId}
169+
id={itemId}
170+
// data-item-text={item.text}
171+
tabIndex={0}
172+
/>,
173+
this.renderTooltip(
174+
itemId,
175+
item.text,
176+
item.value,
177+
size,
178+
item.startValue,
179+
item.prefix,
180+
item.suffix,
181+
),
182+
];
183+
});
184+
}
185+
186+
private renderTooltip(
187+
itemId: string,
188+
text: string,
189+
value: number,
190+
size: number,
191+
startValue?: number,
192+
prefix: string = '',
193+
suffix: string = '',
194+
) {
195+
const PERCENT_DECIMAL = 2;
196+
const noStartValue = `${prefix}${value}${suffix}`;
197+
const withStartValue = `${prefix}${startValue}${suffix}${prefix}${value}${suffix}`;
198+
const formattedValue =
199+
startValue !== undefined ? withStartValue : noStartValue;
200+
201+
const tooltipProps: any = {
202+
label: `${text}`,
203+
helperLabel: `${formattedValue}`,
204+
elementId: itemId,
205+
};
206+
207+
if (this.type !== 'bar' && this.type !== 'scatter') {
208+
tooltipProps.label = `${text} (${size.toFixed(PERCENT_DECIMAL)}%)`;
209+
}
210+
211+
return (
212+
<limel-tooltip
213+
{...tooltipProps}
214+
openDirection={
215+
this.orientation === 'portrait' ? 'right' : 'top'
216+
}
217+
/>
218+
);
219+
}
220+
221+
private calculateRange() {
222+
const minRange = Math.min(
223+
...[].concat(
224+
...this.items.map((item) => [item.startValue ?? 0, item.value]),
225+
),
226+
);
227+
const maxRange = Math.max(
228+
...[].concat(
229+
...this.items.map((item) => [item.startValue ?? 0, item.value]),
230+
),
231+
);
232+
233+
// Calculate total sum of all item values for pie and doughnut charts
234+
const totalSum = this.items.reduce((sum, item) => sum + item.value, 0);
235+
236+
// Determine final max range based on chart type and maxValue prop
237+
let finalMaxRange;
238+
if (
239+
(this.type === 'pie' || this.type === 'doughnut') &&
240+
!this.maxValue
241+
) {
242+
finalMaxRange = totalSum;
243+
} else {
244+
finalMaxRange = this.maxValue ?? maxRange;
245+
}
246+
247+
// Adjust finalMaxRange to the nearest multiple of axisIncrement
248+
const visualMaxRange =
249+
Math.ceil(finalMaxRange / this.axisIncrement) * this.axisIncrement;
250+
251+
// Adjust minRange to the nearest multiple of axisIncrement (this is the first axis line)
252+
const visualMinRange =
253+
Math.floor(minRange / this.axisIncrement) * this.axisIncrement;
254+
255+
const totalRange = visualMaxRange - visualMinRange;
256+
257+
return {
258+
minRange: visualMinRange, // Use visualMinRange for calculating the offset
259+
maxRange: visualMaxRange, // Use visualMaxRange for alignment with axis lines
260+
totalRange: totalRange,
261+
};
262+
}
263+
264+
@Watch('items')
265+
handleItemsChange() {
266+
this.recalculateRangeData();
267+
}
268+
269+
@Watch('range')
270+
handleRangeChange() {
271+
this.recalculateRangeData();
272+
}
273+
274+
private recalculateRangeData() {
275+
this.rangeData = this.calculateRange();
276+
}
277+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Chart component props.
3+
* @public
4+
*/
5+
export interface ChartItem {
6+
/**
7+
* label displayed for the item.
8+
*/
9+
text: string;
10+
11+
/**
12+
* value of the item.
13+
*/
14+
value: number;
15+
16+
/**
17+
* Defines the starting value of those items
18+
* which are not a single data point, and instead
19+
* represent a range.
20+
*
21+
* :::important
22+
* The `startValue` should always be smaller than the `value`!
23+
* :::
24+
*/
25+
startValue?: number;
26+
27+
/**
28+
* A prefix shown before the value
29+
*/
30+
prefix?: string;
31+
32+
/**
33+
* A suffix shown after the value.
34+
*/
35+
suffix?: string;
36+
37+
/**
38+
* color of the item in the chart.
39+
*/
40+
color: string;
41+
}

0 commit comments

Comments
 (0)