@eocrm/design-system 0.6.23 → 0.6.24
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/AI-PRIMER.md +1 -0
- package/docs/components/Chart.md +1 -0
- package/docs/components/FunnelChart.md +96 -0
- package/docs/components/index.json +5 -0
- package/package.json +2 -2
- package/src/_meta/manifest.ts +1 -0
- package/src/components/Chart/Chart.tsx +1 -22
- package/src/components/Chart/ChartTooltip.tsx +5 -3
- package/src/components/FunnelChart/FunnelChart.module.scss +265 -0
- package/src/components/FunnelChart/FunnelChart.tokens.scss +72 -0
- package/src/components/FunnelChart/FunnelChart.tsx +607 -0
- package/src/components/FunnelChart/funnelModel.ts +124 -0
- package/src/components/FunnelChart/index.ts +2 -0
- package/src/components/_internal/useSize.ts +23 -0
- package/src/components.manifest.json +18 -2
- package/src/i18n/en.ts +10 -0
- package/src/i18n/messages.ts +18 -0
- package/src/i18n/ru.ts +11 -0
- package/src/index.ts +7 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Chart-palette slot (`--chart-series-N`). */
|
|
2
|
+
export type FunnelSlot = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
3
|
+
|
|
4
|
+
/** Silhouette between stages: hold then a short straight taper, or a full-column trapezoid. */
|
|
5
|
+
export type FunnelShape = 'taper' | 'sharp';
|
|
6
|
+
|
|
7
|
+
/** One funnel stage as supplied by the consumer. */
|
|
8
|
+
export interface FunnelStage {
|
|
9
|
+
/** Stable identity (React key). */
|
|
10
|
+
key: string;
|
|
11
|
+
/** Human name shown in labels, tooltip and data table. */
|
|
12
|
+
label: string;
|
|
13
|
+
/** Count for this stage. Non-finite or negative values are treated as 0. */
|
|
14
|
+
value: number;
|
|
15
|
+
/** Palette slot to highlight this stage with (e.g. Won in green); omit to follow the one-hue ramp. */
|
|
16
|
+
color?: FunnelSlot;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A stage with sanitised value, resolved colour slot and derived ratios. */
|
|
20
|
+
export interface ResolvedStage {
|
|
21
|
+
key: string;
|
|
22
|
+
label: string;
|
|
23
|
+
/** Sanitised: non-finite or negative becomes 0. */
|
|
24
|
+
value: number;
|
|
25
|
+
/** The explicit `color` slot, or null for the ramp colour. */
|
|
26
|
+
slot: FunnelSlot | null;
|
|
27
|
+
/** `value / max(values)`; 0 when the max is 0. */
|
|
28
|
+
heightRatio: number;
|
|
29
|
+
/** `value / previous value`; null for the first stage or when the previous is <= 0. */
|
|
30
|
+
stepRatio: number | null;
|
|
31
|
+
/** `value / first value`; null when the first is <= 0. */
|
|
32
|
+
shareRatio: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Per-stage width in px below which the layout goes vertical. */
|
|
36
|
+
export const MIN_STAGE_WIDTH = 96;
|
|
37
|
+
/** Minimum drawn height in px of a stage, so zero values stay hoverable. */
|
|
38
|
+
export const MIN_THICKNESS = 6;
|
|
39
|
+
/** Taper shape: fraction of the column held at the stage's height before the diagonal starts. */
|
|
40
|
+
export const TAPER_HOLD = 0.75;
|
|
41
|
+
|
|
42
|
+
/** Sanitise values and compute ratios. */
|
|
43
|
+
export function resolveStages(stages: readonly FunnelStage[]): ResolvedStage[] {
|
|
44
|
+
const values = stages.map((s) => (Number.isFinite(s.value) && s.value > 0 ? s.value : 0));
|
|
45
|
+
const max = Math.max(0, ...values);
|
|
46
|
+
const first = values[0] ?? 0;
|
|
47
|
+
return stages.map((s, i) => ({
|
|
48
|
+
key: s.key,
|
|
49
|
+
label: s.label,
|
|
50
|
+
value: values[i],
|
|
51
|
+
slot: s.color ?? null,
|
|
52
|
+
heightRatio: max > 0 ? values[i] / max : 0,
|
|
53
|
+
stepRatio: i > 0 && values[i - 1] > 0 ? values[i] / values[i - 1] : null,
|
|
54
|
+
shareRatio: first > 0 ? values[i] / first : null,
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Horizontal while each stage gets at least `MIN_STAGE_WIDTH` px, otherwise vertical. */
|
|
59
|
+
export function orientationFor(width: number, count: number): 'horizontal' | 'vertical' {
|
|
60
|
+
return width / Math.max(count, 1) >= MIN_STAGE_WIDTH ? 'horizontal' : 'vertical';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Path and anchor points of one stage, in SVG user units. */
|
|
64
|
+
export interface StageGeometry {
|
|
65
|
+
/** SVG path of the stage's closed outline. */
|
|
66
|
+
d: string;
|
|
67
|
+
/** Column centre x. */
|
|
68
|
+
cx: number;
|
|
69
|
+
/** Vertical centre (shared by all stages). */
|
|
70
|
+
cy: number;
|
|
71
|
+
/** Column left edge. */
|
|
72
|
+
x0: number;
|
|
73
|
+
/** Column right edge. */
|
|
74
|
+
x1: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const r2 = (n: number) => Math.round(n * 100) / 100;
|
|
78
|
+
|
|
79
|
+
/** Outline of each stage, vertically centred; each stage tapers into the next one's height. */
|
|
80
|
+
export function stageGeometry(args: {
|
|
81
|
+
stages: readonly ResolvedStage[];
|
|
82
|
+
width: number;
|
|
83
|
+
height: number;
|
|
84
|
+
shape: FunnelShape;
|
|
85
|
+
}): StageGeometry[] {
|
|
86
|
+
const { stages, width, height, shape } = args;
|
|
87
|
+
const colW = width / stages.length;
|
|
88
|
+
const mid = height / 2;
|
|
89
|
+
const half = (s: ResolvedStage) => Math.max(MIN_THICKNESS / 2, (s.heightRatio * height) / 2);
|
|
90
|
+
|
|
91
|
+
return stages.map((s, i) => {
|
|
92
|
+
const x0 = i * colW;
|
|
93
|
+
const x1 = (i + 1) * colW;
|
|
94
|
+
const a = half(s);
|
|
95
|
+
const last = i === stages.length - 1;
|
|
96
|
+
const b = last ? a : half(stages[i + 1]);
|
|
97
|
+
const [t0, t1, b0, b1] = [mid - a, mid - b, mid + b, mid + a].map(r2);
|
|
98
|
+
const p = (x: number, y: number) => `${r2(x)},${y}`;
|
|
99
|
+
let d: string;
|
|
100
|
+
if (shape === 'sharp' || last) {
|
|
101
|
+
d = `M ${p(x0, t0)} L ${p(x1, t1)} L ${p(x1, b0)} L ${p(x0, b1)} Z`;
|
|
102
|
+
} else {
|
|
103
|
+
const xh = x0 + colW * TAPER_HOLD;
|
|
104
|
+
d =
|
|
105
|
+
`M ${p(x0, t0)} L ${p(xh, t0)} L ${p(x1, t1)}` +
|
|
106
|
+
` L ${p(x1, b0)} L ${p(xh, b1)} L ${p(x0, b1)} Z`;
|
|
107
|
+
}
|
|
108
|
+
return { d, cx: r2(x0 + colW / 2), cy: mid, x0: r2(x0), x1: r2(x1) };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Column index under `x`, or null outside `[0, width)`. */
|
|
113
|
+
export function stageIndexAt(width: number, count: number, x: number): number | null {
|
|
114
|
+
if (!(x >= 0 && x < width) || count <= 0) return null;
|
|
115
|
+
return Math.min(count - 1, Math.floor((x / width) * count));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Percent string; one decimal for ratios under 1%, otherwise whole percent. */
|
|
119
|
+
export function formatPercent(ratio: number, locale: string): string {
|
|
120
|
+
return new Intl.NumberFormat(locale, {
|
|
121
|
+
style: 'percent',
|
|
122
|
+
maximumFractionDigits: ratio > 0 && ratio < 0.01 ? 1 : 0,
|
|
123
|
+
}).format(ratio);
|
|
124
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useLayoutEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Callback ref plus the element's floored size, kept current by a ResizeObserver. */
|
|
4
|
+
export function useSize<T extends HTMLElement>() {
|
|
5
|
+
const [el, setEl] = useState<T | null>(null);
|
|
6
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
7
|
+
useLayoutEffect(() => {
|
|
8
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
9
|
+
const measure = () => {
|
|
10
|
+
const { width, height } = el.getBoundingClientRect();
|
|
11
|
+
setSize((prev) =>
|
|
12
|
+
prev.width === Math.floor(width) && prev.height === Math.floor(height)
|
|
13
|
+
? prev
|
|
14
|
+
: { width: Math.floor(width), height: Math.floor(height) },
|
|
15
|
+
);
|
|
16
|
+
};
|
|
17
|
+
measure();
|
|
18
|
+
const ro = new ResizeObserver(measure);
|
|
19
|
+
ro.observe(el);
|
|
20
|
+
return () => ro.disconnect();
|
|
21
|
+
}, [el]);
|
|
22
|
+
return [setEl, size, el] as const;
|
|
23
|
+
}
|
|
@@ -149,7 +149,9 @@
|
|
|
149
149
|
"LiveRegion",
|
|
150
150
|
"VisuallyHidden"
|
|
151
151
|
],
|
|
152
|
-
"composedBy": [
|
|
152
|
+
"composedBy": [
|
|
153
|
+
"FunnelChart"
|
|
154
|
+
]
|
|
153
155
|
},
|
|
154
156
|
"ChatBubble": {
|
|
155
157
|
"tier": "composition",
|
|
@@ -392,7 +394,8 @@
|
|
|
392
394
|
"composedBy": [
|
|
393
395
|
"CatalogPicker",
|
|
394
396
|
"Chart",
|
|
395
|
-
"DataTable"
|
|
397
|
+
"DataTable",
|
|
398
|
+
"FunnelChart"
|
|
396
399
|
]
|
|
397
400
|
},
|
|
398
401
|
"EntityChip": {
|
|
@@ -461,6 +464,17 @@
|
|
|
461
464
|
],
|
|
462
465
|
"composedBy": []
|
|
463
466
|
},
|
|
467
|
+
"FunnelChart": {
|
|
468
|
+
"tier": "composition",
|
|
469
|
+
"cluster": "Display",
|
|
470
|
+
"composes": [
|
|
471
|
+
"Chart",
|
|
472
|
+
"EmptyState",
|
|
473
|
+
"LiveRegion",
|
|
474
|
+
"VisuallyHidden"
|
|
475
|
+
],
|
|
476
|
+
"composedBy": []
|
|
477
|
+
},
|
|
464
478
|
"Grid": {
|
|
465
479
|
"tier": "primitive",
|
|
466
480
|
"cluster": "Layout",
|
|
@@ -609,6 +623,7 @@
|
|
|
609
623
|
"Chart",
|
|
610
624
|
"CodeBlock",
|
|
611
625
|
"DateStrip",
|
|
626
|
+
"FunnelChart",
|
|
612
627
|
"RichTextEditor",
|
|
613
628
|
"TopBar",
|
|
614
629
|
"TreeView"
|
|
@@ -1122,6 +1137,7 @@
|
|
|
1122
1137
|
"ChatBubble",
|
|
1123
1138
|
"DashboardWidget",
|
|
1124
1139
|
"DateStrip",
|
|
1140
|
+
"FunnelChart",
|
|
1125
1141
|
"LiveRegion",
|
|
1126
1142
|
"StagePath",
|
|
1127
1143
|
"StatGroup",
|
package/src/i18n/en.ts
CHANGED
|
@@ -477,6 +477,16 @@ export const en: Messages = {
|
|
|
477
477
|
noData: 'No data',
|
|
478
478
|
markers: 'Markers',
|
|
479
479
|
},
|
|
480
|
+
funnelChart: {
|
|
481
|
+
keyboardHint: 'Use arrow keys to inspect stages. All values are also in the data table.',
|
|
482
|
+
stage: 'Stage',
|
|
483
|
+
value: 'Value',
|
|
484
|
+
stepConversion: 'Conversion from previous stage',
|
|
485
|
+
shareOfFirst: 'Share of first stage',
|
|
486
|
+
ofStage: ({ percent, stage }) => `${percent as string} of ${stage as string}`,
|
|
487
|
+
noData: '—',
|
|
488
|
+
vsPrevious: 'vs previous',
|
|
489
|
+
},
|
|
480
490
|
treeView: {
|
|
481
491
|
loading: 'Loading…',
|
|
482
492
|
loadError: "Couldn't load",
|
package/src/i18n/messages.ts
CHANGED
|
@@ -900,6 +900,24 @@ export interface Messages {
|
|
|
900
900
|
/** Header of the data table's marker column (labels of the annotations covering each category). */
|
|
901
901
|
markers: string;
|
|
902
902
|
};
|
|
903
|
+
funnelChart: {
|
|
904
|
+
/** Accessible description of the focusable plot (its name is the chart label); says arrow keys inspect stages and that the data table lists every value. */
|
|
905
|
+
keyboardHint: string;
|
|
906
|
+
/** Header of the data table's stage column. */
|
|
907
|
+
stage: string;
|
|
908
|
+
/** Header of the data table's value column and the tooltip's value row label. */
|
|
909
|
+
value: string;
|
|
910
|
+
/** Header of the data table's step-conversion column (this stage / previous stage). */
|
|
911
|
+
stepConversion: string;
|
|
912
|
+
/** Header of the data table's share column (this stage / first stage). */
|
|
913
|
+
shareOfFirst: string;
|
|
914
|
+
/** A ratio relative to a named stage, e.g. "40% of Proposal" (tooltip, announcement). `percent` is already formatted. */
|
|
915
|
+
ofStage: (params: { percent: string; stage: string }) => string;
|
|
916
|
+
/** Data-table cell for an undefined ratio (first stage's step, or a previous stage of 0). */
|
|
917
|
+
noData: string;
|
|
918
|
+
/** Header of the vertical layout's visible step-conversion column ("vs previous"). */
|
|
919
|
+
vsPrevious: string;
|
|
920
|
+
};
|
|
903
921
|
sparkline: {
|
|
904
922
|
/** Accessible name of a Sparkline with data: label plus the series summary. */
|
|
905
923
|
summary: (params: {
|
package/src/i18n/ru.ts
CHANGED
|
@@ -484,6 +484,17 @@ export const ru: Messages = {
|
|
|
484
484
|
noData: 'Нет данных',
|
|
485
485
|
markers: 'Отметки',
|
|
486
486
|
},
|
|
487
|
+
funnelChart: {
|
|
488
|
+
keyboardHint:
|
|
489
|
+
'Используйте стрелки для просмотра этапов. Все значения также есть в таблице данных.',
|
|
490
|
+
stage: 'Этап',
|
|
491
|
+
value: 'Значение',
|
|
492
|
+
stepConversion: 'Конверсия из предыдущего этапа',
|
|
493
|
+
shareOfFirst: 'Доля от первого этапа',
|
|
494
|
+
ofStage: ({ percent, stage }) => `${percent as string} от «${stage as string}»`,
|
|
495
|
+
noData: '—',
|
|
496
|
+
vsPrevious: 'к предыдущему',
|
|
497
|
+
},
|
|
487
498
|
treeView: {
|
|
488
499
|
loading: 'Загрузка…',
|
|
489
500
|
loadError: 'Не удалось загрузить',
|
package/src/index.ts
CHANGED
|
@@ -894,6 +894,13 @@ export type {
|
|
|
894
894
|
StatTrend,
|
|
895
895
|
} from './components/StatGroup';
|
|
896
896
|
export { Chart } from './components/Chart';
|
|
897
|
+
export { FunnelChart } from './components/FunnelChart';
|
|
898
|
+
export type {
|
|
899
|
+
FunnelChartProps,
|
|
900
|
+
FunnelShape,
|
|
901
|
+
FunnelSlot,
|
|
902
|
+
FunnelStage,
|
|
903
|
+
} from './components/FunnelChart';
|
|
897
904
|
export { Sparkline } from './components/Sparkline';
|
|
898
905
|
export type { SparklineProps, SparklineType } from './components/Sparkline';
|
|
899
906
|
export { TreeView } from './components/TreeView';
|