diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..10ae987df --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,156 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +CloudTower UI Kit — 基于 Ant Design 的企业级 React 组件库,采用 Yarn Workspaces + Lerna + Turbo 的 monorepo 架构。 + +## Monorepo 结构 + +- **packages/eagle** — 核心组件库(100+ 基础组件 + 30+ 复合组件),React 17 + TypeScript 5 + antd 4/5 + Linaria + Rollup +- **packages/parrot** — i18n 模块,基于 i18next,支持 zh-CN / en-US +- **packages/icons** — SVG 图标源文件(从 Figma 同步),私有包 +- **packages/icons-react** — 由 SVG 自动生成的 React 图标组件,SVGR + Rollup + +## 常用命令 + +```bash +# 安装依赖 +yarn + +# 构建所有包(turbo 编排,含 prebuild → build → typings) +yarn build + +# 启动 Storybook 开发(端口 6006) +cd packages/eagle && yarn storybook + +# 运行所有测试(vitest) +yarn test:ci + +# 运行 eagle 包测试(支持 watch 模式) +cd packages/eagle && yarn test + +# 运行单个测试文件 +cd packages/eagle && yarn test -- src/core/Button/__test__/Button.test.tsx + +# TypeScript 类型检查(生成声明文件) +cd packages/eagle && yarn typings + +# Lint(ESLint + auto-fix) +yarn lint + +# 格式化(Prettier) +yarn format + +# 构建图标(先构建 SVG,再生成 React 组件) +cd packages/icons-react && yarn prebuild && yarn build + +# Bundle 大小检查 +yarn bundlewatch +``` + +## 架构要点 + +### 组件分层 + +- **`src/core/`** — 基础 UI 组件(Button, Table, Form, Select, Modal 等),大多封装 antd4 +- **`src/coreX/`** — 复合/业务组件(BarChart, BatchOperation, CronPlan, DateRangePicker 等) +- **`src/hooks/`** — 自定义 hooks(useParrotTranslation, useElementsSize, useCTErrorMsg 等) +- **`src/store/`** — Redux store(Modal 栈管理、Chart 状态) +- **`src/styles/`** — 设计 token 和样式系统 +- **`src/UIKitProvider/`** — 顶层 Provider,管理 Kit 上下文、i18n、message + +### Ant Design 双版本 + +- **antd 4.5.0** — 现有组件使用,修改旧组件时保持 antd4 +- **antd5 (5.14.0)** — 新组件使用,需加 `prefixCls={${Antd5PrefixCls}-xxx}` 做样式隔离 + +### 样式系统 + +- **Linaria** (`@linaria/core` 的 `css`) — 零运行时 CSS-in-JS,样式变量名以 `Style` 结尾(如 `ContentStyle`) +- **SCSS** — 设计 token(颜色在 `src/styles/token/color.scss`),直接作为 SCSS 变量使用 +- **字体排版** — 使用 `src/core/Typo/index.ts` 预定义的 Linaria 样式,用 `cx` 合并 +- 不要使用 `styled`,不要使用未定义的变量 + +### i18n + +- 使用 `useParrotTranslation` hook(从 `@src/hooks/useParrotTranslation` 导入) +- 翻译文件位于 `packages/parrot/src/locales/{zh-CN,en-US}/`,分 common.json, components.json, unit.json, metric.json +- 禁止直接从 react-i18next 导入 `Trans` 和 `useTranslation`(ESLint 会报错) + +## 重要:始终使用 package.json 定义的 script + +检查、构建、测试等操作必须通过项目根目录 `package.json` 中定义的 script 执行,不要直接调用 `npx prettier`、`npx eslint` 等命令: + +```bash +yarn lint # ESLint 检查 + auto-fix(不要用 npx eslint) +yarn format # Prettier 格式化(不要用 npx prettier) +yarn test:ci # 运行测试(不要用 npx vitest) +yarn build # 构建(不要用 npx rollup) +``` + +子包的 script 同理,使用 `cd packages/eagle && yarn test` 而非直接调用 `npx vitest`。 + +## 编码规范 + +### 文件组织 + +``` +ComponentName/ + ├── index.tsx # 组件实现 + ├── component.type.ts # 类型定义(Props 命名:ComponentNameProps) + ├── component.style.ts # 样式(可选) + └── __test__/ + └── component.test.tsx +``` + +### 关键规则 + +- 每个 tsx 文件必须 `import React from "react"` +- 始终使用命名导出 `export const`,不使用 `export default`(Story 的 meta 除外) +- 使用双引号(ESLint `quotes` 规则) +- Import 排序遵循 `eslint-plugin-simple-import-sort` +- 类型定义添加 JSDoc 注释 +- 弹窗使用 `usePushModal` / `usePopModal`(从 `@src/core/KitStoreProvider` 导入) + +### 测试 + +- 框架:Vitest + jsdom + @testing-library/react +- 配置:`packages/eagle/vitest.config.ts` +- 路径别名:`@src/*` → `./src/*`,`@cloudtower/parrot` 指向本地包 + +### Storybook + +- Story 文件放在 `packages/eagle/stories/docs/{core,coreX,cascader}/` +- 每个 Story 需包含组件介绍、参数说明、多个场景用例 +- Story meta 的 title 格式:`"Core/ComponentName | 中文描述"` + +## 构建输出 + +eagle 包通过 Rollup 构建,输出: + +- `dist/cjs/` 和 `dist/esm/` — JS 模块 +- `dist/style.css` — 完整样式(含字体) +- `dist/components.css` — 组件样式(不含字体) +- `dist/font.css` — 字体样式 +- `dist/token.css` — CSS 变量 token +- `dist/src/` — 类型声明文件 + +## Git 提交规范 + +### 何时 fixup vs 新建 commit + +- **Fixup 合入旧 commit**:PR 内已有 commit 引入的内容需要修复/调整时(bug 修复、类型错误、措辞调整、review 反馈),一律 fixup 到引入该内容的 commit +- **新建 commit**:PR 新增独立工作项时(新 task、新 feature、改动不属于 PR 内任何已有 commit 的范围) +- 原则:PR 内的修修补补全部 fixup,只有全新的工作项才新建 commit,保持每个 commit 是完整的逻辑单元 + +### Fixup 操作方法 + +- 使用 `git commit --no-verify -m "fixup! "` 创建 fixup commit,然后用 `GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash --no-verify ^` 将其合入目标 commit +- 不同文件的改动如果属于不同的 commit,必须分别创建 fixup commit 指向各自的目标 commit,不要混在一起 +- `--autosquash` 必须配合 `-i` 使用,否则不会生效 + +## 发版流程 + +通过 GitHub Actions 自动化:Create Release PR workflow 触发 → Lerna 升版 → 合入 PR → 自动发布到 npm。支持 patch 和 minor 版本。版本号跟随 CloudTower 产品版本。 diff --git a/packages/eagle/__test__/setup.ts b/packages/eagle/__test__/setup.ts index 515eaa2a0..f4c901d25 100644 --- a/packages/eagle/__test__/setup.ts +++ b/packages/eagle/__test__/setup.ts @@ -1,8 +1,8 @@ import "@testing-library/jest-dom"; import { initParrotI18n } from "@cloudtower/parrot"; -import fs from "fs"; -import path from "path"; +import fs from "node:fs"; +import path from "node:path"; import { format, plugins } from "pretty-format"; import ResizeObserver from "resize-observer-polyfill"; import { expect, vi } from "vitest"; diff --git a/packages/eagle/src/core/LineChart/RenderChart.tsx b/packages/eagle/src/core/LineChart/RenderChart.tsx index 6a62635a1..cdfdff8e6 100644 --- a/packages/eagle/src/core/LineChart/RenderChart.tsx +++ b/packages/eagle/src/core/LineChart/RenderChart.tsx @@ -1,27 +1,15 @@ -import { useKitDispatch } from "@src/core/KitStoreProvider"; -import LineChartLegend from "@src/core/LineChart/LineChartLegend"; -import { MetricLegendTabStyle } from "@src/core/LineChart/styled"; -import TooltipFormatter from "@src/core/LineChart/TooltipFormatter"; -import { - ILineChartDateRange, - ILineChartGraphType, - ILineChartMetric, -} from "@src/core/LineChart/type"; -import { - convertLineChartDataStruct, - lineChartYaxisTickFormatter, -} from "@src/core/LineChart/utils"; -import useParrotTranslation from "@src/hooks/useParrotTranslation"; -import { ChartActions } from "@src/store"; +import React, { useCallback, useMemo, useState } from "react"; import { Empty as AntdEmpty } from "antd"; import { DropdownProps } from "antd5"; import cs from "classnames"; import dayjs from "dayjs"; import _ from "lodash"; -import React, { useCallback, useMemo, useState } from "react"; import { Area, AreaChart, + ReferenceArea, + ReferenceDot, + ReferenceLine, ResponsiveContainer, Tooltip, TooltipProps, @@ -36,13 +24,43 @@ import { } from "recharts/types/component/DefaultTooltipContent"; import { AxisDomain } from "recharts/types/util/types"; -import LineChartToolBar from "./LineChartToolBar"; +import { useKitDispatch } from "@src/core/KitStoreProvider"; +import LineChartLegend from "@src/core/LineChart/LineChartLegend"; +import { + ChartContentWrapper, + MetricLegendTabStyle, + ThresholdTooltipOverlay, +} from "@src/core/LineChart/styled"; +import TooltipFormatter, { + LineChartTooltipContent, +} from "@src/core/LineChart/TooltipFormatter"; +import { + ILineChartAreaHighlightRange, + ILineChartBackgroundRange, + ILineChartDateRange, + ILineChartGraphType, + ILineChartMetric, + ILineChartMetricStream, + ILineChartThresholdIntersectionInfo, + ILineChartThresholdIntersectionLabelProps, + ILineChartThresholdLineProps, +} from "@src/core/LineChart/type"; import { + convertLineChartDataStruct, + getLineChartAreaHighlightData, + getLineChartAreaHighlightRanges, + getLineChartBackgroundRanges, + getLineChartThresholdIntersections, getLineChartXAxisDomain, getYAxisDomain, lineChartTickFormatter, lineChartXaxisCal, -} from "./utils"; + lineChartYaxisTickFormatter, +} from "@src/core/LineChart/utils"; +import useParrotTranslation from "@src/hooks/useParrotTranslation"; +import { ChartActions } from "@src/store"; + +import LineChartToolBar from "./LineChartToolBar"; export interface IChartProps< TValue extends ValueType = string, @@ -60,6 +78,12 @@ export interface IChartProps< dropdownProps?: DropdownProps; onLabelsChange?: (labels: string[]) => void; metric: ILineChartMetric; + backgroundRanges?: ILineChartBackgroundRange[]; + areaHighlightRanges?: ILineChartAreaHighlightRange[]; + thresholdLineProps?: ILineChartThresholdLineProps; + renderThresholdTooltip?: ( + info: ILineChartThresholdIntersectionInfo, + ) => React.ReactElement; yAxisProps?: { domain?: AxisDomain; ticks?: (string | number)[]; @@ -83,6 +107,71 @@ export interface IChartProps< emptyIcon?: React.ReactNode | string; } +interface IHoveredThresholdIntersection { + info: ILineChartThresholdIntersectionInfo; + left: number; + top: number; +} + +interface IAreaHighlightOverlay { + key: string; + data: Array<{ + t: number; + value: number; + }>; + fill: string; + fillOpacity: number; + legendId: string; + stroke?: string; +} + +const DEFAULT_THRESHOLD_LINE_STROKE = "#ff4d4f"; +const DEFAULT_THRESHOLD_LINE_DASHARRAY = "4 4"; +const DEFAULT_THRESHOLD_INTERSECTION_LABEL_TEXT_COLOR = "#ffffff"; +const THRESHOLD_INTERSECTION_LABEL_HEIGHT = 28; +const THRESHOLD_INTERSECTION_LABEL_RADIUS = 4; +const THRESHOLD_INTERSECTION_LABEL_OFFSET = 10; +const THRESHOLD_INTERSECTION_LABEL_PADDING_X = 12; +const THRESHOLD_INTERSECTION_LABEL_MIN_WIDTH = 48; +const THRESHOLD_INTERSECTION_LABEL_FONT_SIZE = 12; +const THRESHOLD_INTERSECTION_LABEL_MARGIN_TOP = 44; +const THRESHOLD_INTERSECTION_CJK_REGEXP = /[\u3400-\u9fff\uf900-\ufaff]/; + +const getThresholdIntersectionLabelText = ( + labelProps: ILineChartThresholdIntersectionLabelProps | undefined, + info: ILineChartThresholdIntersectionInfo, +) => { + const text = labelProps?.text; + + if (_.isFunction(text)) { + return text(info) || info.formattedThresholdValue; + } + + return text || info.formattedThresholdValue; +}; + +const getThresholdIntersectionLabelWidth = (label: string) => { + const contentWidth = Array.from(label).reduce((sum, char) => { + return ( + sum + + (THRESHOLD_INTERSECTION_CJK_REGEXP.test(char) + ? THRESHOLD_INTERSECTION_LABEL_FONT_SIZE + : THRESHOLD_INTERSECTION_LABEL_FONT_SIZE * 0.65) + ); + }, 0); + + return Math.max( + THRESHOLD_INTERSECTION_LABEL_MIN_WIDTH, + Math.ceil(contentWidth) + THRESHOLD_INTERSECTION_LABEL_PADDING_X * 2, + ); +}; + +const getStreamStroke = (stream: ILineChartMetricStream) => { + return stream.legend.stroke + ? `${stream.legend.color}1A` + : stream.legend.color; +}; + const RenderChart = (props: IChartProps & { width: number }) => { const { metricName, @@ -94,8 +183,11 @@ const RenderChart = (props: IChartProps & { width: number }) => { type, mode = "legend", dropdownProps, - onLabelsChange, metric, + backgroundRanges, + areaHighlightRanges, + thresholdLineProps, + renderThresholdTooltip, yAxisProps, xAxisProps, actionsProps, @@ -112,6 +204,8 @@ const RenderChart = (props: IChartProps & { width: number }) => { const [hovering, setHovering] = useState([]); const [hoveringSelf, setHoveringSelf] = useState([]); const [tempDeselected, setTempDeselected] = useState([]); + const [hoveredThresholdIntersection, setHoveredThresholdIntersection] = + useState(null); const streams = useMemo(() => metric.sample_streams, [metric]); const legends = useMemo(() => { @@ -122,12 +216,128 @@ const RenderChart = (props: IChartProps & { width: number }) => { () => convertLineChartDataStruct(streams.map((stream) => stream.points)), [streams], ); - const yDomain = getYAxisDomain(areaChartData, type, metric.unit); const xDomain = getLineChartXAxisDomain(dateRange, dateRange[1].valueOf()); + const thresholdExtraValues = useMemo(() => { + if (_.isNumber(thresholdLineProps?.value)) { + return [thresholdLineProps.value]; + } + + return []; + }, [thresholdLineProps?.value]); + const yDomain = + yAxisProps?.domain ?? + getYAxisDomain(areaChartData, type, metric.unit, thresholdExtraValues); const xTicks = lineChartXaxisCal(xDomain[1], dateRange, width); + const normalizedBackgroundRanges = useMemo(() => { + return getLineChartBackgroundRanges(backgroundRanges, xDomain); + }, [backgroundRanges, xDomain]); + const normalizedAreaHighlightRanges = useMemo(() => { + return getLineChartAreaHighlightRanges(areaHighlightRanges, xDomain); + }, [areaHighlightRanges, xDomain]); + const hasThresholdIntersectionLabel = Boolean( + thresholdLineProps?.intersectionLabelProps, + ); + + const areaHighlightOverlays = useMemo(() => { + if (type !== ILineChartGraphType.Area) { + return []; + } + + return normalizedAreaHighlightRanges.flatMap((range, rangeIndex) => { + return streams.flatMap((stream, streamIndex) => { + if (range.legendId && stream.legend.id !== range.legendId) { + return []; + } + + const data = getLineChartAreaHighlightData(stream.points, range); + + if (data.length < 2) { + return []; + } + + return [ + { + key: `${stream.legend.id}-${range.start}-${range.end}-${rangeIndex}-${streamIndex}`, + data, + fill: range.fill, + fillOpacity: range.fillOpacity ?? 0.18, + legendId: stream.legend.id, + stroke: getStreamStroke(stream), + } satisfies IAreaHighlightOverlay, + ]; + }); + }); + }, [normalizedAreaHighlightRanges, streams, type]); + + const formatIntersectionValue = useCallback( + (streamIndex: number, value: number, timestamp: number) => { + if (!tooltipProps.format) { + return lineChartYaxisTickFormatter(value, metric.unit); + } + + const payload = { + color: legends[streamIndex]?.color, + dataKey: `v${streamIndex}`, + fill: legends[streamIndex]?.fill, + name: `v${streamIndex}`, + payload: { + [`v${streamIndex}`]: value, + t: timestamp, + v: value, + }, + stroke: legends[streamIndex]?.color, + value, + } as Payload; + + return tooltipProps.format(payload); + }, + [legends, metric.unit, tooltipProps], + ); + + const thresholdIntersections = useMemo(() => { + if (!_.isNumber(thresholdLineProps?.value)) { + return []; + } + + const formattedThresholdValue = lineChartYaxisTickFormatter( + thresholdLineProps.value, + metric.unit, + ); + + return getLineChartThresholdIntersections( + streams, + thresholdLineProps.value, + xDomain, + ).map((intersection) => { + return { + ...intersection, + formattedThresholdValue, + formattedValue: formatIntersectionValue( + intersection.streamIndex, + intersection.value, + intersection.timestamp, + ), + thresholdValue: thresholdLineProps.value, + }; + }); + }, [ + formatIntersectionValue, + metric.unit, + streams, + thresholdLineProps, + xDomain, + ]); + + const visibleThresholdIntersections = useMemo(() => { + return thresholdIntersections.filter((intersection) => { + return !deselected.includes(intersection.legend.id); + }); + }, [deselected, thresholdIntersections]); + const onLegendClick = useCallback( (id: string) => { + setHoveredThresholdIntersection(null); setDeselected((prev) => { const currentDeselected = tempDeselected.length ? tempDeselected : prev; @@ -172,10 +382,8 @@ const RenderChart = (props: IChartProps & { width: number }) => { (method: "enter" | "leave", id: string) => { if (method === "enter") { if (deselected.length) { - // If there are unselected metrics setTempDeselected(deselected); if (deselected.includes(id)) { - // If hovering over an unselected metric, select the currently unselected metric setDeselected( streams .map((stream) => stream.legend.id) @@ -197,7 +405,6 @@ const RenderChart = (props: IChartProps & { width: number }) => { .filter((legendId) => legendId === id), ); } else { - // If more than one metric is selected, hovering does not change the selected metrics, but the hovered metric will be highlighted if (deselected.length === streams.length - 1) { setDeselected([]); setHovering( @@ -222,7 +429,6 @@ const RenderChart = (props: IChartProps & { width: number }) => { } } } else { - // If all metrics are selected setHovering( streams .map((stream) => stream.legend.id) @@ -240,7 +446,9 @@ const RenderChart = (props: IChartProps & { width: number }) => { }, [deselected, streams, tempDeselected], ); + const hidePointer: CategoricalChartFunc = useCallback(() => { + setHoveredThresholdIntersection(null); dispatch({ type: ChartActions.SET_POINTER, payload: { visible: false, uuid: syncId }, @@ -271,6 +479,55 @@ const RenderChart = (props: IChartProps & { width: number }) => { [dispatch, syncId], ); + const handleThresholdIntersectionEnter = useCallback( + (info: ILineChartThresholdIntersectionInfo, left: number, top: number) => { + setHoveredThresholdIntersection({ + info, + left, + top, + }); + }, + [], + ); + + const thresholdTooltipContent = useMemo(() => { + if (!hoveredThresholdIntersection) { + return null; + } + + const renderer = + thresholdLineProps?.renderTooltip ?? renderThresholdTooltip; + + if (renderer) { + return renderer(hoveredThresholdIntersection.info); + } + + if (hasThresholdIntersectionLabel) { + return null; + } + + return ( + + ); + }, [ + hasThresholdIntersectionLabel, + hoveredThresholdIntersection, + renderThresholdTooltip, + thresholdLineProps?.renderTooltip, + ]); + if (!streams?.length || streams.every((stream) => !stream.points?.length)) { return (
@@ -313,94 +570,274 @@ const RenderChart = (props: IChartProps & { width: number }) => { onLegendHover={onLegendHover} /> - - - lineChartTickFormatter(tick, dateRange)} - {...xAxisProps} - /> - - lineChartYaxisTickFormatter(tick, metric.unit) + + + - + lineChartTickFormatter(tick, dateRange)} + {...xAxisProps} + /> + + lineChartYaxisTickFormatter(tick, metric.unit) + } + {...yAxisProps} + /> + {normalizedBackgroundRanges.map((range, index) => { + return ( + - ) - } - {...tooltipProps} - /> - {streams.map((item, index) => { - if (deselected.includes(item.legend.id)) { - return null; - } - - return ( - - ); - })} - - + )} + + ) + } + {...tooltipProps} + /> + {streams.map((item, index) => { + if (deselected.includes(item.legend.id)) { + return null; + } + + return ( + + ); + })} + {areaHighlightOverlays.map((overlay) => { + if (deselected.includes(overlay.legendId)) { + return null; + } + + return ( + + ); + })} + {visibleThresholdIntersections.map((intersection, index) => { + return ( + { + const { cx = 0, cy = 0 } = shapeProps; + const intersectionOpacity = hovering.includes( + intersection.legend.id, + ) + ? 0.3 + : 1; + const intersectionLabelText = + getThresholdIntersectionLabelText( + thresholdLineProps?.intersectionLabelProps, + intersection, + ); + const intersectionLabelColor = + thresholdLineProps?.intersectionLabelProps?.color || + thresholdLineProps?.stroke || + DEFAULT_THRESHOLD_LINE_STROKE; + const intersectionLabelTextColor = + thresholdLineProps?.intersectionLabelProps?.textColor || + DEFAULT_THRESHOLD_INTERSECTION_LABEL_TEXT_COLOR; + const intersectionLabelWidth = + getThresholdIntersectionLabelWidth(intersectionLabelText); + const intersectionLabelX = cx - intersectionLabelWidth / 2; + const intersectionLabelY = + cy - + THRESHOLD_INTERSECTION_LABEL_HEIGHT - + THRESHOLD_INTERSECTION_LABEL_OFFSET; + + return ( + + handleThresholdIntersectionEnter(intersection, cx, cy) + } + onMouseLeave={() => + setHoveredThresholdIntersection(null) + } + > + + {hasThresholdIntersectionLabel ? ( + + + + {intersectionLabelText} + + + ) : ( + + )} + + ); + }} + /> + ); + })} + + + {hoveredThresholdIntersection && thresholdTooltipContent && ( + + {thresholdTooltipContent} + + )} + ); }; diff --git a/packages/eagle/src/core/LineChart/TooltipFormatter.tsx b/packages/eagle/src/core/LineChart/TooltipFormatter.tsx index 6f27829ca..b2d742c55 100644 --- a/packages/eagle/src/core/LineChart/TooltipFormatter.tsx +++ b/packages/eagle/src/core/LineChart/TooltipFormatter.tsx @@ -13,6 +13,35 @@ const TooltipTitle = styled.div` color: $text-light-on-tint; `; +export interface ILineChartTooltipItem { + id: string; + color?: string; + label?: React.ReactNode; + value?: React.ReactNode; +} + +export const LineChartTooltipContent: React.FC<{ + title: React.ReactNode; + items: ILineChartTooltipItem[]; +}> = ({ title, items }) => { + return ( + + {title} + {items.map((item) => { + return ( + +
+ + {item.label} +
+
{item.value}
+
+ ); + })} +
+ ); +}; + const TooltipFormatter: React.FC< TooltipProps & { deselected: string[]; @@ -26,33 +55,38 @@ const TooltipFormatter: React.FC< return null; } - return ( - - { - - {dayjs(Number(payload[0].payload.t)).format("MM/DD HH:mm:ss")} - + const items = payload + .map((item) => { + return { + ...item, + legend: legends.find((_legend, index) => `v${index}` === item.name), + }; + }) + .sort((a, b) => (b.value as number) - (a.value as number)) + .flatMap((item) => { + if (!item.legend) { + return []; } - {payload - .map((item) => { - return { - ...item, - legend: legends.find((_legend, index) => `v${index}` === item.name), - }; - }) - .sort((a, b) => (b.value as number) - (a.value as number)) - .map((item) => { - return deselected.includes(item.legend?.id || "") ? null : ( - -
- - {item.legend?.name} -
-
{format(item)}
-
- ); - })} -
+ + if (deselected.includes(item.legend?.id || "")) { + return []; + } + + return [ + { + id: item.legend?.id || `${item.name}`, + color: item.legend?.color, + label: item.legend?.name, + value: format(item), + }, + ]; + }); + + return ( + ); }; diff --git a/packages/eagle/src/core/LineChart/__test__/RenderChart.test.tsx b/packages/eagle/src/core/LineChart/__test__/RenderChart.test.tsx new file mode 100644 index 000000000..5c5d88fb7 --- /dev/null +++ b/packages/eagle/src/core/LineChart/__test__/RenderChart.test.tsx @@ -0,0 +1,242 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import dayjs from "dayjs"; +import { describe, expect, it, vi } from "vitest"; + +import KitStoreProvider from "@src/core/KitStoreProvider"; +import RenderChart from "@src/core/LineChart/RenderChart"; +import { + ILineChartGraphType, + ILineChartMetricUnit, +} from "@src/core/LineChart/type"; + +vi.mock("@src/core/LineChart/LineChartLegend", () => { + const ColorBlock = ({ background }: { background?: string }) => { + return {background}; + }; + + return { + LineChartColorBlock: ColorBlock, + default: () =>
, + }; +}); + +vi.mock("@src/core/LineChart/LineChartToolBar", () => { + return { + default: () =>
, + }; +}); + +vi.mock("recharts", async () => { + const actual = await vi.importActual("recharts"); + + return { + ...actual, + ResponsiveContainer: ({ + children, + height, + }: { + children: React.ReactNode; + height?: number | string; + }) => { + const resolvedHeight = + typeof height === "number" ? height : Number.parseInt(`${height}`, 10); + + return ( +
+ {React.isValidElement(children) + ? React.cloneElement(children, { + height: resolvedHeight || 180, + width: 800, + }) + : children} +
+ ); + }, + }; +}); + +const minute = 60 * 1000; +const rangeStart = dayjs("2026-01-06T00:00:00.000Z"); +const rangeEnd = rangeStart.add(30, "minute"); + +const metric = { + sample_streams: [ + { + legend: { + id: "forecast", + name: "存储预计使用量", + color: "#8f63ff", + fill: "#efe9ff", + }, + step: minute, + tolerance: 0, + points: [ + { + t: rangeStart.valueOf(), + v: 2, + }, + { + t: rangeStart.add(10, "minute").valueOf(), + v: 8, + }, + { + t: rangeStart.add(20, "minute").valueOf(), + v: 4, + }, + { + t: rangeEnd.valueOf(), + v: 9, + }, + ], + }, + ], + unit: ILineChartMetricUnit.Percent, + dropped: false, +}; + +const renderChart = ( + customProps?: Partial>, +) => { + return render( + + `${payload.value}%`, + }} + backgroundRanges={[ + { + start: rangeStart.valueOf(), + end: rangeStart.add(8, "minute").valueOf(), + fill: "#ff7875", + fillOpacity: 0.18, + }, + { + start: rangeStart.add(14, "minute").valueOf(), + end: rangeStart.add(18, "minute").valueOf(), + fill: "#ffd666", + }, + { + start: rangeStart.add(21, "minute").valueOf(), + end: rangeStart.add(19, "minute").valueOf(), + fill: "#bae637", + }, + ]} + thresholdLineProps={{ + value: 5, + }} + {...customProps} + /> + , + ); +}; + +describe("RenderChart", () => { + it("renders background ranges, threshold line, and threshold intersections", () => { + renderChart(); + + expect(screen.getAllByTestId("line-chart-background-range")).toHaveLength( + 2, + ); + expect(screen.getByTestId("line-chart-threshold-line")).toBeInTheDocument(); + expect( + screen.getAllByTestId("line-chart-threshold-intersection-dot"), + ).toHaveLength(3); + }); + + it("renders curve-only area highlights for the configured time ranges", () => { + renderChart({ + areaHighlightRanges: [ + { + start: rangeStart.add(5, "minute").valueOf(), + end: rangeStart.add(15, "minute").valueOf(), + fill: "#ff7875", + fillOpacity: 0.2, + legendId: "forecast", + }, + ], + }); + + expect(screen.getAllByTestId("line-chart-area-highlight")).toHaveLength(1); + }); + + it("shows the default threshold tooltip when hovering an intersection", () => { + renderChart(); + + fireEvent.mouseEnter( + screen.getAllByTestId("line-chart-threshold-intersection-dot")[0], + ); + + expect( + screen.getByTestId("line-chart-threshold-tooltip"), + ).toBeInTheDocument(); + expect(screen.getByText("存储预计使用量")).toBeInTheDocument(); + expect(screen.getByText("5%")).toBeInTheDocument(); + }); + + it("renders threshold intersection labels above the markers when configured", () => { + renderChart({ + thresholdLineProps: { + value: 5, + intersectionLabelProps: { + text: "24 天", + color: "#ff4d4f", + }, + }, + }); + + expect( + screen.getAllByTestId("line-chart-threshold-intersection-label"), + ).toHaveLength(3); + expect(screen.getAllByText("24 天")).toHaveLength(3); + expect( + screen.getAllByTestId( + "line-chart-threshold-intersection-label-background", + )[0], + ).toHaveAttribute("fill", "#ff4d4f"); + }); + + it("uses custom threshold tooltip renderer when provided", () => { + renderChart({ + thresholdLineProps: { + value: 5, + renderTooltip: (info) => { + return
{`Runway: ${info.formattedThresholdValue}`}
; + }, + }, + }); + + fireEvent.mouseEnter( + screen.getAllByTestId("line-chart-threshold-intersection-dot")[0], + ); + + expect(screen.getByText(/Runway:\s+5(\.00)?\s%/)).toBeInTheDocument(); + }); + + it("supports dynamic threshold intersection label text", () => { + const legendName = metric.sample_streams[0].legend.name; + + renderChart({ + thresholdLineProps: { + value: 5, + intersectionLabelProps: { + text: (info) => `${info.legend.name} 24 天`, + }, + }, + }); + + expect(screen.getAllByText(`${legendName} 24 天`)).toHaveLength(3); + }); +}); diff --git a/packages/eagle/src/core/LineChart/__test__/utils.test.ts b/packages/eagle/src/core/LineChart/__test__/utils.test.ts new file mode 100644 index 000000000..c6a0f84ad --- /dev/null +++ b/packages/eagle/src/core/LineChart/__test__/utils.test.ts @@ -0,0 +1,244 @@ +import { + ILineChartGraphType, + ILineChartMetricUnit, +} from "@src/core/LineChart/type"; +import { + getLineChartAreaHighlightData, + getLineChartAreaHighlightRanges, + getLineChartBackgroundRanges, + getLineChartThresholdIntersections, + getYAxisDomain, +} from "@src/core/LineChart/utils"; +import { describe, expect, it } from "vitest"; + +describe("LineChart utils", () => { + it("clips background ranges to the current x domain and filters invalid ranges", () => { + expect( + getLineChartBackgroundRanges( + [ + { + start: 0, + end: 50, + fill: "#ff4d4f", + }, + { + start: 120, + end: 80, + fill: "#1890ff", + }, + { + start: 160, + end: 260, + fill: "#52c41a", + fillOpacity: 0.2, + }, + ], + [20, 200], + ), + ).toEqual([ + { + start: 20, + end: 50, + fill: "#ff4d4f", + }, + { + start: 160, + end: 200, + fill: "#52c41a", + fillOpacity: 0.2, + }, + ]); + }); + + it("clips area highlight ranges and keeps legend binding", () => { + expect( + getLineChartAreaHighlightRanges( + [ + { + start: 0, + end: 40, + fill: "#ff4d4f", + legendId: "cpu", + }, + { + start: 120, + end: 80, + fill: "#1890ff", + }, + ], + [20, 100], + ), + ).toEqual([ + { + start: 20, + end: 40, + fill: "#ff4d4f", + legendId: "cpu", + }, + ]); + }); + + it("calculates area highlight points with boundary interpolation", () => { + expect( + getLineChartAreaHighlightData( + [ + { + t: 0, + v: 0, + }, + { + t: 10, + v: 10, + }, + { + t: 20, + v: 0, + }, + ], + { + start: 5, + end: 15, + }, + ), + ).toEqual([ + { + t: 5, + value: 5, + }, + { + t: 10, + value: 10, + }, + { + t: 15, + value: 5, + }, + ]); + }); + + it("calculates threshold intersections with interpolation and removes endpoint duplicates", () => { + const legend = { + id: "cpu", + name: "CPU", + color: "#1890ff", + }; + + const intersections = getLineChartThresholdIntersections( + [ + { + legend, + step: 1, + tolerance: 0, + points: [ + { + t: 0, + v: 0, + }, + { + t: 10, + v: 5, + }, + { + t: 20, + v: 10, + }, + { + t: 30, + v: 0, + }, + ], + }, + ], + 5, + [0, 30], + ); + + expect(intersections).toEqual([ + { + timestamp: 10, + value: 5, + legend, + streamIndex: 0, + }, + { + timestamp: 25, + value: 5, + legend, + streamIndex: 0, + }, + ]); + }); + + it("does not emit repeated markers for a flat segment that stays on the threshold", () => { + const legend = { + id: "memory", + name: "Memory", + color: "#52c41a", + }; + + const intersections = getLineChartThresholdIntersections( + [ + { + legend, + step: 1, + tolerance: 0, + points: [ + { + t: 0, + v: 0, + }, + { + t: 10, + v: 5, + }, + { + t: 20, + v: 5, + }, + { + t: 30, + v: 10, + }, + ], + }, + ], + 5, + [0, 30], + ); + + expect(intersections).toEqual([ + { + timestamp: 10, + value: 5, + legend, + streamIndex: 0, + }, + { + timestamp: 20, + value: 5, + legend, + streamIndex: 0, + }, + ]); + }); + + it("extends the computed y-axis domain when a threshold is above the data max", () => { + const domain = getYAxisDomain( + [ + { + t: 0, + v0: 1, + }, + { + t: 1, + v0: 2, + }, + ], + ILineChartGraphType.Area, + ILineChartMetricUnit.Count, + [10], + ); + + expect(domain[1]).toBeGreaterThanOrEqual(10); + }); +}); diff --git a/packages/eagle/src/core/LineChart/styled.ts b/packages/eagle/src/core/LineChart/styled.ts index a9eb73e1c..601ad3bee 100644 --- a/packages/eagle/src/core/LineChart/styled.ts +++ b/packages/eagle/src/core/LineChart/styled.ts @@ -198,6 +198,20 @@ export const LineChartWrapper = styled.div` } `; +export const ChartContentWrapper = styled.div` + position: relative; + width: 100%; +`; + +export const ThresholdTooltipOverlay = styled.div` + position: absolute; + left: 0; + top: 0; + transform: translate(-50%, calc(-100% - 12px)); + pointer-events: none; + z-index: 1; +`; + export const TooltipWrapper = styled.div` min-width: 200px; background-color: $fill-neutral-trans-8; diff --git a/packages/eagle/src/core/LineChart/type.ts b/packages/eagle/src/core/LineChart/type.ts index 4c2fa3fe4..9f49a14ef 100644 --- a/packages/eagle/src/core/LineChart/type.ts +++ b/packages/eagle/src/core/LineChart/type.ts @@ -64,13 +64,65 @@ export enum ILineChartGraphType { export type ILineChartDateRange = [Dayjs, Dayjs]; +/** + * Background highlight range along the time axis. + */ +export interface ILineChartBackgroundRange { + start: number; + end: number; + fill: string; + fillOpacity?: number; +} + +/** + * Curve-only area highlight range within a time segment. + */ +export interface ILineChartAreaHighlightRange { + start: number; + end: number; + fill: string; + fillOpacity?: number; + legendId?: string; +} + +/** + * Threshold intersection tooltip payload. + */ +export interface ILineChartThresholdIntersectionInfo { + timestamp: number; + value: number; + thresholdValue: number; + formattedValue: string; + formattedThresholdValue: string; + legend: ILineChartILegend; +} + +export interface ILineChartThresholdIntersectionLabelProps { + text?: string | ((info: ILineChartThresholdIntersectionInfo) => string); + color?: string; + textColor?: string; +} + +/** + * Single horizontal threshold line configuration. + */ +export interface ILineChartThresholdLineProps { + value: number; + stroke?: string; + strokeDasharray?: string; + intersectionLabelProps?: ILineChartThresholdIntersectionLabelProps; + renderTooltip?: (info: ILineChartThresholdIntersectionInfo) => ReactElement; +} + export type ChartProps = { metric: string; legendTooltip?: ReactElement; - renderThresholdTooltip?: (info: { - current: string; - max: string; - }) => ReactElement; + renderThresholdTooltip?: ( + info: ILineChartThresholdIntersectionInfo, + ) => ReactElement; + backgroundRanges?: ILineChartBackgroundRange[]; + areaHighlightRanges?: ILineChartAreaHighlightRange[]; + thresholdLineProps?: ILineChartThresholdLineProps; yAxisAlign?: "left" | "right"; showXaxis?: boolean; showLegend?: boolean; @@ -97,131 +149,45 @@ export interface ILineChartLegend { } /** - * 折线图组件属性接口 - * @description 用于展示时序数据的折线图组件,支持多种展示模式和交互功能 + * LineChart component props. */ export type LineChartProps = { - /** - * 数据测试 id - */ "data-testid"?: string; - - /** - * 图表高度 - * @default 154 - */ height?: number; - - /** - * 是否显示鼠标悬停指示器 - * @default true - */ showPointer?: boolean; - - /** - * Y轴对齐方式 - * @default "left" - */ yAxisAlign?: "left" | "right"; - - /** - * 是否显示X轴 - * @default false - */ showXaxis?: boolean; - - /** - * 是否显示图例 - * @default true - */ showLegend?: boolean; - - /** - * 图表核心配置项 - * @description 包含图表的所有核心配置,如数据、样式、交互等 - */ chartProps: Omit; }; -/** - * 图例名称格式化函数类型 - */ export type FormatName = (params: { - /** 指标类型 */ type: string | undefined; - /** 指标名称 */ metricName: string; - /** 国际化函数 */ t: TFunction; - /** 数据索引 */ dIndex: number; }) => string; /** - * 指标属性接口 - * @description 用于配置指标相关的展示和行为 + * Metric-driven LineChart props. */ export type LineChartMetricProps = { - /** 指标名称 */ metric: string; - - /** - * 图表高度 - * @default 154 - */ height?: number; - - /** - * 是否显示鼠标悬停指示器 - * @default true - */ showPointer?: boolean; - - /** - * Y轴对齐方式 - * @default 'left' - */ yAxisAlign?: "left" | "right"; - - /** - * 是否显示X轴 - * @default false - */ showXaxis?: boolean; - - /** - * 是否显示图例 - * @default true - */ showLegend?: boolean; - - /** - * 指标宽度配置 - * @description 用于配置不同指标的宽度 - */ metricWidth?: Record; - - /** - * 展示模式 - * @default 'legend' - * - simple: 简单模式,不显示图例 - * - legend: 图例模式,显示完整图例 - * - single: 单一模式,适用于单指标展示 - */ mode?: "simple" | "legend" | "single"; - - /** - * 是否显示平均线 - * @default false - */ averageLine?: boolean; - - /** 日期范围 */ dateRange?: PickerDateRange; - - /** 图例项名称格式化函数 */ formatLegendItemName?: FormatName; - - /** 历史记录对象 */ + backgroundRanges?: ILineChartBackgroundRange[]; + areaHighlightRanges?: ILineChartAreaHighlightRange[]; + thresholdLineProps?: ILineChartThresholdLineProps; + renderThresholdTooltip?: ( + info: ILineChartThresholdIntersectionInfo, + ) => ReactElement; history: History; -} & Pick; +} & Pick; diff --git a/packages/eagle/src/core/LineChart/utils.ts b/packages/eagle/src/core/LineChart/utils.ts index aa526d3d7..5a9ef71f9 100644 --- a/packages/eagle/src/core/LineChart/utils.ts +++ b/packages/eagle/src/core/LineChart/utils.ts @@ -1,9 +1,13 @@ import { + ILineChartAreaHighlightRange, + ILineChartBackgroundRange, ILineChartDataPoint, ILineChartDateRange, ILineChartGraphType, + ILineChartILegend, ILineChartMetric, ILineChartMetricUnit, + ILineChartMetricStream, } from "@src/core/LineChart/type"; import { DAY, @@ -23,6 +27,18 @@ import { import dayjs from "dayjs"; import _ from "lodash"; +export interface ILineChartThresholdIntersectionPoint { + timestamp: number; + value: number; + legend: ILineChartILegend; + streamIndex: number; +} + +export interface ILineChartAreaHighlightPoint { + t: number; + value: number; +} + export function filterLineChartPointsByDateRange( points: ILineChartDataPoint[], dateRange?: ILineChartDateRange, @@ -101,6 +117,36 @@ export function getLineChartXAxisDomain( return [startDate.valueOf(), xaxisLastTime ?? endDate.valueOf()]; } +export const getLineChartBackgroundRanges = ( + ranges: ILineChartBackgroundRange[] = [], + xDomain: [number, number], +) => { + const [domainStart, domainEnd] = xDomain; + + return ranges + .map((range) => ({ + ...range, + start: Math.max(range.start, domainStart), + end: Math.min(range.end, domainEnd), + })) + .filter((range) => range.start < range.end); +}; + +export const getLineChartAreaHighlightRanges = ( + ranges: ILineChartAreaHighlightRange[] = [], + xDomain: [number, number], +) => { + const [domainStart, domainEnd] = xDomain; + + return ranges + .map((range) => ({ + ...range, + start: Math.max(range.start, domainStart), + end: Math.min(range.end, domainEnd), + })) + .filter((range) => range.start < range.end); +}; + export const getLineChartRangeTimestamp = ( dateRange: ILineChartDateRange, ): number => { @@ -108,6 +154,215 @@ export const getLineChartRangeTimestamp = ( return endDate.valueOf() - startDate.valueOf(); }; +const isValidLineChartValue = (value?: number): value is number => { + return _.isNumber(value) && Number.isFinite(value); +}; + +const getLineChartInterpolatedValue = ( + currentPoint: ILineChartDataPoint, + nextPoint: ILineChartDataPoint, + timestamp: number, +) => { + if ( + !isValidLineChartValue(currentPoint.v) || + !isValidLineChartValue(nextPoint.v) || + currentPoint.t === nextPoint.t + ) { + return undefined; + } + + if (timestamp === currentPoint.t) { + return currentPoint.v; + } + + if (timestamp === nextPoint.t) { + return nextPoint.v; + } + + const ratio = (timestamp - currentPoint.t) / (nextPoint.t - currentPoint.t); + return currentPoint.v + (nextPoint.v - currentPoint.v) * ratio; +}; + +const isSameAreaHighlightPoint = ( + prev: ILineChartAreaHighlightPoint | undefined, + next: ILineChartAreaHighlightPoint, +) => { + if (!prev) { + return false; + } + + return Math.abs(prev.t - next.t) < 1e-6 && Math.abs(prev.value - next.value) < 1e-6; +}; + +const pushLineChartAreaHighlightPoint = ( + points: ILineChartAreaHighlightPoint[], + next: ILineChartAreaHighlightPoint, +) => { + if (!isSameAreaHighlightPoint(points[points.length - 1], next)) { + points.push(next); + } +}; + +export const getLineChartAreaHighlightData = ( + points: ILineChartDataPoint[], + range: Pick, +) => { + const highlightedPoints: ILineChartAreaHighlightPoint[] = []; + + for (let index = 0; index < points.length - 1; index++) { + const currentPoint = points[index]; + const nextPoint = points[index + 1]; + + if ( + !isValidLineChartValue(currentPoint?.v) || + !isValidLineChartValue(nextPoint?.v) || + currentPoint.t === nextPoint.t + ) { + continue; + } + + if (nextPoint.t < range.start || currentPoint.t > range.end) { + continue; + } + + const segmentStart = Math.max(range.start, currentPoint.t); + const segmentEnd = Math.min(range.end, nextPoint.t); + + if (segmentStart > segmentEnd) { + continue; + } + + const startValue = getLineChartInterpolatedValue( + currentPoint, + nextPoint, + segmentStart, + ); + const endValue = getLineChartInterpolatedValue( + currentPoint, + nextPoint, + segmentEnd, + ); + + if (!isValidLineChartValue(startValue) || !isValidLineChartValue(endValue)) { + continue; + } + + pushLineChartAreaHighlightPoint(highlightedPoints, { + t: segmentStart, + value: startValue, + }); + pushLineChartAreaHighlightPoint(highlightedPoints, { + t: segmentEnd, + value: endValue, + }); + } + + return highlightedPoints.length >= 2 ? highlightedPoints : []; +}; + +const isSameIntersectionPoint = ( + prev: ILineChartThresholdIntersectionPoint | undefined, + next: ILineChartThresholdIntersectionPoint, +) => { + if (!prev) { + return false; + } + + return ( + prev.streamIndex === next.streamIndex && + Math.abs(prev.timestamp - next.timestamp) < 1e-6 && + Math.abs(prev.value - next.value) < 1e-6 + ); +}; + +const pushLineChartIntersection = ( + intersections: ILineChartThresholdIntersectionPoint[], + next: ILineChartThresholdIntersectionPoint, +) => { + if (!isSameIntersectionPoint(intersections[intersections.length - 1], next)) { + intersections.push(next); + } +}; + +export const getLineChartThresholdIntersections = ( + streams: ILineChartMetricStream[], + thresholdValue: number, + xDomain?: [number, number], +) => { + const [domainStart, domainEnd] = xDomain ?? [-Infinity, Infinity]; + + return streams.flatMap((stream, streamIndex) => { + const intersections: ILineChartThresholdIntersectionPoint[] = []; + + for (let index = 0; index < stream.points.length - 1; index++) { + const currentPoint = stream.points[index]; + const nextPoint = stream.points[index + 1]; + + if ( + !isValidLineChartValue(currentPoint?.v) || + !isValidLineChartValue(nextPoint?.v) || + currentPoint.t === nextPoint.t + ) { + continue; + } + + const currentDiff = currentPoint.v - thresholdValue; + const nextDiff = nextPoint.v - thresholdValue; + + if (currentDiff === 0 && nextDiff === 0) { + continue; + } + + if ( + currentDiff === 0 && + currentPoint.t >= domainStart && + currentPoint.t <= domainEnd + ) { + pushLineChartIntersection(intersections, { + timestamp: currentPoint.t, + value: thresholdValue, + legend: stream.legend, + streamIndex, + }); + } + + if (currentDiff * nextDiff < 0) { + const ratio = currentDiff / (currentDiff - nextDiff); + const timestamp = + currentPoint.t + (nextPoint.t - currentPoint.t) * ratio; + + if (timestamp >= domainStart && timestamp <= domainEnd) { + pushLineChartIntersection(intersections, { + timestamp, + value: thresholdValue, + legend: stream.legend, + streamIndex, + }); + } + } + + if ( + nextDiff === 0 && + nextPoint.t >= domainStart && + nextPoint.t <= domainEnd + ) { + pushLineChartIntersection(intersections, { + timestamp: nextPoint.t, + value: thresholdValue, + legend: stream.legend, + streamIndex, + }); + } + } + + return intersections.filter( + (intersection) => + intersection.timestamp >= domainStart && + intersection.timestamp <= domainEnd, + ); + }); +}; + export const getLineChartStep = (dateRange: ILineChartDateRange): number => { const [startDate, endDate] = dateRange; const range = endDate.valueOf() - startDate.valueOf(); @@ -377,6 +632,7 @@ export const UNIT_FORMATTER = { export const getLineChartYDataMax = ( dataPoints: ILineChartDataPoint[], type: ILineChartGraphType, + extraValues: number[] = [], ) => { const values = dataPoints.map((p) => { if (_.isNumber(p?.v)) { @@ -402,7 +658,11 @@ export const getLineChartYDataMax = ( return max; } }); - return Math.max(...values, 0); + return Math.max( + ...values, + 0, + ...extraValues.filter((value) => Number.isFinite(value)), + ); }; export const getYAxisUpperBound = (max: number, type: ILineChartMetricUnit) => { @@ -442,8 +702,9 @@ export const getYAxisDomain = ( dataPoints: ILineChartDataPoint[], graphType: ILineChartGraphType, unitType: ILineChartMetricUnit, + extraValues: number[] = [], ): [number, number] => { - const max = getLineChartYDataMax(dataPoints, graphType); + const max = getLineChartYDataMax(dataPoints, graphType, extraValues); if (!max) { if (unitType === ILineChartMetricUnit.Ratio) { return [0, 1]; diff --git a/packages/eagle/stories/docs/core/LineChart.stories.tsx b/packages/eagle/stories/docs/core/LineChart.stories.tsx index 49023b9e1..4693fab1d 100644 --- a/packages/eagle/stories/docs/core/LineChart.stories.tsx +++ b/packages/eagle/stories/docs/core/LineChart.stories.tsx @@ -328,6 +328,144 @@ const menu: Antd5DropdownProps["menu"] = { }, }; +export const ThresholdHighlight = Template.bind({}); +export const AreaSegmentHighlight = Template.bind({}); + +const thresholdHighlightMetric: ILineChartMetric = { + sample_streams: [ + { + points: [ + { + t: dateRange1[0].valueOf(), + v: 220 * 1024 ** 4, + }, + { + t: dateRange1[0].add(20, "minute").valueOf(), + v: 260 * 1024 ** 4, + }, + { + t: dateRange1[0].add(40, "minute").valueOf(), + v: 275 * 1024 ** 4, + }, + { + t: dateRange1[0].add(60, "minute").valueOf(), + v: 248 * 1024 ** 4, + }, + { + t: dateRange1[0].add(80, "minute").valueOf(), + v: 286 * 1024 ** 4, + }, + { + t: dateRange1[1].valueOf(), + v: 268 * 1024 ** 4, + }, + ], + step: 20 * 60 * 1000, + tolerance: 1700000, + legend: { + id: "forecast_usage", + name: "Forecast Usage", + color: "#8f63ff", + fill: "rgba(143, 99, 255, 0.14)", + }, + }, + ], + unit: ILineChartMetricUnit.DataSize, + dropped: false, +}; + +ThresholdHighlight.args = { + chartProps: { + syncId: "threshold-highlight", + mode: "legend", + showLegend: true, + metricName: "Threshold Highlight Demo", + metric: thresholdHighlightMetric, + height: 180, + type: ILineChartGraphType.Area, + dateRange: dateRange1, + showXAxis: true, + xAxisProps: { + domain: domain1, + }, + backgroundRanges: [ + { + start: dateRange1[0].valueOf(), + end: dateRange1[0].add(28, "minute").valueOf(), + fill: "#ff7875", + fillOpacity: 0.14, + }, + { + start: dateRange1[0].add(52, "minute").valueOf(), + end: dateRange1[0].add(72, "minute").valueOf(), + fill: "#ffd666", + fillOpacity: 0.18, + }, + ], + thresholdLineProps: { + value: 250 * 1024 ** 4, + stroke: "#ff4d4f", + strokeDasharray: "4 4", + intersectionLabelProps: { + text: "24 天", + color: "#ff4d4f", + }, + }, + tooltipProps: { + format: (val) => + lineChartYaxisTickFormatter(val.value, ILineChartMetricUnit.DataSize), + }, + }, +}; + +ThresholdHighlight.parameters = { + docs: { + description: { + story: + "灞曠ず澶氭鏃堕棿鑼冨洿鑳屾櫙楂樹寒銆佸崟鏉¢槇鍊艰櫄绾垮拰浜ょ偣 tooltip 鐨勭粍鍚堢ず渚嬨€?", + }, + }, +}; + +AreaSegmentHighlight.args = { + chartProps: { + syncId: "area-segment-highlight", + mode: "legend", + showLegend: true, + metricName: "Area Segment Highlight Demo", + metric: thresholdHighlightMetric, + height: 180, + type: ILineChartGraphType.Area, + dateRange: dateRange1, + showXAxis: true, + xAxisProps: { + domain: domain1, + }, + areaHighlightRanges: [ + { + start: dateRange1[0].add(12, "minute").valueOf(), + end: dateRange1[0].add(48, "minute").valueOf(), + fill: "#ff7875", + fillOpacity: 0.2, + legendId: "forecast_usage", + }, + ], + tooltipProps: { + format: (val) => + lineChartYaxisTickFormatter(val.value, ILineChartMetricUnit.DataSize), + }, + }, +}; + +AreaSegmentHighlight.parameters = { + docs: { + description: { + story: + "灞曠ず鍦ㄦ寚瀹氭椂闂存鍐咃紝鍙~鍏呭埌鏇茬嚎 value 浣嶇疆鐨勫眬閮ㄩ珮浜ず渚嬨€?", + }, + }, +}; + Primary.args = { chartProps: { syncId: "abc", @@ -504,8 +642,6 @@ PrimaryWithXAxisLg.args = { export const Secondary = Template.bind({}); // More on args: https://storybook.js.org/docs/react/writing-stories/args -const domain2 = getLineChartXAxisDomain(dateRange2, dateRange2[1].valueOf()); - Secondary.args = { chartProps: { mode: "legend", diff --git a/packages/icons/.gitignore b/packages/icons/.gitignore index e399f9ae2..90c18417c 100644 --- a/packages/icons/.gitignore +++ b/packages/icons/.gitignore @@ -4,3 +4,4 @@ /24 /32 /optimized +AGENTS.md