|
| 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 | +} |
0 commit comments