@tsingroc/tsingroc-components 3.14.0 → 3.14.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.
@@ -0,0 +1,285 @@
1
+ import type { ColumnsType, ColumnType, TableRef } from "antd/es/table";
2
+ import { ConfigProvider, Flex, type FlexProps, Table, theme } from "antd";
3
+ import type { ElementEvent, LineSeriesOption } from "echarts";
4
+ import {
5
+ type TdHTMLAttributes,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import { createStyles } from "antd-style";
13
+ import EChartsReact from "echarts-for-react";
14
+
15
+ import * as echarts from "../echarts";
16
+
17
+ export interface LineChartTableProps extends Omit<FlexProps, "children"> {
18
+ /** X 轴的数据。*/
19
+ xData: (string | number)[];
20
+ /**
21
+ * X 轴的名称。
22
+ * @default "时间"
23
+ */
24
+ xName?: string;
25
+ /**
26
+ * Y 轴的名称。
27
+ * @default "数值"
28
+ */
29
+ yName?: string;
30
+ /** Y 轴的单位,默认不显示。*/
31
+ yUnit?: string;
32
+ /** 各个曲线的名称、数据和其它选项(颜色、插值模式等)。*/
33
+ series?: { name: string; data: number[]; option?: LineSeriesOption }[];
34
+ /**
35
+ * 折线图的高度。表格会自动占满剩余的高度。
36
+ * @default 175
37
+ */
38
+ chartHeight?: string | number;
39
+ }
40
+
41
+ interface DataItem {
42
+ key: number;
43
+ x: string | number;
44
+ [key: `y${string}`]: number;
45
+ hover?: true | number;
46
+ }
47
+
48
+ const EMPTY: [] = [];
49
+
50
+ /**
51
+ * 折线图与表格的组合,用鼠标指向表格中的数据时会高亮折线图中的对应数据,反之亦然。
52
+ *
53
+ * 除了文档列出的属性外,多余的属性会被传递给最外层的 [Flex][1] 组件。
54
+ *
55
+ * [1]: https://ant-design.antgroup.com/components/flex-cn#api
56
+ */
57
+ function LineChartTable(props: LineChartTableProps) {
58
+ const {
59
+ xData,
60
+ xName = "时间",
61
+ yName = "数值",
62
+ yUnit,
63
+ series = [],
64
+ chartHeight = 175,
65
+ ...rest
66
+ } = props;
67
+ // TODO i18n
68
+ const unitString = yUnit !== undefined ? `(${yUnit})` : "";
69
+ const { cx, styles, theme: token } = useStyles();
70
+ const isDark =
71
+ useContext(ConfigProvider.ConfigContext).theme?.algorithm ===
72
+ theme.darkAlgorithm;
73
+ const echartsRef = useRef<EChartsReact>(null);
74
+ const tableRef = useRef<TableRef>(null);
75
+ type HoverSource = "chart" | "table";
76
+ const [[hoverSource, hoverXIndex, hoverY], setHover] = useState<
77
+ [] | [HoverSource, number] | [HoverSource, number, number]
78
+ >(EMPTY);
79
+ let [closestYIndex, closestY]: (number | undefined)[] = [];
80
+ if (hoverXIndex !== undefined && hoverY !== undefined) {
81
+ let minDist = Infinity;
82
+ series.forEach((series, index) => {
83
+ const y = series.data[hoverXIndex];
84
+ const dist = Math.abs(y - hoverY!);
85
+ if (dist < minDist) {
86
+ closestYIndex = index;
87
+ closestY = y;
88
+ minDist = dist;
89
+ }
90
+ });
91
+ }
92
+ const option = echarts.buildEChartsOption(
93
+ {
94
+ backgroundColor: "transparent",
95
+ },
96
+ echarts.useGrid({
97
+ option: {
98
+ id: "grid",
99
+ left: 12,
100
+ right: 42,
101
+ bottom: 40,
102
+ },
103
+ xAxis: {
104
+ name: xName,
105
+ data: xData,
106
+ axisPointer: {
107
+ value: xData[hoverXIndex ?? 0],
108
+ status: hoverXIndex !== undefined ? "show" : "hide",
109
+ },
110
+ },
111
+ yAxis: {
112
+ name: yName + unitString,
113
+ axisPointer: {
114
+ value: closestY,
115
+ status: closestY !== undefined ? "show" : "hide",
116
+ },
117
+ },
118
+ series: series.map((series) => echarts.lineSeries(series)),
119
+ }),
120
+ echarts.legend({
121
+ data: series.map((series) => series.name),
122
+ option: { right: 42 },
123
+ }),
124
+ {
125
+ axisPointer: [{ show: true, triggerOn: "none" }],
126
+ dataZoom: [{ orient: "horizontal", bottom: 8, filterMode: "empty" }],
127
+ },
128
+ );
129
+ useEffect(() => {
130
+ const inst = echartsRef.current?.getEchartsInstance()?.getZr();
131
+ if (!inst) return () => {};
132
+ const onMousemove = (event: ElementEvent) => {
133
+ const inst = echartsRef.current?.getEchartsInstance();
134
+ if (!inst) return;
135
+ const coord = [event.offsetX, event.offsetY];
136
+ if (inst.containPixel("grid", coord)) {
137
+ const [x, y] = inst.convertFromPixel("grid", coord);
138
+ setHover(["chart", x, y]);
139
+ } else {
140
+ setHover(EMPTY);
141
+ }
142
+ };
143
+ const onGlobalout = () => setHover(EMPTY);
144
+ inst.on("mousemove", onMousemove);
145
+ inst.on("globalout", onGlobalout);
146
+ return () => {
147
+ inst.off("mousemove", onMousemove);
148
+ inst.off("globalout", onGlobalout);
149
+ };
150
+ }, []);
151
+ useEffect(() => {
152
+ const inst = echartsRef.current?.getEchartsInstance();
153
+ if (!inst) return;
154
+ if (hoverXIndex !== undefined) {
155
+ inst.dispatchAction({
156
+ type: "highlight",
157
+ seriesIndex: closestYIndex,
158
+ dataIndex: hoverXIndex,
159
+ });
160
+ if (hoverSource === "chart") {
161
+ tableScrollTo(hoverXIndex);
162
+ }
163
+ } else {
164
+ inst.dispatchAction({
165
+ type: "downplay",
166
+ });
167
+ }
168
+ }, [hoverSource, hoverXIndex, closestYIndex]);
169
+ const tableDataWithoutHover: DataItem[] = useMemo(() => {
170
+ const data = xData.map((x, i) => ({ key: i, x }) as DataItem);
171
+ series.forEach((series) =>
172
+ series.data.forEach((y, i) => (data[i][`y${series.name}`] = y)),
173
+ );
174
+ return data;
175
+ }, [xData, series]);
176
+ const tableData: DataItem[] = useMemo(() => {
177
+ if (hoverSource === "chart" && hoverXIndex !== undefined) {
178
+ const data = [...tableDataWithoutHover];
179
+ data[hoverXIndex] = {
180
+ ...data[hoverXIndex],
181
+ hover: closestYIndex ?? true,
182
+ };
183
+ return data;
184
+ } else {
185
+ return tableDataWithoutHover;
186
+ }
187
+ }, [tableDataWithoutHover, hoverSource, hoverXIndex, closestYIndex]);
188
+ const tableColumns = useMemo((): ColumnsType<DataItem> => {
189
+ const columnWidth = `${100 / series.length}%`;
190
+ return [
191
+ {
192
+ key: "x",
193
+ dataIndex: "x",
194
+ title: xName,
195
+ onCell: (
196
+ data: DataItem,
197
+ index?: number,
198
+ ): TdHTMLAttributes<HTMLTableCellElement> => ({
199
+ className: cx({
200
+ [styles.rowHovered]: data.hover !== undefined,
201
+ }),
202
+ onMouseEnter: () => setHover(["table", index!]),
203
+ onMouseLeave: () => setHover(EMPTY),
204
+ }),
205
+ },
206
+ ...series.map(
207
+ (series, yIndex): ColumnType<DataItem> => ({
208
+ key: `y${series.name}`,
209
+ dataIndex: `y${series.name}`,
210
+ title: series.name + unitString,
211
+ width: columnWidth,
212
+ onCell: (
213
+ data: DataItem,
214
+ xIndex?: number,
215
+ ): TdHTMLAttributes<HTMLTableCellElement> => ({
216
+ className: cx(styles.cell, {
217
+ [styles.rowHovered]: data.hover !== undefined,
218
+ [styles.cellHovered]: data.hover === yIndex,
219
+ }),
220
+ onMouseEnter: () =>
221
+ setHover(["table", xIndex!, data[`y${series.name}`]]),
222
+ onMouseLeave: () => setHover(EMPTY),
223
+ }),
224
+ }),
225
+ ),
226
+ ];
227
+ }, [cx, styles, xName, series, unitString]);
228
+ const tableScrollTo = (index: number) => {
229
+ const table = tableRef.current?.nativeElement;
230
+ if (table) {
231
+ const header = table.getElementsByTagName("thead")[0];
232
+ const row = table.getElementsByTagName("tr")[index];
233
+ table.scrollTo({
234
+ top:
235
+ row.offsetTop +
236
+ row.offsetHeight / 2 -
237
+ (table.offsetHeight - header.offsetHeight) / 2,
238
+ behavior: "smooth",
239
+ });
240
+ }
241
+ };
242
+ return (
243
+ <Flex vertical gap={token.marginXS} {...rest}>
244
+ <EChartsReact
245
+ ref={echartsRef}
246
+ option={option}
247
+ theme={isDark ? "dark" : undefined}
248
+ style={{ height: chartHeight, flexShrink: 0 }}
249
+ />
250
+ {useMemo(
251
+ () => (
252
+ <Table<DataItem>
253
+ ref={tableRef}
254
+ dataSource={tableData}
255
+ columns={tableColumns}
256
+ size="small"
257
+ pagination={false}
258
+ style={{ flexBasis: "100%", overflowY: "auto" }}
259
+ onHeaderRow={() => ({
260
+ style: { position: "sticky", top: 0, zIndex: 1 },
261
+ })}
262
+ />
263
+ ),
264
+ [tableData, tableColumns],
265
+ )}
266
+ </Flex>
267
+ );
268
+ }
269
+
270
+ export default LineChartTable;
271
+
272
+ const useStyles = createStyles(({ css, token }) => {
273
+ const rowHovered = css`
274
+ background: ${token.colorFillQuaternary};
275
+ `;
276
+ const cell = css`
277
+ [class] > &:hover:hover {
278
+ background: ${token.colorFillSecondary};
279
+ }
280
+ `;
281
+ const cellHovered = css`
282
+ background: ${token.colorFillSecondary};
283
+ `;
284
+ return { rowHovered, cell, cellHovered };
285
+ });
@@ -0,0 +1,223 @@
1
+ import { ConfigProvider, theme } from "antd";
2
+ import {
3
+ createContext,
4
+ type ReactNode,
5
+ useContext,
6
+ useEffect,
7
+ useId,
8
+ useMemo,
9
+ useRef,
10
+ } from "react";
11
+ import EChartsReact, { type EChartsReactProps } from "echarts-for-react";
12
+ import type { LineSeriesOption, Payload } from "echarts";
13
+
14
+ import * as echarts from "../echarts";
15
+
16
+ interface LineChartLinkContext {
17
+ listeners: LinkEventListener[];
18
+ xData: (string | number)[];
19
+ xName: string;
20
+ }
21
+
22
+ type LinkEvent = { sourceId: string } & (
23
+ | { type: "highlight"; xIndex: number }
24
+ | { type: "downplay" }
25
+ | { type: "datazoom"; start: number; end: number }
26
+ );
27
+ type LinkEventListener = (event: LinkEvent) => void;
28
+
29
+ const LineChartLinkContext = createContext<LineChartLinkContext>({
30
+ listeners: [],
31
+ xData: [],
32
+ xName: "时间",
33
+ });
34
+
35
+ export interface LineChartLinkProviderProps {
36
+ /** X 轴的数据。*/
37
+ xData: (string | number)[];
38
+ /**
39
+ * X 轴的名称。
40
+ * @default "时间"
41
+ */
42
+ xName?: string;
43
+ /** 包裹的内容,其中包含需要联动的 {@linkcode LinkedLineChart} 组件。*/
44
+ children: ReactNode;
45
+ }
46
+
47
+ /**
48
+ * {@linkcode LinkedLineChart} 联动上下文的 Provider。
49
+ *
50
+ * 用于包裹需要联动的 {@linkcode LinkedLineChart} 组件。
51
+ */
52
+ export function LineChartLinkProvider(props: LineChartLinkProviderProps) {
53
+ const listenersRef = useRef<LinkEventListener[]>([]);
54
+
55
+ return (
56
+ <LineChartLinkContext.Provider
57
+ value={{
58
+ listeners: listenersRef.current,
59
+ xData: props.xData,
60
+ xName: props.xName ?? "时间",
61
+ }}
62
+ >
63
+ {props.children}
64
+ </LineChartLinkContext.Provider>
65
+ );
66
+ }
67
+
68
+ export interface LinkedLineChartProps
69
+ extends Omit<EChartsReactProps, "option"> {
70
+ /**
71
+ * Y 轴的名称。
72
+ * @default "数值"
73
+ */
74
+ yName?: string;
75
+ /** Y 轴的单位,默认不显示。*/
76
+ yUnit?: string;
77
+ /** 各个曲线的名称、数据和其它选项(颜色、插值模式等)。*/
78
+ series?: { name: string; data: number[]; option?: LineSeriesOption }[];
79
+ /**
80
+ * 是否启用横轴下方的缩放条(`dataZoom`)
81
+ * @default true
82
+ */
83
+ dataZoom?: boolean;
84
+ }
85
+
86
+ /**
87
+ * 折线图组件,支持多条数据曲线和横轴联动。鼠标悬停在图表上时会触发联动高亮。
88
+ */
89
+ function LinkedLineChart(props: LinkedLineChartProps) {
90
+ const {
91
+ yName = "数值",
92
+ yUnit,
93
+ series = [],
94
+ dataZoom = true,
95
+ ...rest
96
+ } = props;
97
+ const unitString = yUnit !== undefined ? `(${yUnit})` : "";
98
+ const isDark =
99
+ useContext(ConfigProvider.ConfigContext).theme?.algorithm ===
100
+ theme.darkAlgorithm;
101
+ const echartsRef = useRef<EChartsReact>(null);
102
+ const { listeners, xData, xName } = useContext(LineChartLinkContext);
103
+ const currentId = useId();
104
+ const option = echarts.buildEChartsOption(
105
+ {
106
+ backgroundColor: "transparent",
107
+ },
108
+ echarts.useGrid({
109
+ option: {
110
+ id: "grid",
111
+ left: 12,
112
+ right: 42,
113
+ bottom: dataZoom ? 40 : 0,
114
+ },
115
+ xAxis: {
116
+ name: xName,
117
+ data: xData,
118
+ axisPointer: { show: true },
119
+ },
120
+ yAxis: {
121
+ name: yName + unitString,
122
+ axisPointer: { show: false },
123
+ },
124
+ series: series.map((series) => echarts.lineSeries(series)),
125
+ }),
126
+ echarts.legend({
127
+ data: series.map((series) => series.name),
128
+ option: { right: 42 },
129
+ }),
130
+ echarts.tooltip({
131
+ trigger: "axis",
132
+ position: (
133
+ [x],
134
+ params,
135
+ dom,
136
+ rect,
137
+ { contentSize: [popupWidth], viewSize: [chartWidth] },
138
+ ) => [
139
+ x + 10 + popupWidth <= chartWidth ? x + 10 : x - 10 - popupWidth,
140
+ 20,
141
+ ],
142
+ }),
143
+ {
144
+ dataZoom: [
145
+ {
146
+ show: dataZoom,
147
+ orient: "horizontal",
148
+ bottom: 8,
149
+ filterMode: "empty",
150
+ },
151
+ ],
152
+ },
153
+ );
154
+ const onEvents = useMemo(
155
+ () => ({
156
+ highlight: (payload: Payload) => {
157
+ const batch = payload.batch;
158
+ if (!batch || batch.length === 0) return;
159
+ listeners.forEach((listener) =>
160
+ listener({
161
+ type: "highlight",
162
+ sourceId: currentId,
163
+ xIndex: batch[0].dataIndex,
164
+ }),
165
+ );
166
+ },
167
+ downplay: () => {
168
+ listeners.forEach((listener) =>
169
+ listener({ type: "downplay", sourceId: currentId }),
170
+ );
171
+ },
172
+ datazoom: (payload: Payload) => {
173
+ listeners.forEach((listener) =>
174
+ listener({
175
+ type: "datazoom",
176
+ sourceId: currentId,
177
+ start: payload.start,
178
+ end: payload.end,
179
+ }),
180
+ );
181
+ },
182
+ }),
183
+ [currentId, listeners],
184
+ );
185
+ useEffect(() => {
186
+ const listener = (event: LinkEvent) => {
187
+ if (event.sourceId === currentId) return;
188
+ const chart = echartsRef.current?.getEchartsInstance();
189
+ if (!chart) return;
190
+ switch (event.type) {
191
+ case "highlight":
192
+ chart.dispatchAction(
193
+ { type: "showTip", seriesIndex: 0, dataIndex: event.xIndex },
194
+ { silent: true },
195
+ );
196
+ break;
197
+ case "downplay":
198
+ chart.setOption({ xAxis: { axisPointer: { status: "hide" } } });
199
+ chart.dispatchAction({ type: "hideTip" }, { silent: true });
200
+ break;
201
+ case "datazoom":
202
+ chart.dispatchAction(
203
+ { type: "dataZoom", start: event.start, end: event.end },
204
+ { silent: true },
205
+ );
206
+ break;
207
+ }
208
+ };
209
+ listeners.push(listener);
210
+ return () => listeners.splice(listeners.indexOf(listener), 1) && undefined;
211
+ }, [currentId, listeners]);
212
+ return (
213
+ <EChartsReact
214
+ ref={echartsRef}
215
+ option={option}
216
+ onEvents={onEvents}
217
+ theme={isDark ? "dark" : undefined}
218
+ {...rest}
219
+ />
220
+ );
221
+ }
222
+
223
+ export default LinkedLineChart;
@@ -0,0 +1,84 @@
1
+ import {
2
+ Button,
3
+ type ButtonProps,
4
+ DatePicker,
5
+ Space,
6
+ type SpaceProps,
7
+ } from "antd";
8
+ import dayjs, { type Dayjs } from "dayjs";
9
+ import type { NoUndefinedRangeValueType } from "rc-picker/lib/PickerInput/RangePicker";
10
+ import type { RangePickerProps } from "antd/es/date-picker";
11
+ import { useState } from "react";
12
+
13
+ export interface QuickDateRangePickerProps
14
+ extends Omit<SpaceProps, "onChange"> {
15
+ /** 一个二元组 `[start, end]`,表示时间段的开始和结束。*/
16
+ value?: NoUndefinedRangeValueType<Dayjs>;
17
+ onChange?: (range: NoUndefinedRangeValueType<Dayjs>) => void;
18
+ /**
19
+ * 内部按钮的属性,参见 [Ant Design 的 `Button` API][1]。
20
+ *
21
+ * [1]: https://ant-design.antgroup.com/components/button-cn#api
22
+ */
23
+ buttonProps?: ButtonProps;
24
+ /**
25
+ * 内部日期范围选择器的属性,参见 [Ant Design 的 `DatePicker.RangePicker` API][1]。
26
+ *
27
+ * [1]: https://ant-design.antgroup.com/components/date-picker-cn#rangepicker
28
+ */
29
+ pickerProps?: RangePickerProps;
30
+ }
31
+
32
+ const QUICK_PICK_RANGES = [
33
+ { value: 3, label: "最近 3 天" },
34
+ { value: 5, label: "最近 5 天" },
35
+ { value: 7, label: "最近 7 天" },
36
+ ];
37
+
38
+ /**
39
+ * 带有快速选择最近 3/5/7 天功能的日期范围选择器。
40
+ *
41
+ * 除了文档中列出的属性外,该组件会将其它属性传递给 [Ant Design 的 `Space` 组件][1]。
42
+ *
43
+ * [1]: https://ant-design.antgroup.com/components/space-cn#api
44
+ */
45
+ function QuickDateRangePicker(props: QuickDateRangePickerProps) {
46
+ const { value, onChange, buttonProps, pickerProps, ...rest } = props;
47
+ const [range, setRange] =
48
+ value === undefined
49
+ ? // eslint-disable-next-line react-hooks/rules-of-hooks
50
+ useState<NoUndefinedRangeValueType<Dayjs> | undefined>(undefined)
51
+ : [value, () => {}];
52
+ function quickPick(value: number) {
53
+ const end = dayjs();
54
+ const start = end.subtract(value - 1, "day");
55
+ const newRange: NoUndefinedRangeValueType<Dayjs> = [start, end];
56
+ setRange(newRange);
57
+ onChange?.(newRange);
58
+ }
59
+ return (
60
+ <Space {...rest}>
61
+ {/* TODO i18n */}
62
+ {QUICK_PICK_RANGES.map((item, index) => (
63
+ <Button
64
+ key={index}
65
+ onClick={() => quickPick(item.value)}
66
+ {...buttonProps}
67
+ >
68
+ {item.label}
69
+ </Button>
70
+ ))}
71
+ <DatePicker.RangePicker
72
+ value={range}
73
+ allowClear={false}
74
+ onChange={(range) => {
75
+ setRange(range!);
76
+ onChange?.(range!); // 由于 allowClear=false,这里 range 不可能为 null
77
+ }}
78
+ {...pickerProps}
79
+ />
80
+ </Space>
81
+ );
82
+ }
83
+
84
+ export default QuickDateRangePicker;
@@ -0,0 +1,46 @@
1
+ import { Radio, type RadioGroupProps } from "antd";
2
+
3
+ /**
4
+ * 分段单选按钮。
5
+ *
6
+ * 该组件是 [Ant Design 的 `Radio.Group optionType="button"`][1] 的别名。
7
+ *
8
+ * [1]: https://ant-design.antgroup.com/components/radio-cn#radiogroup
9
+ */
10
+ function SegmentedButtons(props: RadioGroupProps) {
11
+ return <Radio.Group optionType="button" {...props} />;
12
+ }
13
+
14
+ // TODO i18n
15
+ const TIME_UNIT_OPTIONS = [
16
+ { label: "日", value: "day" },
17
+ { label: "周", value: "week" },
18
+ { label: "月", value: "month" },
19
+ ];
20
+
21
+ export interface TimeUnitSwitcherProps
22
+ extends Omit<RadioGroupProps, "onChange"> {
23
+ value?: "day" | "week" | "month";
24
+ onChange?: (value: "day" | "week" | "month") => void;
25
+ }
26
+
27
+ /**
28
+ * 时间单位(日/周/月)切换按钮。
29
+ *
30
+ * 该组件是 {@linkcode SegmentedButtons} 的特化版本。更多属性参见 [`Radio.Group`][1]。
31
+ *
32
+ * [1]: https://ant-design.antgroup.com/components/radio-cn#radiogroup
33
+ */
34
+ export function TimeUnitSwitcher(props: TimeUnitSwitcherProps) {
35
+ const { onChange, ...rest } = props;
36
+ return (
37
+ <Radio.Group
38
+ optionType="button"
39
+ options={TIME_UNIT_OPTIONS}
40
+ onChange={(e) => onChange?.(e.target.value)}
41
+ {...rest}
42
+ />
43
+ );
44
+ }
45
+
46
+ export default SegmentedButtons;