@ds-mo/ui 2.10.4 → 2.11.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/dist/.build-stamp +1 -1
- package/dist/components/ds-app-shell.js +1 -1
- package/dist/components/ds-banner.js +1 -1
- package/dist/components/ds-bar-nav.js +1 -1
- package/dist/components/ds-button-filled.js +1 -1
- package/dist/components/ds-button-unfilled-icon.js +1 -1
- package/dist/components/ds-card-data-viz.d.ts +11 -0
- package/dist/components/ds-card-data-viz.js +2 -0
- package/dist/components/ds-card-data-viz.js.map +1 -0
- package/dist/components/ds-card-setting.js +1 -1
- package/dist/components/{ds-card.d.ts → ds-chart-bar.d.ts} +4 -4
- package/dist/components/ds-chart-bar.js +2 -0
- package/dist/components/ds-chart-bar.js.map +1 -0
- package/dist/components/ds-chart-donut.d.ts +11 -0
- package/dist/components/ds-chart-donut.js +2 -0
- package/dist/components/ds-chart-donut.js.map +1 -0
- package/dist/components/ds-chart-legend.d.ts +11 -0
- package/dist/components/ds-chart-legend.js +2 -0
- package/dist/components/ds-chart-legend.js.map +1 -0
- package/dist/components/ds-chart-line.d.ts +11 -0
- package/dist/components/ds-chart-line.js +2 -0
- package/dist/components/ds-chart-line.js.map +1 -0
- package/dist/components/ds-checkbox.js +1 -1
- package/dist/components/ds-chip.js +1 -1
- package/dist/components/ds-divider.js +1 -1
- package/dist/components/ds-empty-state.js +1 -1
- package/dist/components/ds-empty-state.js.map +1 -1
- package/dist/components/ds-fade.js +1 -1
- package/dist/components/ds-field.js +1 -1
- package/dist/components/ds-header.js +1 -1
- package/dist/components/ds-header.js.map +1 -1
- package/dist/components/ds-icon.js +1 -1
- package/dist/components/ds-input.js +1 -1
- package/dist/components/ds-loader.js +1 -1
- package/dist/components/ds-menu.js +1 -1
- package/dist/components/ds-modal.js +1 -1
- package/dist/components/ds-panel-nav.js +1 -1
- package/dist/components/ds-panel-tools.js +1 -1
- package/dist/components/ds-radio-group.js +1 -1
- package/dist/components/ds-scrollbar.js +1 -1
- package/dist/components/ds-select.js +1 -1
- package/dist/components/ds-shell-gradient-picker.js +1 -1
- package/dist/components/ds-shell-gradient-swatch.js +1 -1
- package/dist/components/ds-slider.js +1 -1
- package/dist/components/ds-surface.js +1 -1
- package/dist/components/ds-tab-group-nav.js +1 -1
- package/dist/components/ds-tab-group.js +1 -1
- package/dist/components/ds-table.js +1 -1
- package/dist/components/ds-table.js.map +1 -1
- package/dist/components/ds-tag.js +1 -1
- package/dist/components/ds-text.js +1 -1
- package/dist/components/ds-toast-provider.js +1 -1
- package/dist/components/ds-toggle.js +1 -1
- package/dist/components/ds-tooltip-data-viz.d.ts +11 -0
- package/dist/components/ds-tooltip-data-viz.js +2 -0
- package/dist/components/ds-tooltip-data-viz.js.map +1 -0
- package/dist/components/ds-tooltip.js +1 -1
- package/dist/components/{p-BdP53fjF.js → p-8Nt92j1-.js} +2 -2
- package/dist/components/{p-DFAxTEe4.js → p-BMQbEIrQ.js} +2 -2
- package/dist/components/{p-Ba1QXRnN.js → p-BSFgy5r6.js} +2 -2
- package/dist/components/p-CMv_7vFs.js +2 -0
- package/dist/components/p-CMv_7vFs.js.map +1 -0
- package/dist/components/{p-CRh-MLwj.js → p-ChVwCVHy.js} +2 -2
- package/dist/components/{p-TuuGOzXH.js → p-CpNIQXz6.js} +2 -2
- package/dist/components/p-CrCGIx8s.js +2 -0
- package/dist/components/p-CrCGIx8s.js.map +1 -0
- package/dist/components/{p-BXI096cJ.js → p-CvgHQfvT.js} +2 -2
- package/dist/components/p-CzBnvtcz.js +2 -0
- package/dist/components/p-CzBnvtcz.js.map +1 -0
- package/dist/components/p-DEpLlQZK.js +2 -0
- package/dist/components/p-DEpLlQZK.js.map +1 -0
- package/dist/components/{p-BGZjpSXm.js → p-DOU4cbpc.js} +2 -2
- package/dist/components/p-DdFUTZ3y.js +2 -0
- package/dist/components/{p-eOWUz57C.js.map → p-DdFUTZ3y.js.map} +1 -1
- package/dist/components/p-bDf6yBQd.js +2 -0
- package/dist/components/p-bDf6yBQd.js.map +1 -0
- package/dist/lib/nav/index.js +7 -0
- package/dist/lib/nav/index.js.map +2 -2
- package/dist/lib/utils/format-compact-number.d.ts +2 -0
- package/dist/lib/utils/index.d.ts +2 -0
- package/dist/lib/utils/index.js +59 -1
- package/dist/lib/utils/index.js.map +3 -3
- package/dist/lib/utils/token-defaults.d.ts +7 -0
- package/dist/lib/utils/truncate-svg-text.d.ts +9 -0
- package/dist/types/components/CardDataViz/CardDataViz.d.ts +18 -0
- package/dist/types/components/CardDataViz/index.d.ts +2 -0
- package/dist/types/components/ChartBar/ChartBar.d.ts +9 -0
- package/dist/types/components/ChartBar/index.d.ts +2 -0
- package/dist/types/components/ChartDonut/ChartDonut.d.ts +35 -0
- package/dist/types/components/ChartDonut/index.d.ts +2 -0
- package/dist/types/components/ChartLegend/ChartLegend.d.ts +38 -0
- package/dist/types/components/ChartLegend/index.d.ts +3 -0
- package/dist/types/components/ChartLine/ChartLine.d.ts +12 -0
- package/dist/types/components/ChartLine/index.d.ts +2 -0
- package/dist/types/components/TooltipDataViz/TooltipDataViz.d.ts +28 -0
- package/dist/types/components/TooltipDataViz/index.d.ts +2 -0
- package/dist/types/components.d.ts +470 -23
- package/dist/types/utils/chart-colors.d.ts +2 -0
- package/dist/types/utils/chart-types.d.ts +23 -0
- package/dist/types/utils/format-compact-number.d.ts +3 -0
- package/dist/types/utils/index.d.ts +2 -0
- package/dist/types/utils/token-defaults.d.ts +7 -0
- package/dist/types/utils/truncate-svg-text.d.ts +10 -0
- package/package.json +12 -4
- package/src/angular/index.ts +7 -2
- package/src/angular/proxies.ts +144 -6
- package/src/react/components.ts +6 -1
- package/src/react/ds-card-data-viz.ts +26 -0
- package/src/react/{ds-card.ts → ds-chart-bar.ts} +7 -7
- package/src/react/ds-chart-donut.ts +27 -0
- package/src/react/ds-chart-legend.ts +33 -0
- package/src/react/ds-chart-line.ts +26 -0
- package/src/react/ds-tooltip-data-viz.ts +26 -0
- package/src/wc/components/CardDataViz/CardDataViz.tsx +67 -0
- package/src/wc/components/CardDataViz/index.ts +1 -0
- package/src/wc/components/ChartBar/ChartBar.tsx +88 -0
- package/src/wc/components/ChartBar/index.ts +1 -0
- package/src/wc/components/ChartDonut/ChartDonut.tsx +184 -0
- package/src/wc/components/ChartDonut/index.ts +1 -0
- package/src/wc/components/ChartLegend/ChartLegend.tsx +114 -0
- package/src/wc/components/ChartLegend/index.ts +2 -0
- package/src/wc/components/ChartLine/ChartLine.tsx +102 -0
- package/src/wc/components/ChartLine/index.ts +1 -0
- package/src/wc/components/TooltipDataViz/TooltipDataViz.tsx +82 -0
- package/src/wc/components/TooltipDataViz/index.ts +1 -0
- package/src/wc/components.d.ts +470 -23
- package/src/wc/utils/chart-colors.ts +7 -0
- package/src/wc/utils/chart-types.ts +25 -0
- package/src/wc/utils/format-compact-number.ts +27 -0
- package/src/wc/utils/index.ts +2 -0
- package/src/wc/utils/token-defaults.ts +14 -0
- package/src/wc/utils/truncate-svg-text.ts +25 -0
- package/dist/components/ds-card.js +0 -2
- package/dist/components/ds-card.js.map +0 -1
- package/dist/components/p-eOWUz57C.js +0 -2
- package/dist/components/p-jjfqrXnP.js +0 -2
- package/dist/components/p-jjfqrXnP.js.map +0 -1
- package/dist/types/components/Card/Card.d.ts +0 -8
- package/src/wc/components/Card/Card.tsx +0 -26
- /package/dist/components/{p-BdP53fjF.js.map → p-8Nt92j1-.js.map} +0 -0
- /package/dist/components/{p-DFAxTEe4.js.map → p-BMQbEIrQ.js.map} +0 -0
- /package/dist/components/{p-Ba1QXRnN.js.map → p-BSFgy5r6.js.map} +0 -0
- /package/dist/components/{p-CRh-MLwj.js.map → p-ChVwCVHy.js.map} +0 -0
- /package/dist/components/{p-TuuGOzXH.js.map → p-CpNIQXz6.js.map} +0 -0
- /package/dist/components/{p-BXI096cJ.js.map → p-CvgHQfvT.js.map} +0 -0
- /package/dist/components/{p-BGZjpSXm.js.map → p-DOU4cbpc.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { ChartDatum } from '../../utils/chart-types';
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { Component, Prop, State, Event, EventEmitter, h, Host } from '@stencil/core';
|
|
2
|
+
import { arc, pie } from 'd3-shape';
|
|
3
|
+
import { categoryColor } from '../../utils/chart-colors';
|
|
4
|
+
import { formatCompactNumber, resolveCssLengthPx, truncateSvgTextToWidth, TOKEN_DEFAULTS } from '../../utils';
|
|
5
|
+
import type { ChartDatum } from '../../utils/chart-types';
|
|
6
|
+
|
|
7
|
+
const DIMMED_OPACITY = 0.25;
|
|
8
|
+
/** Gap between the center value and caption line boxes. */
|
|
9
|
+
const CENTER_GAP_PX = 0;
|
|
10
|
+
/** Fraction of the inner-circle diameter center text is allowed to use before truncating. */
|
|
11
|
+
const CENTER_TEXT_SAFE_WIDTH_RATIO = 0.82;
|
|
12
|
+
|
|
13
|
+
@Component({
|
|
14
|
+
tag: 'ds-chart-donut',
|
|
15
|
+
styleUrl: 'ChartDonut.css',
|
|
16
|
+
scoped: true,
|
|
17
|
+
})
|
|
18
|
+
export class ChartDonut {
|
|
19
|
+
/** Slices to render. Set as a JS property (not an HTML attribute). */
|
|
20
|
+
@Prop() data: ChartDatum[] = [];
|
|
21
|
+
@Prop() size: number = 175;
|
|
22
|
+
/** Ring thickness — number (px) or TokoMo length. Defaults to `--dimension-size-200` (16px). */
|
|
23
|
+
@Prop() thickness: number | string = TOKEN_DEFAULTS.size200;
|
|
24
|
+
/** Corner radius on each slice — number (px) or TokoMo length. Defaults to `--dimension-radius-025` (2px). */
|
|
25
|
+
@Prop() cornerRadius: number | string = TOKEN_DEFAULTS.radius025;
|
|
26
|
+
/** Gap between slices, in degrees. */
|
|
27
|
+
@Prop() gap: number = 1;
|
|
28
|
+
/** Primary center value; defaults to the sum of `data` values (e.g. "187", "40.9%"). */
|
|
29
|
+
@Prop() centerValue: string | undefined;
|
|
30
|
+
/** Secondary caption below the center value (e.g. "Total", "Normal"). Rendered uppercase. */
|
|
31
|
+
@Prop() centerCaption: string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Externally controlled highlight, matched by `label` — e.g. drive this from a sibling
|
|
34
|
+
* `ds-chart-legend`'s `dsItemHover` event to keep chart and legend hover in sync.
|
|
35
|
+
* Falls back to this component's own pointer/focus hover when unset.
|
|
36
|
+
*/
|
|
37
|
+
@Prop() activeLabel: string | null = null;
|
|
38
|
+
|
|
39
|
+
@State() private hoveredLabel: string | null = null;
|
|
40
|
+
/** Center text, truncated with an ellipsis if it doesn't fit inside the inner circle. */
|
|
41
|
+
@State() private displayedValueText: string = '';
|
|
42
|
+
@State() private displayedCaptionText: string = '';
|
|
43
|
+
|
|
44
|
+
/** Fires with the hovered/focused slice's datum, or `null` on leave/blur. */
|
|
45
|
+
@Event() dsSliceHover!: EventEmitter<ChartDatum | null>;
|
|
46
|
+
|
|
47
|
+
private valueTextEl?: SVGTextElement;
|
|
48
|
+
private captionTextEl?: SVGTextElement;
|
|
49
|
+
|
|
50
|
+
private handleHover(datum: ChartDatum | null) {
|
|
51
|
+
this.hoveredLabel = datum?.label ?? null;
|
|
52
|
+
this.dsSliceHover.emit(datum);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
componentDidRender() {
|
|
56
|
+
const radius = this.size / 2;
|
|
57
|
+
const thicknessPx = resolveCssLengthPx(this.thickness, TOKEN_DEFAULTS.size200);
|
|
58
|
+
const innerRadius = radius - thicknessPx;
|
|
59
|
+
const maxWidth = innerRadius * 2 * CENTER_TEXT_SAFE_WIDTH_RATIO;
|
|
60
|
+
const total = this.data.reduce((sum, d) => sum + d.value, 0);
|
|
61
|
+
|
|
62
|
+
const fullValueText = this.centerValue ?? formatCompactNumber(total);
|
|
63
|
+
if (this.valueTextEl) {
|
|
64
|
+
const truncated = truncateSvgTextToWidth(this.valueTextEl, fullValueText, maxWidth);
|
|
65
|
+
if (truncated !== this.displayedValueText) this.displayedValueText = truncated;
|
|
66
|
+
else this.valueTextEl.textContent = truncated;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const fullCaptionText = this.centerCaption ?? '';
|
|
70
|
+
if (this.captionTextEl && fullCaptionText) {
|
|
71
|
+
const truncated = truncateSvgTextToWidth(this.captionTextEl, fullCaptionText, maxWidth);
|
|
72
|
+
if (truncated !== this.displayedCaptionText) this.displayedCaptionText = truncated;
|
|
73
|
+
else this.captionTextEl.textContent = truncated;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
render() {
|
|
78
|
+
const radius = this.size / 2;
|
|
79
|
+
const thicknessPx = resolveCssLengthPx(this.thickness, TOKEN_DEFAULTS.size200);
|
|
80
|
+
const cornerRadiusPx = resolveCssLengthPx(this.cornerRadius, TOKEN_DEFAULTS.radius025);
|
|
81
|
+
const innerRadius = radius - thicknessPx;
|
|
82
|
+
const total = this.data.reduce((sum, d) => sum + d.value, 0);
|
|
83
|
+
// d3's pie() divides each value by the total to get an angle — with an all-zero total
|
|
84
|
+
// that's 0/0, producing NaN angles and broken paths. Render a flat neutral ring instead.
|
|
85
|
+
const isEmpty = total <= 0;
|
|
86
|
+
// Highlight/dim from either this component's own hover or an externally-synced label
|
|
87
|
+
// (e.g. a sibling ds-chart-legend row).
|
|
88
|
+
const highlightLabel = this.activeLabel ?? this.hoveredLabel;
|
|
89
|
+
|
|
90
|
+
const arcGenerator = arc<{ startAngle: number; endAngle: number }>()
|
|
91
|
+
.innerRadius(innerRadius)
|
|
92
|
+
.outerRadius(radius)
|
|
93
|
+
.cornerRadius(cornerRadiusPx)
|
|
94
|
+
.padAngle((this.gap * Math.PI) / 180);
|
|
95
|
+
|
|
96
|
+
const slices = isEmpty
|
|
97
|
+
? []
|
|
98
|
+
: pie<ChartDatum>()
|
|
99
|
+
.value(d => d.value)
|
|
100
|
+
.sort(null)(this.data);
|
|
101
|
+
|
|
102
|
+
const emptyRingPath = isEmpty
|
|
103
|
+
? arc<{ startAngle: number; endAngle: number }>()
|
|
104
|
+
.innerRadius(innerRadius)
|
|
105
|
+
.outerRadius(radius)({ startAngle: 0, endAngle: Math.PI * 2 })
|
|
106
|
+
: null;
|
|
107
|
+
|
|
108
|
+
// Precise vertical centering for the two-line center text, using the real line-height of
|
|
109
|
+
// text-title-large (value) and text-body-medium (caption) rather than eyeballed em offsets.
|
|
110
|
+
const valueLineHeight = resolveCssLengthPx(TOKEN_DEFAULTS.lineheightXl, 32);
|
|
111
|
+
const captionLineHeight = resolveCssLengthPx(TOKEN_DEFAULTS.lineheightMd, 20);
|
|
112
|
+
const centerValueY = this.centerCaption ? radius - (CENTER_GAP_PX + captionLineHeight) / 2 : radius;
|
|
113
|
+
const centerCaptionY = radius + (valueLineHeight + CENTER_GAP_PX) / 2;
|
|
114
|
+
|
|
115
|
+
// Show the full text before the post-render truncation pass has measured it (avoids a blank first paint).
|
|
116
|
+
const fullValueText = this.centerValue ?? formatCompactNumber(total);
|
|
117
|
+
const fullCaptionText = this.centerCaption ?? '';
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<Host class="chart-donut">
|
|
121
|
+
<div class="chart-donut__wrapper" style={{ width: `${this.size}px`, height: `${this.size}px` }}>
|
|
122
|
+
<svg
|
|
123
|
+
class="chart-donut__svg"
|
|
124
|
+
viewBox={`0 0 ${this.size} ${this.size}`}
|
|
125
|
+
width={this.size}
|
|
126
|
+
height={this.size}
|
|
127
|
+
>
|
|
128
|
+
<g transform={`translate(${radius}, ${radius})`}>
|
|
129
|
+
{isEmpty ? (
|
|
130
|
+
<path d={emptyRingPath ?? undefined} fill="var(--color-background-faint-neutral)" aria-label="No data" />
|
|
131
|
+
) : (
|
|
132
|
+
slices.map((slice, i) => {
|
|
133
|
+
const datum = this.data[i];
|
|
134
|
+
const isDimmed = highlightLabel != null && datum.label !== highlightLabel;
|
|
135
|
+
return (
|
|
136
|
+
<path
|
|
137
|
+
key={datum.label}
|
|
138
|
+
d={arcGenerator(slice) ?? undefined}
|
|
139
|
+
fill={datum.color ?? categoryColor(i)}
|
|
140
|
+
opacity={isDimmed ? DIMMED_OPACITY : 1}
|
|
141
|
+
tabindex={0}
|
|
142
|
+
role="img"
|
|
143
|
+
aria-label={`${datum.label}: ${datum.value}`}
|
|
144
|
+
onMouseEnter={() => this.handleHover(datum)}
|
|
145
|
+
onMouseLeave={() => this.handleHover(null)}
|
|
146
|
+
onFocus={() => this.handleHover(datum)}
|
|
147
|
+
onBlur={() => this.handleHover(null)}
|
|
148
|
+
/>
|
|
149
|
+
);
|
|
150
|
+
})
|
|
151
|
+
)}
|
|
152
|
+
</g>
|
|
153
|
+
<text
|
|
154
|
+
class="chart-donut__center-value"
|
|
155
|
+
x={radius}
|
|
156
|
+
y={centerValueY}
|
|
157
|
+
text-anchor="middle"
|
|
158
|
+
dominant-baseline="central"
|
|
159
|
+
ref={el => {
|
|
160
|
+
this.valueTextEl = (el as SVGTextElement) ?? undefined;
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
{this.displayedValueText || fullValueText}
|
|
164
|
+
</text>
|
|
165
|
+
{fullCaptionText && (
|
|
166
|
+
<text
|
|
167
|
+
class="chart-donut__center-caption"
|
|
168
|
+
x={radius}
|
|
169
|
+
y={centerCaptionY}
|
|
170
|
+
text-anchor="middle"
|
|
171
|
+
dominant-baseline="central"
|
|
172
|
+
ref={el => {
|
|
173
|
+
this.captionTextEl = (el as SVGTextElement) ?? undefined;
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
{this.displayedCaptionText || fullCaptionText}
|
|
177
|
+
</text>
|
|
178
|
+
)}
|
|
179
|
+
</svg>
|
|
180
|
+
</div>
|
|
181
|
+
</Host>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { ChartDatum } from '../../utils/chart-types';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Component, Prop, State, Event, EventEmitter, h, Host } from '@stencil/core';
|
|
2
|
+
import { categoryColor } from '../../utils/chart-colors';
|
|
3
|
+
import { formatCompactNumber } from '../../utils';
|
|
4
|
+
import type { ChartLegendItem } from '../../utils/chart-types';
|
|
5
|
+
|
|
6
|
+
export type ChartLegendDirection = 'vertical' | 'horizontal';
|
|
7
|
+
|
|
8
|
+
const DIMMED_OPACITY = 0.25;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Base legend for `ds-chart-*` components. Webapp's Overview widgets each style
|
|
12
|
+
* legends differently (list w/ values, compact chips, bare swatches, external
|
|
13
|
+
* stat callouts) — this covers the common "swatch + label + optional value" case.
|
|
14
|
+
* Consumers who need a different treatment skip this component entirely and
|
|
15
|
+
* render their own markup against the same `ChartDatum[]` data.
|
|
16
|
+
*/
|
|
17
|
+
@Component({
|
|
18
|
+
tag: 'ds-chart-legend',
|
|
19
|
+
styleUrl: 'ChartLegend.css',
|
|
20
|
+
scoped: true,
|
|
21
|
+
})
|
|
22
|
+
export class ChartLegend {
|
|
23
|
+
/** Legend entries. Set as a JS property (not an HTML attribute). */
|
|
24
|
+
@Prop() items: ChartLegendItem[] = [];
|
|
25
|
+
@Prop() direction: ChartLegendDirection = 'vertical';
|
|
26
|
+
/** Show each item's share of the total (of items with a `value`) alongside its count. */
|
|
27
|
+
@Prop() showPercentage: boolean = true;
|
|
28
|
+
/**
|
|
29
|
+
* Externally controlled highlight, matched by `label` — e.g. drive this from a sibling
|
|
30
|
+
* chart's `dsSliceHover` event to keep chart and legend hover in sync. Only dims the other
|
|
31
|
+
* rows' opacity (like a chart dimming its other slices) — it never shows the hover-fill,
|
|
32
|
+
* since that's reserved for a real pointer/keyboard interaction on this row.
|
|
33
|
+
*/
|
|
34
|
+
@Prop() activeLabel: string | null = null;
|
|
35
|
+
|
|
36
|
+
/** Own pointer/focus hover, kept separate from `activeLabel` so the hover-fill (a "you can
|
|
37
|
+
* click here" affordance) never shows just because an external row was synced in. */
|
|
38
|
+
@State() private hoveredLabel: string | null = null;
|
|
39
|
+
|
|
40
|
+
/** Fires on row hover/focus with the item, or `null` on leave/blur. */
|
|
41
|
+
@Event() dsItemHover!: EventEmitter<ChartLegendItem | null>;
|
|
42
|
+
/** Fires when a deep-linkable row (`item.href` set) is activated. */
|
|
43
|
+
@Event() dsItemClick!: EventEmitter<{ item: ChartLegendItem; originalEvent: MouseEvent }>;
|
|
44
|
+
|
|
45
|
+
private handleHover(item: ChartLegendItem | null) {
|
|
46
|
+
this.hoveredLabel = item?.label ?? null;
|
|
47
|
+
this.dsItemHover.emit(item);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private handleClick = (item: ChartLegendItem, originalEvent: MouseEvent) => {
|
|
51
|
+
this.dsItemClick.emit({ item, originalEvent });
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
render() {
|
|
55
|
+
const total = this.items.reduce((sum, item) => sum + (item.value ?? 0), 0);
|
|
56
|
+
// Highlight/dim from either this component's own hover or an externally-synced label
|
|
57
|
+
// (e.g. a sibling ds-chart-donut slice) — same mechanism ds-chart-donut uses for its slices.
|
|
58
|
+
const highlightLabel = this.activeLabel ?? this.hoveredLabel;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Host class={{ 'chart-legend': true, [`chart-legend--${this.direction}`]: true }}>
|
|
62
|
+
{/* mouseleave lives on the list, not each row — the row-gap between items is still
|
|
63
|
+
inside the list's box, so crossing it while moving between rows never fires this.
|
|
64
|
+
Only actually leaving the whole list does, and that clears with no delay. */}
|
|
65
|
+
<ul class="chart-legend__list" onMouseLeave={() => this.handleHover(null)}>
|
|
66
|
+
{this.items.map((item, i) => {
|
|
67
|
+
const isDimmed = highlightLabel != null && item.label !== highlightLabel;
|
|
68
|
+
const RowTag = item.href ? 'a' : 'div';
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<li class="chart-legend__list-item" key={item.label}>
|
|
72
|
+
<RowTag
|
|
73
|
+
class={{
|
|
74
|
+
'chart-legend__item': true,
|
|
75
|
+
'chart-legend__item--interactive': !!item.href,
|
|
76
|
+
}}
|
|
77
|
+
style={{ opacity: isDimmed ? String(DIMMED_OPACITY) : '1' }}
|
|
78
|
+
href={item.href}
|
|
79
|
+
onClick={item.href ? (e: MouseEvent) => this.handleClick(item, e) : undefined}
|
|
80
|
+
onMouseEnter={() => this.handleHover(item)}
|
|
81
|
+
onFocus={() => this.handleHover(item)}
|
|
82
|
+
onBlur={() => this.handleHover(null)}
|
|
83
|
+
>
|
|
84
|
+
<span class="chart-legend__swatch-box">
|
|
85
|
+
<span class="chart-legend__swatch" style={{ backgroundColor: item.color ?? categoryColor(i) }} />
|
|
86
|
+
</span>
|
|
87
|
+
<span class="chart-legend__label" title={item.label}>
|
|
88
|
+
<ds-text as="span" variant="text-body-medium" color="secondary">
|
|
89
|
+
{item.label}
|
|
90
|
+
</ds-text>
|
|
91
|
+
</span>
|
|
92
|
+
{item.value != null && (
|
|
93
|
+
<span class="chart-legend__value" title={String(item.value)}>
|
|
94
|
+
<ds-text as="span" variant="text-body-medium" color="primary">
|
|
95
|
+
{formatCompactNumber(item.value)}
|
|
96
|
+
</ds-text>
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
{item.value != null && this.showPercentage && (
|
|
100
|
+
<span class="chart-legend__percentage">
|
|
101
|
+
<ds-text as="span" variant="text-body-medium" color="primary">
|
|
102
|
+
{(total ? (item.value / total) * 100 : 0).toFixed(1)}%
|
|
103
|
+
</ds-text>
|
|
104
|
+
</span>
|
|
105
|
+
)}
|
|
106
|
+
</RowTag>
|
|
107
|
+
</li>
|
|
108
|
+
);
|
|
109
|
+
})}
|
|
110
|
+
</ul>
|
|
111
|
+
</Host>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Component, Prop, h, Host } from '@stencil/core';
|
|
2
|
+
import { line as lineShape } from 'd3-shape';
|
|
3
|
+
import { scaleLinear, scalePoint } from 'd3-scale';
|
|
4
|
+
import { max } from 'd3-array';
|
|
5
|
+
import { categoryColor } from '../../utils/chart-colors';
|
|
6
|
+
import type { ChartSeries } from '../../utils/chart-types';
|
|
7
|
+
|
|
8
|
+
const MARGIN = { top: 16, right: 16, bottom: 24, left: 32 };
|
|
9
|
+
|
|
10
|
+
@Component({
|
|
11
|
+
tag: 'ds-chart-line',
|
|
12
|
+
styleUrl: 'ChartLine.css',
|
|
13
|
+
scoped: true,
|
|
14
|
+
})
|
|
15
|
+
export class ChartLine {
|
|
16
|
+
/** One or more series to plot. Set as a JS property (not an HTML attribute). */
|
|
17
|
+
@Prop() series: ChartSeries[] = [];
|
|
18
|
+
/** X-axis labels — must match each series' `data` length. Set as a JS property. */
|
|
19
|
+
@Prop() categories: string[] = [];
|
|
20
|
+
@Prop() width: number = 480;
|
|
21
|
+
@Prop() height: number = 240;
|
|
22
|
+
@Prop() showPoints: boolean = true;
|
|
23
|
+
|
|
24
|
+
render() {
|
|
25
|
+
const innerWidth = this.width - MARGIN.left - MARGIN.right;
|
|
26
|
+
const innerHeight = this.height - MARGIN.top - MARGIN.bottom;
|
|
27
|
+
|
|
28
|
+
const allValues = this.series.flatMap(s => s.data);
|
|
29
|
+
const yMax = max(allValues) ?? 0;
|
|
30
|
+
|
|
31
|
+
const xScale = scalePoint<number>()
|
|
32
|
+
.domain(this.categories.map((_, i) => i))
|
|
33
|
+
.range([0, innerWidth]);
|
|
34
|
+
|
|
35
|
+
const yScale = scaleLinear()
|
|
36
|
+
.domain([0, yMax || 1])
|
|
37
|
+
.range([innerHeight, 0])
|
|
38
|
+
.nice();
|
|
39
|
+
|
|
40
|
+
const lineGenerator = lineShape<number>()
|
|
41
|
+
.x((_, i) => xScale(i) ?? 0)
|
|
42
|
+
.y(d => yScale(d));
|
|
43
|
+
|
|
44
|
+
const yTicks = yScale.ticks(4);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Host class="chart-line">
|
|
48
|
+
<svg
|
|
49
|
+
class="chart-line__svg"
|
|
50
|
+
viewBox={`0 0 ${this.width} ${this.height}`}
|
|
51
|
+
width={this.width}
|
|
52
|
+
height={this.height}
|
|
53
|
+
role="img"
|
|
54
|
+
aria-label={this.series.map(s => s.name).join(', ')}
|
|
55
|
+
>
|
|
56
|
+
<g transform={`translate(${MARGIN.left}, ${MARGIN.top})`}>
|
|
57
|
+
{yTicks.map(tick => (
|
|
58
|
+
<g key={`grid-${tick}`} transform={`translate(0, ${yScale(tick)})`}>
|
|
59
|
+
<line class="chart-line__gridline" x1={0} x2={innerWidth} />
|
|
60
|
+
<text class="chart-line__axis-label" x={-8} text-anchor="end" dominant-baseline="middle">
|
|
61
|
+
{tick}
|
|
62
|
+
</text>
|
|
63
|
+
</g>
|
|
64
|
+
))}
|
|
65
|
+
|
|
66
|
+
{this.categories.map((label, i) => (
|
|
67
|
+
<text
|
|
68
|
+
key={`x-${label}`}
|
|
69
|
+
class="chart-line__axis-label"
|
|
70
|
+
x={xScale(i) ?? 0}
|
|
71
|
+
y={innerHeight + 16}
|
|
72
|
+
text-anchor="middle"
|
|
73
|
+
>
|
|
74
|
+
{label}
|
|
75
|
+
</text>
|
|
76
|
+
))}
|
|
77
|
+
|
|
78
|
+
{this.series.map((s, si) => {
|
|
79
|
+
const color = s.color ?? categoryColor(si);
|
|
80
|
+
return (
|
|
81
|
+
<g key={s.name}>
|
|
82
|
+
<path class="chart-line__path" d={lineGenerator(s.data) ?? undefined} stroke={color} />
|
|
83
|
+
{this.showPoints &&
|
|
84
|
+
s.data.map((d, i) => (
|
|
85
|
+
<circle
|
|
86
|
+
key={`${s.name}-${i}`}
|
|
87
|
+
class="chart-line__point"
|
|
88
|
+
cx={xScale(i) ?? 0}
|
|
89
|
+
cy={yScale(d)}
|
|
90
|
+
r={3}
|
|
91
|
+
fill={color}
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
</g>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
</g>
|
|
98
|
+
</svg>
|
|
99
|
+
</Host>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { ChartSeries } from '../../utils/chart-types';
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Component, Prop, State, Element, Watch, h, Host } from '@stencil/core';
|
|
2
|
+
|
|
3
|
+
const CURSOR_OFFSET_PX = 12;
|
|
4
|
+
const VIEWPORT_PAD_PX = 8;
|
|
5
|
+
|
|
6
|
+
export type TooltipDataVizSide = 'left' | 'right';
|
|
7
|
+
export type TooltipDataVizAlign = 'top' | 'bottom';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Positioned value/label callout for chart hover interactions (donut slice, bar,
|
|
11
|
+
* line point, ...). Unlike `ds-tooltip`, this doesn't bind to a slotted anchor element —
|
|
12
|
+
* charts hover-highlight data that lives inside an SVG, so the chart itself computes
|
|
13
|
+
* the anchor point (e.g. the cursor position while hovering) and passes it in as `x`/`y`.
|
|
14
|
+
*
|
|
15
|
+
* Renders `position: absolute` — place inside a `position: relative` chart wrapper,
|
|
16
|
+
* with `x`/`y` as pixel coordinates within that wrapper. Defaults to sitting below-right
|
|
17
|
+
* of the anchor (matching cursor-following tooltips), flipping to whichever side/edge
|
|
18
|
+
* keeps it on-screen.
|
|
19
|
+
*/
|
|
20
|
+
@Component({
|
|
21
|
+
tag: 'ds-tooltip-data-viz',
|
|
22
|
+
styleUrl: 'TooltipDataViz.css',
|
|
23
|
+
scoped: true,
|
|
24
|
+
})
|
|
25
|
+
export class TooltipDataViz {
|
|
26
|
+
@Element() el!: HTMLElement;
|
|
27
|
+
|
|
28
|
+
@Prop() value: string | number = '';
|
|
29
|
+
@Prop() label: string = '';
|
|
30
|
+
/** Anchor point in px, relative to the nearest `position: relative` ancestor — e.g. the cursor. */
|
|
31
|
+
@Prop() x: number = 0;
|
|
32
|
+
@Prop() y: number = 0;
|
|
33
|
+
|
|
34
|
+
@State() private side: TooltipDataVizSide = 'right';
|
|
35
|
+
@State() private align: TooltipDataVizAlign = 'bottom';
|
|
36
|
+
|
|
37
|
+
componentDidLoad() {
|
|
38
|
+
this.calculatePlacement();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Watch('x')
|
|
42
|
+
@Watch('y')
|
|
43
|
+
onAnchorChange() {
|
|
44
|
+
requestAnimationFrame(() => this.calculatePlacement());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private calculatePlacement() {
|
|
48
|
+
const rect = this.el.getBoundingClientRect();
|
|
49
|
+
if (!rect.width || !rect.height) return;
|
|
50
|
+
|
|
51
|
+
const nextSide: TooltipDataVizSide = rect.right > window.innerWidth - VIEWPORT_PAD_PX ? 'left' : 'right';
|
|
52
|
+
const nextAlign: TooltipDataVizAlign = rect.bottom > window.innerHeight - VIEWPORT_PAD_PX ? 'top' : 'bottom';
|
|
53
|
+
|
|
54
|
+
if (nextSide !== this.side) this.side = nextSide;
|
|
55
|
+
if (nextAlign !== this.align) this.align = nextAlign;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
render() {
|
|
59
|
+
const translateX = this.side === 'right' ? `${CURSOR_OFFSET_PX}px` : `calc(-100% - ${CURSOR_OFFSET_PX}px)`;
|
|
60
|
+
const translateY = this.align === 'bottom' ? `${CURSOR_OFFSET_PX}px` : `calc(-100% - ${CURSOR_OFFSET_PX}px)`;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Host
|
|
64
|
+
class="tooltip-data-viz"
|
|
65
|
+
style={{ left: `${this.x}px`, top: `${this.y}px`, transform: `translate(${translateX}, ${translateY})` }}
|
|
66
|
+
>
|
|
67
|
+
<div class="tooltip-data-viz__item">
|
|
68
|
+
<span class="tooltip-data-viz__label">
|
|
69
|
+
<ds-text as="span" variant="text-body-medium" color="var(--color-foreground-on-translucent-background-secondary)">
|
|
70
|
+
{this.label}
|
|
71
|
+
</ds-text>
|
|
72
|
+
</span>
|
|
73
|
+
<span class="tooltip-data-viz__value">
|
|
74
|
+
<ds-text as="span" variant="text-body-medium-emphasis" color="var(--color-foreground-on-translucent-background-primary)">
|
|
75
|
+
{this.value}
|
|
76
|
+
</ds-text>
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
</Host>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { TooltipDataVizSide, TooltipDataVizAlign } from './TooltipDataViz';
|