diff --git a/.eslintrc.json b/.eslintrc.json index 64b78a6..99f6abe 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,9 @@ { "extends": "@1stg/eslint-config/loose", "rules": { - "regexp/strict": "off" + "regexp/strict": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-argument": "off" }, "overrides": [ { diff --git a/.gitignore b/.gitignore index 4aa2738..73e784e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ lib .type-coverage .vercel .*cache -*.tsbuildinfo +*.tsbuildinfo \ No newline at end of file diff --git a/.storybook/main.ts b/.storybook/main.ts index 1d70539..f9f90f4 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -12,6 +12,7 @@ export const addons = [ '@storybook/addon-links', '@storybook/addon-essentials', '@storybook/preset-scss', + 'storybook-dark-mode' ]; export const core = { diff --git a/.stylelintignore b/.stylelintignore index 907c1ff..92d7d41 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -5,4 +5,4 @@ LICENSE *.json *.sh *.tsbuildinfo -*.lock +*.lock \ No newline at end of file diff --git a/.stylelintrc b/.stylelintrc index bad9c84..82205fc 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -1,13 +1,6 @@ { "extends": "@1stg/stylelint-config/loose", "rules": { - "scss/function-no-unknown": [ - true, - { - "ignoreFunctions": [ - "use-rgb" - ] - } - ] + "rule-empty-line-before": null } } diff --git a/docs/alauda-chart.svg b/docs/alauda-chart.svg new file mode 100644 index 0000000..b2ae2bd --- /dev/null +++ b/docs/alauda-chart.svg @@ -0,0 +1 @@ +1.2.3.微信用户5926301.2.3.微信用户5926301.2.微信用户592630微信用户592630微信用户592630 \ No newline at end of file diff --git a/docs/chart.md b/docs/chart.md new file mode 100644 index 0000000..d85bb86 --- /dev/null +++ b/docs/chart.md @@ -0,0 +1,148 @@ +# Chart + +> 用于提供创建 svg、自适应图表大小等配置, 继承于 View,有着 View 的 api + +## 职责 + +- 设置主题 +- 初始化 interaction +- 图表宽高自适应事件绑定 +- destroy + +持续更新... +![alauda-chart](./alauda-chart.svg) + +## 结构 + +``` +chart +├─ README.md +├─ docs // 文档 +├─ package.json +├─ src +│ ├─ chart +│ │ ├─ event-emitter.ts +│ │ ├─ index.ts // 图表 chart view 容器 +│ │ └─ view.ts // 视图 view +│ ├─ components // 图表相关组件 +│ │ ├─ annotation.ts // 标注 +│ │ ├─ axis.ts // 坐标轴 +│ │ ├─ base.ts // 组件抽象类 +│ │ ├─ coordinate.ts // 坐标系 +│ │ ├─ header.ts +│ │ ├─ index.ts // 注册组件相关 +│ │ ├─ legend.ts // 图例 +│ │ ├─ scale.ts // 比例尺 度量 +│ │ ├─ shape // 图形 +│ │ │ ├─ area.ts // 面积 +│ │ │ ├─ bar.ts // 柱状图 +│ │ │ ├─ gauge.ts // 计量图 +│ │ │ ├─ index.ts // 图形注册等相关 +│ │ │ ├─ line.ts // 线图 +│ │ │ ├─ pie.ts // 饼图 +│ │ │ └─ point.ts // 点图 +│ │ ├─ styles.ts // 组件公共样式 +│ │ ├─ title.ts // 标题 +│ │ └─ tooltip.ts // 工具提示 +│ ├─ index.ts // 图表base 注册组件、图形、等 +│ ├─ interaction // 交互 +│ │ ├─ action // 交互动作 +│ │ │ ├─ action.ts // action 基类 +│ │ │ ├─ brush-x.ts // x 框选 +│ │ │ ├─ element.ts // 元素激活 +│ │ │ ├─ index.ts // 注册 action 相关 +│ │ │ ├─ legend.ts // 图例切换 +│ │ │ └─ tooltip.ts // 图例展示隐藏 +│ │ ├─ index.ts // 注册交互动作相关 +│ │ └─ interaction.ts // 交互 +│ ├─ reactivity // 响应式 option +│ ├─ strategy // uplot internal策略 +│ │ ├─ abstract.ts // 抽象类 +│ │ ├─ config.ts // 默认配置文件 +│ │ ├─ index.ts +│ │ ├─ internal-strategy.ts // 内置策略 d3 渲染图表 +│ │ ├─ manage.ts // 策略管理 +│ │ ├─ quadtree.ts // uplot 使用 Quadtree组件 +│ │ ├─ uplot-strategy.ts // uplot 策略 uplot 渲染图表 +│ │ └─ utils.ts // 工具函数 +│ ├─ theme // 主题 +│ │ ├─ dark.ts // 深色主题 +│ │ ├─ index.ts +│ │ └─ light.ts // 浅色主题 +│ ├─ types // 类型文件 +│ └─ utils // 工具函数 +├─ stories // demo +├─ tsconfig.json +└─ yarn.lock +``` + +## Api + +> 命令式:同时支持 options 配置 + +```ts +const chart = new Chart({ + container: 'chart', + data: [], + options: Options, // 所有配置集合 +}); + +// 设置图标宽高 根据容器大小动态更新 +chart.changeSize({ width, height }); + +// 命令式 +chart.data([]); +chart.title(option); +chart.legend(option); +``` + +## Option + +```ts +interface ChartOption { + // 绘制的 DOM 可以是 DOM select 也可以是 DOM 实例 + container: string | HTMLElement; + // 图表宽高度 不设置默认根据父容器高度自适应 + width?: number; + height?: number; + // 图表内边距 上 右 下 左 不包含 header + padding?: Padding; // [16,0,0,0] + // 默认交互 ['tooltip', 'legend-filter', 'legend-active'] + defaultInteractions?: string[]; + // 图表组件等相关的配置。同时支持配置式 和 声明式 + options: Options; + /** 主题 */ + theme?: Theme; // 默认根据系统 +} + +// 具体图表设置 +interface Options { + // 只读所有配置反应在次类型上 + readonly padding?: Padding; + readonly data?: Data; + + title?: TitleOption; // 图表标题 + legend?: LegendOption; // 图例 + tooltip?: TooltipOption; // 工具提示 + + scale?: { + // 度量|比例尺 将数据映射像素上 + x?: ScaleOption; + y?: ScaleOption; + }; + axis?: { + // x y 坐标轴 + x?: AxisOption; + y?: AxisOption; + }; + coordinate?: CoordinateOption; // 坐标系 (将不同类型图表统一配置) + annotation?: AnnotationOption; // 标注 xLine yLine + // shape 相关 + line?: LineShapeOption; // 线 + area?: AreaShapeOption; // 面积图 + bar?: BarShapeOption; // 柱状图 + point?: PointShapeOption; // 点图 + pie?: PieShapeOption; // 饼图 + gauge?: GaugeShapeOption; // 计量图 +} +``` diff --git a/docs/components/annotation.md b/docs/components/annotation.md new file mode 100644 index 0000000..1e4b092 --- /dev/null +++ b/docs/components/annotation.md @@ -0,0 +1,46 @@ +# Annotation + +> 标注 在图表上标识额外的标记注解, 暂时只支持 lineX lineY + +## Api + +```ts +chart.annotation().lineX(options); // 命令式 +chart.annotation().lineY(options); + +new Chart({ annotation: { lineX: options, lineY: options } }); // 配置式 +``` + +## Option + +```ts +export interface AnnotationOption { + lineX?: AnnotationLineOption; + lineY?: AnnotationLineOption; +} + +export interface AnnotationLineOption { + data: string | number; // x y 坐标数据 + text?: { + // 不设置则默认展示 data + position?: 'left' | 'right' | string; // 文本位置 + content: unknown; // 文本 + style?: object; // 样式 + border?: { + // 文本边框样式 + style?: string; + padding?: [number, number]; + }; + }; + style?: { + // 样式 + stroke?: string; + width?: number; + lineDash?: [number, number]; + }; +} +``` + +## Todo + +- [ ] 支持用户自定义 annotation diff --git a/docs/components/axis.md b/docs/components/axis.md new file mode 100644 index 0000000..df15d67 --- /dev/null +++ b/docs/components/axis.md @@ -0,0 +1,20 @@ +# Axis + +> 坐标轴 + +## Api + +```ts +chart.axis('x' | 'y', option); // 命令式 + +new Chart({ axis: { x: options, y: options } }); // 配置式 +``` + +## Option + +```ts +export interface AxisOpt { + autoSize?: boolean; // 默认 true y 坐标轴生效,根据 label 长度自动偏移图表 + formatter?: string | ((value: string | number) => string); // 格式化 +} +``` diff --git a/docs/components/coordinate.md b/docs/components/coordinate.md new file mode 100644 index 0000000..a193973 --- /dev/null +++ b/docs/components/coordinate.md @@ -0,0 +1,23 @@ +# Coordinate + +> 坐标系 + +## Api + +```ts +chart.coordinate(option); // 命令式 + +new Chart({ coordinate: options }); // 配置式 +``` + +## Option + +```ts +export interface CoordinateOpt { + transposed?: boolean; // x y 轴置换 +} +``` + +## Todo + +- [ ] 支持多种坐标系类型 例如 极坐标系(pie) polar 极坐标系的配置例如 startAngel endAngel 等 diff --git a/docs/components/legend.md b/docs/components/legend.md new file mode 100644 index 0000000..a997d0c --- /dev/null +++ b/docs/components/legend.md @@ -0,0 +1,32 @@ +# Legend + +## Api + +```ts +chart.legend(boolean | options); // 命令式 + +new Chart({ legend: options }); // 配置式 +``` + +## Option + +```ts +type LegendPosition = + | 'top' + | 'top-left' + | 'top-right' + | 'bottom' + | 'bottom-left' + | 'bottom-right'; + +interface LegendOption { + custom?: boolean; // 预留自定义dom 用于业务覆盖 + position?: LegendPosition; // 图例位置 +} +``` + +## Todo + +- [ ] 自定义图例 custom 通过 fn 方式暴露,不支持让业务操作 dom set + +- [ ] legend icon 支持根据不同图表类型展示不同样式 diff --git a/docs/components/scale.md b/docs/components/scale.md new file mode 100644 index 0000000..fdce281 --- /dev/null +++ b/docs/components/scale.md @@ -0,0 +1,25 @@ +# Scale + +> 度量 比例尺 用于定义域范围类型等 + +## Api + +```ts +chart.scale('x' | 'y', option); // 命令式 + +new Chart({ scale: { x: options, y: options } }); // 配置式 +``` + +## Option + +```ts +export interface ScaleOption { + time?: boolean; // true + min?: number; // 最小值 + max?: number; // 最大值 +} +``` + +## Todo + +- [ ] tickCount 指定 tick 个数 diff --git a/docs/components/shape.md b/docs/components/shape.md new file mode 100644 index 0000000..e547de1 --- /dev/null +++ b/docs/components/shape.md @@ -0,0 +1,195 @@ +# Shape + +> 图形 + +## Option + +```ts +export interface ShapeOption { + name?: string; // 指定 映射关系 为 data name + connectNulls?: boolean; // 是否链接空值 默认 false + points?: Omit | boolean; // 默认 false + width?: number; // 线宽 默认 1.5 + alpha?: number; // 透明度 + map?: string; // 指定 映射关系 为 data name +} + +// map 指定 映射关系 为 data name +// 画出一条线 和 面积 +chart.line().map('area1'); +chart.area().map('area2'); +``` + +## Line + +> 用于绘制折线图、曲线图、阶梯线图等 + +### Api + +```ts +chart.line(option); // 命令式 + +new Chart({ line: option }); // 配置式 +``` + +### Option + +```ts +export interface LineShapeOption extends ShapeOption { + step?: 'start' | 'end'; // 折线图 +} +``` + +## Area + +> 用于绘制面积图 + +### Api + +```ts +chart.area(option); // 命令式 + +new Chart({ area: option }); // 配置式 +``` + +### Option + +```ts +export interface AreaShapeOption extends ShapeOption {} +``` + +### Todo + +- [ ] 支持自定义渐变范围 + +## Bar + +> 用于绘制柱状图 + +### Api + +```ts +chart.bar(option); // 命令式 + +new Chart({ bar: option }); // 配置式 +``` + +### Option + +```ts +export type AdjustType = 'stack' | 'group'; + +export interface AdjustOption { + type?: AdjustType; // 默认 group 支持堆叠及分组 + marginRatio?: number; // type group 下有效 0-1 范围 默认 0.1 +} + +export interface BarShapeOption extends ShapeOption { + adjust?: AdjustOption; +} +``` + +## Point + +> 用于绘制散点图 + +### Api + +```ts +chart.point(option); // 命令式 + +new Chart({ point: option }); // 配置式 +``` + +### Option + +```ts +export type SizeCallback = (...args: unknown[]) => number; + +export interface PointShapeOption extends ShapeOption { + pointSize?: number; // 点大小 默认 5 + sizeField?: string; // 设置 size 映射key 默认 ‘size’ + sizeCallback?: SizeCallback; // 设置size 回调 支持用户自定义大小 返回点大小 +} +``` + +## Pie + +> 用于绘制饼图 + +### Api + +```ts +chart.pie(option); // 命令式 + +new Chart({ pie: option }); // 配置式 +``` + +### Option + +```ts +export interface PieShapeOption { + innerRadius?: number; // 内半径 0 - 1 + outerRadius?: number; // 外半径 + startAngle?: number; // 开始角度 + endAngle?: number; // 结束角度 + label?: { + // pie 中间文本 + text?: string; // 文本 + position?: { + x?: number; + y?: number; + }; + }; + total?: number; // 指定总量 + backgroundColor?: string; // 背景颜色 + itemStyle?: { + borderRadius?: number; // item 圆角 + borderWidth?: number; // item间 隔宽度 + }; + innerDisc?: boolean; //内阴影盘 +} +``` + +### Todo + +- [ ] 将 angle radius 等配置归纳到 coordinate 下 + +## Gauge + +> 用于绘制计量图 + +### Api + +```ts +chart.gauge(option); // 命令式 + +new Chart({ gauge: option }); // 配置式 +``` + +### Option + +```ts +export interface GaugeShapeOption { + innerRadius?: number; // 内半径 0 - 1 + outerRadius?: number; // 外半径 + colors: Array<[number, string]>; // 指定颜色 [百分比, color] + label?: { + text?: string; + position?: { + x?: number; + y?: number; + }; + }; + text?: { + // 计量文本 + show?: boolean; // 默认展示, + size?: number; // 字体大小 默认12 + color?: string | ((value: number) => string); // 文本颜色支持 fn 动态颜色默认 n-4 + }; +} +``` + +### Todo + +- [ ] 本身是属于 pie 的变种,只是默认了 start end angle 应该继承于 pie 实现 diff --git a/docs/components/title.md b/docs/components/title.md new file mode 100644 index 0000000..8c0fc8c --- /dev/null +++ b/docs/components/title.md @@ -0,0 +1,25 @@ +# Title + +## Api + +```ts +chart.title(boolean | options); // 命令式 + +new Chart({ title: options }); // 配置式 +``` + +## Api + +```ts +export interface TitleOption { + text?: string; // 标题文本 + custom?: boolean; // 预留自定义dom 用于业务覆盖 + formatter?: string | ((text: string) => string); // 格式化 +} +``` + +## Todo + +- [ ] 自定义图例 custom 通过 fn 方式暴露,不支持让业务操作 dom set + +- [ ] 支持样式调整,position color 等 diff --git a/docs/components/tooltip.md b/docs/components/tooltip.md new file mode 100644 index 0000000..6ffb08f --- /dev/null +++ b/docs/components/tooltip.md @@ -0,0 +1,29 @@ +# Tooltip + +> 工具提示 + +## Api + +```ts +chart.tooltip(boolean | options); // 命令式 + +new Chart({ tooltip: options }); // 配置式 +``` + +## Option + +```ts +export interface TooltipOpt { + showTitle?: boolean; // 展示标题 + popupContainer?: HTMLElement; // tooltip 渲染父节点 默认 body + titleFormatter?: string | ((title: string, values: TooltipValue[]) => string); // 标题格式化 + nameFormatter?: string | ((name: string) => string); // item name (图例)格式化 + valueFormatter?: string | ((value: TooltipValue) => string); // item value 格式化 + itemFormatter?: (value: TooltipValue[]) => string | TooltipValue[] | Element; // item 格式化 + sort?: (a: TooltipValue, b: TooltipValue) => number; // 排序规则 +} +``` + +## Todo + +- [ ] item icon 支持根据类型不同展示不同 icon 和 legend 对应 diff --git a/docs/interaction.md b/docs/interaction.md new file mode 100644 index 0000000..23ec135 --- /dev/null +++ b/docs/interaction.md @@ -0,0 +1,62 @@ +# interaction + +> 图表相关的交互都属于 interaction, 例如 tooltip hover legend active 等 +> 默认 生效 tooltip, legend-active + +## 内置 interaction + +- tooltip: 鼠标 hover chart 展示提示信息 +- legend-active: 鼠标点击图例激活图例项激活 +- brush-x: 鼠标框选 x 轴 +- element-active:鼠标移入元素 激活 例如:pie point 移入后有激活效果 + +## Api + +```ts +chart.interaction('xxx', steps); // 命令式 +``` + +## Option + +```ts +export interface InteractionSteps { + start?: InteractionStep[]; // 交互开始 + + end?: InteractionStep[]; // 交互结束 +} + +export interface InteractionStep { + trigger: string | TriggerType; // 触发事件,支持 view,chart 的各种事件 + + action: string | ActionType; // action 名称 (组件:动作) + + callback?: (context: unknown) => void; // 回调函数,action 执行后执行 +} +``` + +## 配置 + +> 交互由 trigger 和 action 组合而成 + +```ts +// 设置 x 轴框选交互 +chart.interaction('brush-x', { + start: [ + { + trigger: ChartEvent.PLOT_MOUSEDOWN, // plot 鼠标按下 触发 + action: ActionType.BRUSH_X_START // 执行 brush x start 动作 + }, + ], + end: [ + { + trigger: ChartEvent.PLOT_MOUSEUP, // plot 鼠标抬起 触发 + action: ActionType.BRUSH_X_END // 执行 brush x end 动作 + callback: e => {}, // 触发end 后执行回调 + }, + ], +}) +``` + +### Todo + +- [ ] 增加更多内置交互,legend-highlight element-selected 等 diff --git a/docs/reactive.md b/docs/reactive.md new file mode 100644 index 0000000..ba7ee49 --- /dev/null +++ b/docs/reactive.md @@ -0,0 +1,17 @@ +# reactive + +> 为了支持配置式 options 更新,图表动态响应更新 +> reactive 由 on-change 库完成 + +## Api + +```ts +const reactive = chart.reactive(); +btn.onclick = () => { + reactive.options.tooltip = false; // 异步更新图表设置, 图表响应更新 +}; +``` + +## Todo + +- [ ] 需要使用 reactive 后的变量,更新变量触发,此方式比较麻烦,期望是更新用户的 options 变量就能触发 diff --git a/docs/theme.md b/docs/theme.md new file mode 100644 index 0000000..27737fd --- /dev/null +++ b/docs/theme.md @@ -0,0 +1,24 @@ +# Theme + +> - 提供 `light dark` 主题,默认跟随系统 +> - 支持全局注册及覆盖样式 +> - chart 内支持覆盖 +> - 默认跟随系统主题 + +## API + +```ts +// 全局注册主题 +registerTheme('custom',{ + //... +}) +// 设置主题 +chart.theme('custom') + +// 全局覆盖部分配置 +registerTheme('dark',{ + //... +}) +// 覆盖当前 chart 主题配置 +chart.theme('dark', {//...}) +``` diff --git a/docs/view.md b/docs/view.md new file mode 100644 index 0000000..7547771 --- /dev/null +++ b/docs/view.md @@ -0,0 +1,72 @@ +# View + +> 控制单元, 容器,组装 数据 策略、component 的一个容器 +> 布局、初始化 组件、options、事件、主题 + +## 职责 + +- 初始化 组件、options、事件、主题、策略 + +- 暴露 api + +- 管理组件,组件通讯 + +## Api + +> 提供 命令式 api + +```ts +// 绑定数据 +view.data([]); + +// 设置主题 内置 light dark 可自定义 +view.theme(theme); + +// 获取主题配置 +view.getTheme(); + +// 获取所有配置 +view.getOption(); + +// 设置组件配置 详细查看各组件配置文档 +view.title(option); // 标题 +view.legend(option); // 图例 +view.axis('x', option); // 坐标系 +view.scale('x', options); // 度量 +view.shape('line', option); // 设置图形 +view.annotation(option); // 标注 +view.coordinate(option); // 坐标系 +view.tooltip(options); // 工具提示 +view.theme(theme); // 设置主题 +view.redraw(); // 重绘 +view.destroy(); // 销毁图表 +``` + +## Option + +> view 参数配置 + +```ts +export interface ViewOption { + // chart option 类似 + readonly ele: HTMLElement; // 容器元素 + width?: number; + height?: number; + padding?: number[]; + data?: Data; // 数据 + options?: Options; // 配置 +} + +export interface Options { + title?: TitleOption; + // ... component +} +``` + +## View Strategy + +> 视图策略,根据不同策略 组件初始化、option 转换 ,渲染等 +> chart 内部由两种策略模式, +> uPlot:由第三方库 uPlot 渲染,支持有坐标轴的图表 line area point bar 等 +> internal: 内部实现图表,例如 pie gauge +> 支持增加新策略,例如使用第三方画其他类型图表 diff --git a/package.json b/package.json index 5cb3a14..6ed647b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@alauda/chart", - "version": "0.1.11", + "name": "zz-chart", + "version": "0.1.1-beta.51", "type": "module", "description": "Alauda Chart components by Alauda Frontend Team", "repository": "git+https://github.com/alauda/chart.git", @@ -23,8 +23,7 @@ "fesm2015": "./lib/index.es2015.mjs", "import": "./lib/index.js", "require": "./lib.index.cjs" - }, - "./default.css": "./lib/default.css" + } }, "fesm2020": "lib/index.es2020.mjs", "fesm2015": "lib/index.es2015.mjs", @@ -41,7 +40,6 @@ "scripts": { "build": "run-p build:*", "build:r": "r -f cjs,es2015,es2020", - "build:sass": "sass src/theme/default.scss ./lib/default.css", "build:tsc": "tsc -p src", "clean": "rimraf .type-coverage dist lib '.*cache' '*.tsbuildinfo'", "dev": "start-storybook", @@ -58,13 +56,21 @@ }, "peerDependencies": { "d3": "^7.1.1", - "lodash": "^4.17.21" + "lodash-es": "^4.17.21" }, "peerDependenciesMeta": { - "lodash": { + "lodash-es": { "optional": true } }, + "dependencies": { + "@types/tinycolor2": "^1.4.6", + "aphrodite": "^2.4.0", + "on-change": "^4.0.2", + "placement.js": "^1.0.0-beta.5", + "tinycolor2": "^1.6.0", + "uplot": "^1.6.30" + }, "devDependencies": { "@1stg/app-config": "^6.1.4", "@1stg/lib-config": "^9.0.1", @@ -80,12 +86,14 @@ "@storybook/preset-scss": "^1.0.3", "@types/d3": "^7.4.0", "@types/lodash": "^4.14.182", + "@types/lodash-es": "^4.17.12", "@types/web": "^0.0.69", "d3": "^7.6.1", - "lodash": "^4.17.21", + "lodash-es": "^4.17.21", "resolve-typescript-plugin": "^1.2.0", "sass": "^1.53.0", "sass-loader": "^13.0.2", + "storybook-dark-mode": "^2.0.5", "tsconfig-paths-webpack-plugin": "^3.5.2", "type-coverage": "^2.22.0", "typed-query-selector": "^2.6.1", @@ -98,7 +106,7 @@ "webpack": "^5.73.0" }, "typeCoverage": { - "atLeast": 99.83, + "atLeast": 100, "cache": true, "detail": true, "ignoreAsAssertion": true, diff --git a/src/abstract/controller.ts b/src/abstract/controller.ts deleted file mode 100644 index ef52a5d..0000000 --- a/src/abstract/controller.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { View } from '../chart/index.js'; - -import { ServiceController } from './service-controller.js'; -import { UIController } from './ui-controller.js'; - -export type ControllerCtor = new (view: View) => - | UIController - | ServiceController; - -export abstract class Controller { - constructor(public owner: View) {} - - protected option!: T; - - abstract get name(): string; - - abstract init(): void; - - abstract destroy?(): void; -} diff --git a/src/abstract/index.ts b/src/abstract/index.ts deleted file mode 100644 index dc25fe0..0000000 --- a/src/abstract/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './controller.js'; -export * from './service-controller.js'; -export * from './ui-controller.js'; diff --git a/src/abstract/service-controller.ts b/src/abstract/service-controller.ts deleted file mode 100644 index 6f69113..0000000 --- a/src/abstract/service-controller.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Controller } from './controller.js'; - -export abstract class ServiceController extends Controller {} diff --git a/src/abstract/ui-controller.ts b/src/abstract/ui-controller.ts deleted file mode 100644 index 0678bf8..0000000 --- a/src/abstract/ui-controller.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Controller } from './controller.js'; - -export abstract class UIController extends Controller { - abstract render(): void; -} diff --git a/src/event-emitter.ts b/src/chart/event-emitter.ts similarity index 98% rename from src/event-emitter.ts rename to src/chart/event-emitter.ts index e40f036..212e8d7 100644 --- a/src/event-emitter.ts +++ b/src/chart/event-emitter.ts @@ -17,6 +17,7 @@ export default class EventEmitter { * @param callback * @param once */ + // type-coverage:ignore-next-line on(evt: string, callback: (args: any) => void, once?: boolean) { if (!this._events[evt]) { this._events[evt] = []; diff --git a/src/chart/index.ts b/src/chart/index.ts index 5025c3d..bdf9204 100644 --- a/src/chart/index.ts +++ b/src/chart/index.ts @@ -1,78 +1,115 @@ -import { select as d3Select } from 'd3'; - -import { D3EelSelection, D3Selection, Options } from '../types/index.js'; -import { getChartSize, getElement, resizeOn } from '../utils/index.js'; +import { ChartOption, Size } from '../types/index.js'; +import { DEFAULT_INTERACTIONS } from '../utils/constant.js'; +// import { DEFAULT_INTERACTIONS } from '../utils/constant.js'; +import { + generateName, + getChartSize, + getElement, + resizeObserver, +} from '../utils/index.js'; import { View } from './view.js'; -export * from './view.js'; - -function createSvg(el: D3Selection) { - return el - .append('svg') - .style('width', '100%') - .style('height', '100%') - .style('overflow', 'hidden') - .style('display', 'inline-block'); -} - -function parseConstructorOption(options: Options) { - const { container, width, height, customHeader } = options; - const ele = getElement(container); - const d3El = d3Select(ele); - let header: D3EelSelection; - d3El.attr('position', 'relative'); +export class Chart extends View { + chartEle: HTMLElement; + ele: HTMLElement; + width: number; + height: number; - if (customHeader) { - header = d3El.append('div'); - header.attr('class', 'ac-header'); - } - const containerDom = d3El - .append('div') - .attr('class', 'ac-container') - .style('width', '100%') - .style('height', '100%'); - const svg = createSvg(containerDom); - return { - ele: d3El as unknown as D3EelSelection, - container: containerDom, - header, - svg, - size: getChartSize(ele, width, height), - options, - }; -} + private sizeObserver: ResizeObserver; -export class Chart extends View { - ele: Element | HTMLElement; - container: Element | HTMLElement; + constructor(props: ChartOption) { + const { + container, + width, + autoFit = true, + height, + padding, + defaultInteractions = DEFAULT_INTERACTIONS, + options, + data, + } = props; + const chartEle: HTMLElement = getElement(container); + const header = document.createElement('div'); + header.className = generateName('header'); + chartEle.append(header); + const ele = document.createElement('div'); + chartEle.append(ele); - private resizeOn: () => void; + if (autoFit) { + chartEle.style.width = '100%'; + chartEle.style.height = '100%'; + chartEle.style.justifyContent = 'space-between'; + } + ele.style.position = 'relative'; + ele.style.height = '100%'; + // ele.style.display = 'flex'; + // ele.style.flexDirection = 'column'; - constructor(options: Options) { - const opt = parseConstructorOption(options); - super(opt); - this.ele = getElement(options.container); - this.container = opt.container.node(); - this.bindResize(); - } + chartEle.style.flexDirection = 'column'; + chartEle.style.display = 'flex'; + ele.style.flex = '1'; - override destroy() { - super.destroy(); - this.unbindResize(); + if (width) { + chartEle.style.width = `${width}px`; + } + if (height) { + chartEle.style.height = `${height}px`; + } + const size = getChartSize(ele, width, height); + const opts = { + chartEle, + ele, + ...size, + padding, + data, + options, + defaultInteractions, + chartOption: props, + }; + super(opts); + this.chartEle = chartEle; + this.ele = ele; + this.width = size.width; + this.height = size.height; + this.bindAutoFit(); } - private bindResize() { - this.resizeOn = resizeOn(this.container, this.onResize); + /** + * 绑定自动伸缩视图 + */ + private bindAutoFit() { + this.sizeObserver = resizeObserver(this.chartEle, this.changeSize); } - private unbindResize() { - this.resizeOn(); + private unbindAutoFit() { + this.sizeObserver.disconnect(); } - private readonly onResize = (entry: ResizeObserverEntry) => { - const { width: w, height: h } = this.options; - const { width, height } = entry.contentRect; - this.changeSize(getChartSize(this.container, w || width, h || height)); + /** + * 改变图表大小,重新渲染 (由 bbox内部处理) + * @param width + * @param height + * @returns Chart + */ + changeSize = ({ width, height }: Size) => { + if (this.width === width && this.height === height) { + return; + } + const size = getChartSize(this.ele, width, height); + this.width = size.width; + this.height = size.height; + // 重新渲染 + this.render(size); + return this; }; + + /** + * 销毁图表 + */ + override destroy() { + super.destroy(); + this.unbindAutoFit(); + this.ele = null; + } } diff --git a/src/chart/view.ts b/src/chart/view.ts index 1a42d70..47e9b43 100644 --- a/src/chart/view.ts +++ b/src/chart/view.ts @@ -1,524 +1,430 @@ -import { find, isFunction, mergeWith } from 'lodash'; - -import { - Controller, - ControllerCtor, - ServiceController, - UIController, -} from '../abstract/index.js'; -import { - Axis, - Legend, - YPlotLine, - Series, - Title, - Tooltip, - XPlotLine, - Pie, - Zoom, -} from '../components/index.js'; +import { isBoolean, isObject, merge, set, cloneDeep } from 'lodash-es'; + +import { Annotation } from '../components/annotation.js'; +import { BaseComponent } from '../components/base.js'; +import { Coordinate } from '../components/coordinate.js'; +import { Legend } from '../components/legend.js'; +import { Scale } from '../components/scale.js'; +import { PolarShape, Shape, ShapeCtor } from '../components/shape/index.js'; +import { getInteraction } from '../interaction/index.js'; +import Interaction from '../interaction/interaction.js'; +import { reactive, Reactive } from '../reactivity/index.js'; +import { ViewStrategy } from '../strategy/abstract.js'; import { - basics, - CHART_DEPENDS_MAP, - CLASS_NAME, - LEGEND_EVENTS, - VIEW_HOOKS, -} from '../constant.js'; -import EventEmitter from '../event-emitter.js'; -import { Scale, ControllerContextService } from '../service/index.js'; + UPlotViewStrategy, + ViewStrategyManager, + InternalViewStrategy, +} from '../strategy/index.js'; +import { getTheme } from '../theme/index.js'; import { - BarSeriesOption, - ChartData, - ChartEle, - ChartSize, - D3SvgSSelection, + AxisOption, + ChartEvent, + CoordinateOption, Data, + InteractionSteps, + LegendOption, Options, - PieSeriesOption, + ScaleOption, + ShapeOptions, + Size, Theme, + ThemeOptions, TitleOption, - TooltipContext, - ViewProps, - XData, - XPlotLineOptions, + TooltipOption, + ViewOption, } from '../types/index.js'; -import { - generateUID, - getChartColor, - getTextWidth, - template, -} from '../utils/index.js'; +import { ShapeType } from '../utils/component.js'; +import { getChartColor } from '../utils/index.js'; -export type ViewOptions = Omit; +import EventEmitter from './event-emitter.js'; export class View extends EventEmitter { - chartEle!: ChartEle; - - chartUId: string; - - uiControllers: UIController[] = []; - serviceControllers: ServiceController[] = []; + /** 所有的组件 */ + components: Map = new Map(); - options: Options = { - data: [], - container: '', - }; - - chartData: ChartData[] = []; + /** 图形组件 */ + shapeComponents: Map = new Map(); - chartSize: ChartSize; + // 配置信息存储 + protected options: Options = {}; - size: { - [key: string]: ChartSize; - } = {}; + readonly reactivity: Reactive; - get isBar() { - return this.options.type === 'bar'; - } + interactions: Map = new Map(); - get isGroup() { - return (this.options?.seriesOption as BarSeriesOption)?.isGroup; - } + // container + container: HTMLElement; - get isRotated() { - return this.options.rotated; - } + chartContainer: HTMLElement; - get noData() { - return ( - this.chartData.length === 0 || - this.chartData?.every(d => d?.values?.every(item => item?.y === null)) - ); - } + coordinateInstance: Coordinate; - get tooltipDisabled() { - return this.options.tooltip?.disabled; - } + /** 主题配置,存储当前主题配置。 */ + protected themeObject: ThemeOptions; - get legendDisabled() { - return !this.options.legend; - } + strategyManage: ViewStrategyManager; - get titleDisabled() { - return !this.options.title; - } + private strategy: ViewStrategy[]; - get zoomDisabled() { - return !this.options.zoom?.enabled; - } + private mediaQuery: MediaQueryList; - get yPlotLineDisabled() { - return this.options?.yPlotLine?.hide; - } + systemThemeType: 'light' | 'dark' = 'light'; - get xPlotLineDisabled() { - return this.options?.xPlotLine?.hide || !this.options.xPlotLine; - } + size: Size = { width: 0, height: 0 }; - get noHeader() { - return ( - (!this.options.title && !this.options.legend) || - (this.options?.title?.hide && this.options?.legend?.hide) - ); - } + defaultInteractions: string[]; - get headerHeight() { - const title = document.querySelector(`svg.${CLASS_NAME.title}`); - const legend = document.querySelector(`svg.${CLASS_NAME.legend}`); - const header = document.querySelector('.ac-header'); - const titleH = title?.getBBox?.()?.height || title?.clientHeight; - const legendH = legend?.getBBox?.()?.height || legend?.clientHeight; - return Math.max(header?.clientHeight || 0, titleH || 0, legendH || 0, 0); + // 判断是否是 element active [point] + get isElementAction() { + return !!this.shapeComponents.get('point'); } - get headerTotalHeight() { - return this.options.customHeader - ? this.headerHeight - : this.headerHeight + this.basics.main.top; + get hideTooltip() { + return this.options.tooltip === false; } - context = new ControllerContextService(); - - basics = basics; + fixedSize = { + width: 0, + height: 0, + }; - static setTheme(theme: Theme) { - const root = document.querySelector('html'); - root.setAttribute('ac-theme-mode', theme); - } + shapeCache = new Map>(); - constructor({ ele, size, svg, header, options }: ViewProps) { + constructor(props: ViewOption) { super(); - this.options = { - type: 'line', - offset: { x: 0, y: 0 }, - grid: { top: 0 }, - ...this.options, - ...options, - data: this.handelData(options.data), + const { + width, + height, + chartEle, + ele, + options, + data, + theme, + chartOption, + padding, + defaultInteractions, + } = props; + this.reactivity = reactive(chartOption, this); + this.chartContainer = chartEle; + this.container = ele; + if (options) { + this.options = { ...options, padding }; + } + data && this.data(data); + this.defaultInteractions = defaultInteractions; + this.size = { ...this.size, width, height }; + this.fixedSize = { + width, + height, }; - this.handleBasics(); - this.chartEle = { chart: ele, svg, header, main: this.createMain(svg) }; - this.chartUId = `ac-chart-uid-${generateUID()}`; - this.chartEle.chart.attr('class', `ac-chart-wrapper ${this.chartUId}`); - this.chartEle.chart.style('width', '100%').style('height', '100%'); - this.chartData = this.options.data; - this.changeSize(size); - this.chartEle.svg.attr('width', '100%'); - this.chartEle.svg.attr('height', size.height - this.basics.main.top); + this.initTheme(theme); this.init(); - this.options.contextCallbackFunction?.(this); - } - - setOptions(options: ViewOptions) { - // TODO: 异步修改 option 调整方法 - if (!this.options.contextCallbackFunction) { - options.contextCallbackFunction?.(this); - } - this.asyncUpdateZoomOption(options); - const newOptions = mergeWith(this.options, options) as Options; - this.options = newOptions; - } - - handelData(data: ChartData[]) { - return data.map((d, index) => ({ - ...d, - ...(d.color ? {} : { color: getChartColor(index) }), - })); } init() { - this.registerComponents(); - this.initComponentController(); - this.render(); - // 监听事件 - this.subscribeEvents(); + this.initViewStrategy(); + this.initComponent(); } - registerComponents() { - if (!this.tooltipDisabled) { - this.context.registerComponent(Tooltip); - } - if (!this.titleDisabled) { - this.context.registerComponent(Title); - } - - if (!this.yPlotLineDisabled) { - this.context.registerComponent(YPlotLine); - } - - if (!this.xPlotLineDisabled) { - this.context.registerComponent(XPlotLine); - } - - CHART_DEPENDS_MAP[this.options.type].forEach(C => - this.context.registerComponent(C), - ); + reactive() { + return this.reactivity.reactiveObject; + } - if (!this.legendDisabled) { - this.context.registerComponent(Legend); + render(size?: Size) { + if (size) { + this.size = size; } - - if (!this.zoomDisabled) { - this.context.registerComponent(Zoom); + [...this.components.values()].forEach(c => c.render()); + this.strategy.forEach(item => { + item.render(); + }); + // TODO: 去除依赖 shape 判断 is point + this.initDefaultInteractions(this.defaultInteractions); + } + + interaction(name: string, steps?: InteractionSteps) { + const interactionStep = getInteraction(name); + if ((steps || interactionStep) && !this.interactions.get(name)) { + const step = + steps && interactionStep + ? merge(interactionStep, steps) + : steps || interactionStep; + const interaction = new Interaction(this, cloneDeep(step)); + interaction.init(); + this.interactions.set(name, interaction); } } - // TODO: 先手动异步注册 zoom - asyncUpdateZoomOption(options: ViewOptions) { - if (options?.zoom?.enabled && !this.getController('zoom')) { - this.context.registerComponent(Zoom); - const instance = this.buildController(Zoom, this); - this.uiControllers.push(instance as UIController); - this.getController('zoom').init(); + private initDefaultInteractions(interactions: string[]) { + for (const name of interactions) { + if (name) { + this.interaction(name); + } } } - render() { - // 触发 component render - this.renderComponent(); - // 渲染后事件 - this.emit(VIEW_HOOKS.AFTER_RENDER); + /** + * 基于注册组件初始化 + */ + private initComponent() { + this.createCoordinate(); + this.strategyManage.getComponent().forEach(c => { + this.components.set(c.name, c); + }); } - destroy() { - // 销毁前事件 - this.emit(VIEW_HOOKS.BEFORE_DESTROY); - } - - changeSize(size: ChartSize) { - this.chartSize = size; - this.chartEle.svg - .attr('width', size.width || '100%') - .attr('height', size.height || '100%'); - this.flush(size); - } - - data(data: ChartData[]) { - const res = this.handelData(data); - this.options.data = res; - this.emit(VIEW_HOOKS.SET_DATA, this.options.data); - const legend = this.getController('legend'); - if (legend.disabledLegend.size) { - const data = this.handleLegendDisableData(); - this.changeData(data); - return; + /** + * 初始化策略 uPlot internal + */ + private initViewStrategy() { + this.strategyManage = new ViewStrategyManager(); + const internal = new InternalViewStrategy(this); + this.strategyManage.add(internal); + const uPlot = new UPlotViewStrategy(this); + this.strategyManage.add(uPlot); + this.strategy = this.strategyManage.getAllStrategy(); + } + + /** + * + * @param theme 主题 + * 不设置默认根据系统切换 light dark + */ + private initTheme(theme: Theme) { + this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + this.systemThemeType = this.mediaQuery.matches ? 'dark' : 'light'; + if (!theme || theme?.type === 'system') { + this.bindThemeListener(); } - this.changeData(res); + this.theme(theme || this.systemThemeType); } - changeData(data: ChartData[]) { - this.emit(VIEW_HOOKS.CHANGE_DATA, data); - this.chartData = data; - this.flush(); + private bindThemeListener() { + this.mediaQuery.addEventListener('change', this.systemChangeTheme); } - getTranslate() { - const { - margin: { left, top }, - } = this.basics; - return `translate(${left}, ${top})`; + private unbindThemeListener() { + this.mediaQuery.removeEventListener('change', this.systemChangeTheme); } - flush(size?: ChartSize) { - this.handleBasics(); - this.computeSize(size || this.chartSize); - this.handleMainTransform(); - // TODO: 先手动触发更新函数 - const axis = this.getController('axis'); - const legend = this.getController('legend'); - const series = this.getController('series'); - const tooltip = this.getController('tooltip'); - const yPlotLine = this.getController('yPlotLine'); - const xPlotLine = this.getController('xPlotLine'); - const pie = this.getController('pie'); + private readonly systemChangeTheme = (e: MediaQueryListEvent) => { + const theme = e.matches ? 'dark' : 'light'; + this.theme(theme); + }; - axis?.updateAxis(); - legend?.updateLegend(); - series?.updateSeries(); - tooltip?.update(); - yPlotLine?.update(); - xPlotLine?.update(); - pie?.update(); + /** + * 设置主题。 + * @param theme 主题名或者主题配置 + * @returns View + */ + theme(theme?: string | Theme): View { + this.themeObject = isObject(theme) + ? getTheme(theme.type, theme) + : getTheme(theme); + this.emit(ChartEvent.THEME_CHANGE); + return this; + } + + /** + * 获取主题配置。 + * @returns themeObject + */ + getTheme(): ThemeOptions { + return this.themeObject; + } + + /** + * 获取 view options 配置 + */ + getOption() { + return this.options; + } + + /** + * 装载数据源。 + * + * ```ts + * chart.data(); + * ``` + * + * @param data 数据源。 + * @returns View + */ + data(data: Data): View { + data.forEach((d, index) => { + if (!d.color) { + d.color = getChartColor(index); + } + }); + set(this.options, 'data', data); + this.emit(ChartEvent.DATA_CHANGE, data); + return this; + } + + getData() { + return this.options.data || []; + } + + // -------------- Component ---------------// + title(titleOption: TitleOption): View { + set(this.options, 'title', titleOption); + return this; + } + + legend(legendOption: boolean | LegendOption): Legend { + set(this.options, 'legend', legendOption); + return this.components.get('legend') as Legend; + } + + /** + * 对特定的某条坐标轴进行配置。 + * + * ```ts + * view.axis('x', false); // 不展示 'x' 坐标轴 + * // 将 'x' 字段对应的坐标轴的标题隐藏 + * view.axis('x', { + * //... + * }); + * ``` + * @param field 坐标轴 x y + * @param axisOption 坐标轴配置 + */ + axis(field: string, axisOption: AxisOption): View; + axis(field: string | boolean, axisOption?: AxisOption): View { + if (isBoolean(field)) { + set(this.options, ['axis'], field); + } else { + set(this.options, ['axis', field], axisOption); + } + return this; + } + + /** + * 对x y 度量进行配置。 + * ``` + * @param field 度量 x y + * @param scaleOption 度量配置 + */ + scale(field: string, axisOption: ScaleOption): Scale { + if (isBoolean(field)) { + set(this.options, ['scale'], field); + } else { + set(this.options, ['scale', field], axisOption); + } + return this.components.get('scale') as Scale; } - getController(name: 'scale'): Scale; - - getController(name: 'title'): Title; - - getController(name: 'legend'): Legend; - - getController(name: 'series'): Series; - - getController(name: 'axis'): Axis; - - getController(name: 'tooltip'): Tooltip; - - getController(name: 'zoom'): Zoom; - - getController(name: 'yPlotLine'): YPlotLine; - - getController(name: 'xPlotLine'): XPlotLine; - - getController(name: 'pie'): Pie; - - getController(name: string): Controller; - - getController(name: string) { - return find( - [...this.uiControllers, ...this.serviceControllers], - (c: Controller) => c.name === name, - ); + setScale(field: 'x' | 'y', limits: { min?: number; max?: number }) { + const scale = this.components.get('scale') as Scale; + scale.setScale(field, limits); } - // TODO: 组件实例挂在到 Chart - // chart.yPlotLine(options) - updateYPlotLine = (value: TooltipContext) => { - const yPlotLine = this.getController('yPlotLine'); - this.options.yPlotLine = { - ...this.options.yPlotLine, - value, - }; - yPlotLine?.update(value); - }; - - updateXPlotLine = (option: XPlotLineOptions) => { - const xPlotLine = this.getController('xPlotLine'); - this.options.xPlotLine = { - ...this.options.xPlotLine, - ...option, - }; - xPlotLine?.update(this.options.xPlotLine); - }; - - updateTitle = (option: TitleOption) => { - const title = this.getController('title'); - this.options.title = { - ...this.options.xPlotLine, - ...option, - }; - title?.update(this.options.title); - }; - - updatePie = (option: PieSeriesOption) => { - const pie = this.getController('pie'); - const newSeries = mergeWith( - this.options.seriesOption, - option, - ) as PieSeriesOption; - this.options.seriesOption = newSeries; - pie?.update(); - }; - - // 计算 - private computeSize({ width, height }: ChartSize) { - const { margin, tickLabelWidth, main } = this.basics; - const mainW = width - margin.left - tickLabelWidth; - const headerH = - (this.options.legend.hide && this.options.title.hide) || - !this.options.customHeader - ? main.top - : this.headerHeight + main.top; - this.size = { - chart: { width, height }, - main: { - width: mainW, - height: height - headerH, - }, - grid: { - width: mainW, - height: - height - margin.top - this.headerHeight - this.headerTotalHeight, - }, - }; + /** + * 创建坐标系 + * @private + */ + private createCoordinate() { + this.coordinateInstance = new Coordinate(this); } - private createMain(svg: D3SvgSSelection) { - return svg.append('g').classed('chart-main', true); + /** + * 坐标系配置。 + * + * ```ts + * // 直角坐标系,并进行转置变换 + * chart.coordinate().transpose(); + * ``` + * @returns + */ + coordinate(option?: CoordinateOption): Coordinate { + set(this.options, 'coordinate', option); + // 更新 coordinate 配置 + // this.coordinateInstance.update(option); + return this.coordinateInstance; } - private initComponentController() { - const components = this.context.getComponents(); - for (let i = 0, len = components.length; i < len; i++) { - const Ctor = components[i]; - const instance = this.buildController(Ctor, this); - if (typeof (instance as UIController).render === 'function') { - this.uiControllers.push(instance as UIController); - } else { - this.serviceControllers.push(instance); - } - } - [...this.uiControllers, ...this.serviceControllers].forEach(i => { - i.init(); - }); + getCoordinate() { + return this.coordinateInstance; } - private readonly widgets: Map< - ControllerCtor, - UIController | ServiceController - > = new Map(); + tooltip(tooltipOption: TooltipOption): View { + set(this.options, 'tooltip', tooltipOption); + return this; + } - private buildController(Ctor: ControllerCtor, ctx: View) { - if (!this.widgets.has(Ctor)) { - this.widgets.set(Ctor, new Ctor(ctx)); - } - return this.widgets.get(Ctor)!; + /** + * 辅助标记配置 + */ + annotation(): Annotation { + return this.components.get('annotation') as Annotation; } - private renderComponent() { - const components = this.uiControllers; - for (let i = 0, len = components.length; i < len; i++) { - const c = components[i]; - c.render(); - } + // 命令式设置 option + setOption(name: string | string[], option: unknown) { + set(this.options, name, option); + return this; } - private handleMainTransform() { - this.chartEle?.main?.attr('transform', this.getTranslate()); + redraw() { + this.emit(ChartEvent.HOOKS_REDRAW); } - private subscribeEvents() { - if (this.legendDisabled) { + setShape(name: string, shape: any) { + const shapeValue = this.shapeCache.get(name); + if (shapeValue) { + this.shapeCache.set(name, [...shapeValue, shape]); return; } - this.on(LEGEND_EVENTS.CLICK, () => { - const data = this.handleLegendDisableData(); - this.changeData(data); - }); + this.shapeCache.set(name, [shape]); + } - this.on(LEGEND_EVENTS.SELECT_ALL, () => { - this.changeData(this.options.data); - }); - this.on(LEGEND_EVENTS.UNSELECT_ALL, () => { - this.changeData([]); - }); + getShapeList() { + const keys = Array.from(this.shapeCache.keys()); + return keys + .map(key => { + return this.shapeCache.get(key).flat(); + }) + .flat(); } - private handleLegendDisableData() { - const legend = this.getController('legend'); - return this.options.data.reduce((prev, curr) => { - if (this.isBar) { - return [ - ...prev, - { - ...curr, - values: curr.values.reduce>>( - (acc, value) => - legend.disabledLegend.has(value.x as string) - ? acc - : [...acc, value], - [], - ), - }, - ]; - } - if (!legend.disabledLegend.has(curr.name)) { - return [...prev, curr]; - } - return prev; - }, []); - } - - private handleBasics() { - const { left, top } = basics.margin; - const { x = 0, y = 0 } = this.options.offset; - const scale = this.getController('scale'); - const domain = this.isRotated - ? (scale?.xDomain as string[]) - : scale?.yDomain; - const yLabel = this.getYLabel( - domain?.[1] || '', - this.options.yAxis?.tickFormatter, - ); - const yLabelWidth = getTextWidth(yLabel) || 10; - const data = { - ...basics, - margin: { ...basics.margin, left: left + yLabelWidth + x, top: y + top }, - main: { top: basics.main.top + (this.options?.grid?.top || 0) }, - }; - this.basics = this.noHeader - ? { - ...data, - margin: { ...data.margin, top: 0 }, - } - : data; - } - - getYLabel( - value: string | number, - tickFormatter?: - | string - | ((value?: any) => string | ((value: any) => string)), - ) { - const text = isNaN(+parseInt(value as string)) - ? value - : parseInt(value as string); - if (tickFormatter) { - if (isFunction(tickFormatter)) { - const formatter = tickFormatter(text); - return isFunction(formatter) ? formatter(text) : formatter; - } - return template(tickFormatter, { text }); - } - return text; + getShapeDataName() { + return this.getShapeList() + .map((shape: Shape) => { + return shape.getSeries(); + }) + .flat() + .map(s => s.label); } + + /** + * 生命周期:销毁,完全无法使用。 + */ + destroy() { + // ... + this.chartContainer.innerHTML = ''; + this.options = {}; + this.shapeCache.clear(); + this.reactivity.unsubscribe(); + [...this.components.values()].forEach(c => c.destroy()); + [...this.shapeComponents.values()].forEach(c => c.destroy()); + this.strategyManage.getStrategy('uPlot')?.destroy(); + this.unbindThemeListener(); + this.off(); + } +} + +/** + * 注册 geometry 组件 + * @param name + * @param Ctor + * @returns Geometry + */ +export function registerShape(name: string, Ctor: ShapeCtor) { + const key = name.toLowerCase() as ShapeType; + // 语法糖,在 view API 上增加原型方法 + View.prototype[key] = function (options?: ShapeOptions) { + const shape = new Ctor(this, options); + this.shapeComponents.set(key, shape); + return shape as any; + }; } diff --git a/src/components/annotation.ts b/src/components/annotation.ts new file mode 100644 index 0000000..e67f387 --- /dev/null +++ b/src/components/annotation.ts @@ -0,0 +1,315 @@ +import { StyleSheet, css } from 'aphrodite/no-important.js'; +import { get, merge, set, uniqBy } from 'lodash-es'; + +import { AnnotationLineOption, AnnotationOption } from '../types/index.js'; + +import { BaseComponent } from './base.js'; +import { addAreas, addLines } from '../strategy/gradient-fills.js'; + +const TEXT_SPACE = 4; + +const styles = StyleSheet.create({ + 'mark-x': { + position: 'absolute', + display: 'inline-block', + height: '100%', + }, + 'mark-x-label': { + position: 'absolute', + transform: 'translateX(-50%)', + padding: '0 8px', + fontSize: '12px', + whiteSpace: 'nowrap', + // background: '#fff', + }, +}); + +export class Annotation extends BaseComponent { + get name(): string { + return 'annotation'; + } + + annotationXFn: Array<(u: uPlot) => void> = []; + annotationYFn: Array<(u: uPlot) => void> = []; + + render() { + // .. + const opt = this.ctrl.getOption(); + this.option = get(opt, this.name, {}); + const x = get(this.option, 'lineX'); + const yList = get(this.option, 'lineY', []); + const yAreaList = get(this.option, 'areaY', []); + this.lineX(x); + yList?.forEach(y => this.lineY(y)); + this.areaY(yAreaList || []); + } + + update() { + // .. + this.option = get(this.ctrl.getOption(), this.name); + const x = get(this.option, 'lineX'); + const yList = get(this.option, 'lineY', []); + this.lineX(x); + yList?.forEach(y => this.lineY(y)); + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + lineX(options: AnnotationLineOption) { + if (this.annotationXFn.length) { + const opt = merge(get(this.option, 'lineX'), options); + set(this.option, 'lineX', opt); + this.ctrl.setOption([this.name, 'lineX'], opt); + this.ctrl.redraw(); + return this; + } + this.ctrl.setOption([this.name, 'lineX'], options); + const fn = (u: uPlot) => { + const { + data, + text, + style = { + lineDash: [5, 5], + width: 2, + stroke: this.ctrl.getTheme().colorVar['n-6'], + }, + } = get(this.option, 'lineX', {}) as AnnotationLineOption; + if (!data) { + return; + } + const ctx = u.ctx; + ctx.save(); + const xData = u.data[0]; + + const isTransposed = u.scales.y.ori === 0 && u.axes[1].side === 2; + + const posValue = + u.scales.x.distr === 2 + ? xData.indexOf(data as number) + : (data as number); + const x0 = isTransposed + ? u.valToPos(u.scales.y.min, 'y', true) + : u.valToPos(posValue, 'x', true); + const x1 = isTransposed ? u.bbox.width : x0; + const y0 = isTransposed ? u.valToPos(posValue, 'x', true) : u.bbox.top; + const y1 = isTransposed + ? u.valToPos(posValue, 'x', true) + : u.valToPos(u.scales.y.min, 'y', true); + ctx.beginPath(); + ctx.setLineDash(style?.lineDash || [5, 5]); + ctx.lineWidth = style?.width || 2; + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.strokeStyle = style?.stroke || 'red'; + ctx.stroke(); + + if (x0 && text?.content) { + const markE = u.over.querySelector('.u-mark-x'); + const markLabelE = u.over.querySelector('.u-mark-x-label'); + const markEl = (markE || document.createElement('div')) as HTMLElement; + markEl.className = `u-mark-x ${css(styles['mark-x'])}`; + + const labelEl = (markLabelE || + document.createElement('div')) as HTMLElement; + labelEl.className = `u-mark-x-label ${css(styles['mark-x-label'])}`; + if (text?.border) { + const { padding, style } = text?.border || {}; + labelEl.style.padding = padding + ? `${padding[0]}px ${padding[1]}px` + : '0 8px'; + labelEl.style.border = style; + } + labelEl.textContent = String(text.content); + Object.assign(labelEl.style, text.style || {}); + const labelStyle = getComputedStyle(labelEl); + + requestAnimationFrame(() => { + const x = isTransposed + ? u.valToPos(u.scales.y.max, 'y') + : Math.round(u.valToPos(posValue, 'x')); + const markElWidth = parseInt(labelStyle.width, 10) / 2; + + const y = isTransposed + ? u.valToPos(posValue, 'x') - parseInt(labelStyle.height, 10) / 2 + : -parseInt(labelStyle.height, 10) + TEXT_SPACE; + let left = x; + if (x + markElWidth > u.over.clientWidth) { + left = x - markElWidth; + } + if (x <= 0) { + left = x + markElWidth; + } + markEl.style.left = `${left}px`; + markEl.style.top = `${y}px`; + }); + + !markLabelE && markEl.append(labelEl); + !markE && u.over.append(markEl); + } + + ctx.restore(); + }; + this.annotationXFn.push(fn); + return this; + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + lineY(options: AnnotationLineOption, empty?: boolean) { + this.setOptions('lineY', options, empty); + if (this.annotationYFn.length) { + this.ctrl.redraw(); + return this; + } + const fn = (u: uPlot) => { + const values = get(this.option, 'lineY', []) as AnnotationLineOption[]; + if (!values.length) { + return; + } + const ctx = u.ctx; + values.forEach(item => { + const { + data, + text, + style = { lineDash: [8, 5], width: 2, stroke: 'red' }, + } = item; + if (+data < u.scales.y.min || +data > u.scales.y.max) { + return; + } + ctx.save(); + const isTransposed = u.scales.y.ori === 0 && u.axes[1].side === 2; + const [i0, i1] = u.series[0].idxs; + const d = u.data[0]; + const x0 = isTransposed + ? u.valToPos(data as number, 'y', true) + : u.bbox.left || u.valToPos(d[i0], 'x', true); // x 开始位置 + const x1 = isTransposed + ? u.valToPos(data as number, 'y', true) + : u.bbox.width + u.bbox.left || u.valToPos(d[i1], 'x', true); // x 结束为止 + const y1 = isTransposed + ? u.bbox.height + : u.valToPos(data as number, 'y', true); // y 位置 + ctx.beginPath(); + ctx.setLineDash(style?.lineDash || [8, 5]); + ctx.lineWidth = style?.width || 2; + + ctx.moveTo(x1, isTransposed ? 0 : y1); + ctx.lineTo(x0, y1); + // ctx.moveTo(x1, y1); + // ctx.lineTo(x0, y1); + ctx.strokeStyle = style?.stroke || 'red'; + ctx.stroke(); + if (text?.content) { + const { width, actualBoundingBoxDescent } = ctx.measureText( + String(text.content), + ); + ctx.fillStyle = style?.stroke || 'red'; + ctx.fillText( + String(text.content), + this.getTextPosition(text.position, x0, width, u.bbox.width), + isTransposed ? 0 : y1 - (actualBoundingBoxDescent + TEXT_SPACE), + ); + } + }); + ctx.restore(); + }; + this.annotationYFn.push(fn); + } + + areaY(options: AnnotationLineOption[], empty?: boolean) { + this.setOptions('areaY', options, empty); + const fn = (u: uPlot) => { + const ctx = u.ctx; + const { min: xMin, max: xMax } = u.scales.x; + const { min: yMin, max: yMax } = u.scales.y; + + if ( + xMin == null || + xMax == null || + yMin == null || + yMax == null || + !options?.length + ) { + return; + } + + // if (mode === ThresholdsMode.Percentage) { + // let [min, max] = getGradientRange( + // u, + // scaleKey, + // hardMin, + // hardMax, + // softMin, + // softMax, + // ); + // let range = max - min; + + // steps = steps.map(step => ({ + // ...step, + // value: min + range * (step.value / 100), + // })); + // } + + ctx.save(); + addAreas(u, 'y', options, this); + addLines(u, 'y', options, this); + // switch (config.mode) { + // case GraphTresholdsStyleMode.Line: + // case GraphTresholdsStyleMode.Dashed: + // addLines(u, scaleKey, steps, theme); + // break; + // case GraphTresholdsStyleMode.Area: + // addAreas(u, scaleKey, steps, theme); + // break; + // case GraphTresholdsStyleMode.LineAndArea: + // case GraphTresholdsStyleMode.DashedAndArea: + // addAreas(u, scaleKey, steps, theme); + // addLines(u, scaleKey, steps, theme); + // } + + ctx.restore(); + }; + this.annotationYFn.push(fn); + } + + setOptions( + type: 'lineY' | 'lineX' | 'areaX' | 'areaY', + options: AnnotationLineOption | AnnotationLineOption[], + empty?: boolean, + ) { + const value = Array.isArray(options) ? options : [options]; + if (empty) { + this.ctrl.setOption([this.name, type], value); + } + const option = get(this.ctrl.getOption(), [this.name, type]) || []; + const data = uniqBy([...option, ...value], 'data'); + this.ctrl.setOption([this.name, type], data); + } + + getTextPosition( + position: 'left' | 'right' | string = 'left', + start: number, + textWidth: number, + width: number, + ) { + let x = start; + if (position === 'left') { + x = start + textWidth + TEXT_SPACE; + } + + if (position === 'right') { + x = start + width; + } + return x; + } + + getOptions() { + return { + hooks: { + draw: [ + (u: uPlot) => { + [...this.annotationXFn, ...this.annotationYFn].forEach(fn => fn(u)); + }, + ], + }, + }; + } +} diff --git a/src/components/axis.ts b/src/components/axis.ts new file mode 100644 index 0000000..2cdc4b1 --- /dev/null +++ b/src/components/axis.ts @@ -0,0 +1,91 @@ +import { get, isFunction } from 'lodash-es'; + +import { AXES_X_VALUES } from '../strategy/config.js'; +import { AxisOpt } from '../types/index.js'; +import { template } from '../utils/index.js'; + +import { BaseComponent } from './base.js'; +import { axisAutoSize } from './uplot-lib/index.js'; + +export class Axis extends BaseComponent> { + name = 'axis'; + + render() { + // .. + const opt = this.ctrl.getOption(); + this.option = get(opt, this.name, { x: {}, y: {} }); + } + + update() { + // .. + } + + getOptions() { + return { + axes: [this.getXOptions(), this.getYOptions()], + }; + } + + private getXOptions() { + const { formatter: xFormatter, show } = this.option.x || {}; + const xValues = xFormatter + ? (_u: uPlot, splits: string[]) => + splits.map(d => { + return isFunction(xFormatter) + ? xFormatter(String(d)) + : template(xFormatter, { value: d }); + }) + : AXES_X_VALUES; + return { + show: show !== false, + values: xValues, + }; + } + + private getYOptions() { + const { autoSize, formatter: yFormatter, show } = this.option.y || {}; + const yValues = yFormatter + ? ( + u: uPlot, + splits: string[], + axisIdx: number, + tickSpace: number, + tickIncr: number, + ) => { + const params = { u, splits, axisIdx, tickSpace, tickIncr }; + return splits.map(d => { + return isFunction(yFormatter) + ? yFormatter(String(d), params) + : template(yFormatter, { value: d }); + }); + } + : null; + const ySize = autoSize === false ? {} : { size: axisAutoSize }; + return { + show: show !== false, + values: yValues, + ...ySize, + // space: function (self: uPlot, axisIdx: number): number { + // const axis = self.axes[axisIdx]; + // const scale = self.scales[axis.scale!]; + + // // for axis left & right + // if (axis.side !== 2 || !scale) { + // return 30; + // } + + // const defaultSpacing = 40; + + // return defaultSpacing; + // }, + // gap: 5, + // side: 3, + // show: true, + // ticks: { + // show: true, + // size: 3, + // width: 0.5, + // }, + }; + } +} diff --git a/src/components/axis/axis-render.ts b/src/components/axis/axis-render.ts deleted file mode 100644 index 74b7417..0000000 --- a/src/components/axis/axis-render.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { axisBottom, axisLeft, AxisScale, format } from 'd3'; -import { isFunction, isNumber } from 'lodash'; - -import { UIController } from '../../abstract/index.js'; -import { View } from '../../chart/index.js'; -import { CLASS_NAME, STROKE_DASHARRAY } from '../../constant.js'; -import { AxisOption, D3Selection } from '../../types/index.js'; -import { template } from '../../utils/index.js'; - -interface Config { - range: number[]; - orient: string; -} - -interface AxisRenderProps { - name: string; - config: Config; - g: D3Selection; - owner: View; - option: AxisOption; - isRotated: boolean; -} - -export default class AxisRender extends UIController { - config!: Config; - - g: D3Selection; - - name: string; - - isRotated: boolean; - - tickConfig!: { - tickWidth: number; - size: number; - }; - - get isXAxis() { - return this.name === 'x'; - } - - get isYAxis() { - return this.name === 'y'; - } - - constructor({ config, g, option, owner, name, isRotated }: AxisRenderProps) { - super(owner); - this.config = config; - this.g = g; - this.name = name; - this.option = option; - this.isRotated = isRotated; - } - - init() { - // .. - } - - render() { - this.updateTick(); - } - - destroy() { - // .. - } - - // eslint-disable-next-line sonarjs/cognitive-complexity - updateTick() { - this.updateConfig(); - const { orient } = this.config; - const isBottom = orient === 'bottom'; - const { x, y, isGroup, xSeriesValue, ySeriesValue } = - this.owner.getController('scale'); - const scale = isBottom ? x : y; - const d3Axis = isBottom - ? this.isRotated - ? axisLeft - : axisBottom - : this.isRotated - ? axisBottom - : axisLeft; - - let ticks = this.option.ticks; - if (!ticks) { - ticks = - this.option.minStep != null - ? Math.min( - Math.ceil(Math.max(...ySeriesValue) / this.option.minStep) || 1, - this.getMaxTicks(), - ) - : this.getMaxTicks(); - } - if (!Array.isArray(ticks)) { - ticks = [ticks]; - } - let axis = d3Axis(scale as AxisScale).ticks( - // @ts-expect-error - ...ticks, - ); - // TODO: 优化 ticks - // if (xSeriesValue.every(d => typeof d === 'number') && this.isYAxis) { - // const tickValues = getNiceTickValues(yDomain, maxTicks); - // console.log(yDomain) - // axis = axis.tickValues(tickValues); - // } - if (this.owner.isBar && !isGroup && this.isXAxis) { - axis = axis.tickValues( - xSeriesValue.filter( - (_, i) => - !( - i % Math.floor((100 * xSeriesValue.length) / this.tickConfig.size) - ), - ), - ); - } - - const { tickFormatter } = this.option; - if (tickFormatter) { - axis.tickFormat((value: number) => { - if (isFunction(tickFormatter)) { - const formatter = tickFormatter(value); - return isFunction(formatter) ? formatter(value) : formatter; - } - return template(tickFormatter, { value }); - }); - } - - if (!tickFormatter && d3Axis === axisLeft) { - axis.tickFormat((value: string) => - isNumber(value) ? format('d')(value) : value, - ); - } - this.g - .attr('class', this.isXAxis ? CLASS_NAME.xAxis : CLASS_NAME.yAxis) - .call(this.isXAxis ? axis.tickSizeOuter(0) : axis); - - if (!this.isXAxis) { - this.adjustXAxis(); - } - - if (d3Axis === axisBottom) { - this.g.call(g => { - const lastTick = g.selectAll('.tick:last-child'); - const { width } = this.owner.size.grid; - const text = lastTick.select('text'); - if (lastTick?.size()) { - const x = Number( - lastTick - ?.attr('transform') - .replace(/[^\d,.-]/g, '') - .split(',')[0], - ); - const textWidth = (text.node() as SVGTextElement)?.getBBox()?.width; - const textX = x + textWidth / 2; - const left = textX > width ? textX - width : 0; - text.attr('x', -left); - } - return g; - }); - } - } - - adjustXAxis() { - const { width, height: gridH } = this.owner.size.grid; - const { height } = this.owner.size.main; - const w = this.isRotated ? -gridH : width; - const h = this.isRotated ? width : height; - this.g.selectAll('.tick-line').remove(); - this.g - .call(g => - g - .select('.domain') - .attr( - 'd', - this.isRotated - ? `M-6,0H0V${-gridH}H0` - : `M-6,${h}H0V${this.owner.headerTotalHeight}H0`, - ), - ) - .call(g => { - const line = g.selectAll('.tick line'); - const tickLine = line.clone(); - line.attr('class', 'tick-aim'); - return tickLine - .attr(`${this.isRotated ? 'y1' : 'x2'}`, w) - .attr('class', 'tick-line') - .attr('opacity', 1) - .style('pointer-events', 'none') - .attr('stroke-dasharray', STROKE_DASHARRAY); - }); - } - - updateConfig() { - const { width, height } = this.owner.size.main; - const w = this.isRotated ? height : width; - const h = this.isRotated ? width : height; - this.tickConfig = - this.name === 'x' - ? { tickWidth: 100, size: w } - : { tickWidth: 40, size: h }; - } - - getMaxTicks(): number { - return Math.floor(this.tickConfig.size / this.tickConfig.tickWidth); - } -} diff --git a/src/components/axis/index.ts b/src/components/axis/index.ts deleted file mode 100644 index 9281663..0000000 --- a/src/components/axis/index.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { UIController } from '../../abstract/index.js'; -import { View } from '../../chart/index.js'; -import { AxisOption, D3Selection } from '../../types/index.js'; - -import AxisRender from './axis-render.js'; - -const ORIENT: Record = { - x: 'bottom', - y: 'left', -}; - -export class Axis extends UIController { - x!: D3Selection; - - y!: D3Selection; - - xAxis!: AxisRender; - - yAxis!: AxisRender; - - xOption: AxisOption; - - yOption: AxisOption; - - get name() { - return 'axis'; - } - - get isRotated() { - return this.owner.isRotated; - } - - constructor(owner: View) { - super(owner); - this.xOption = this.owner.options.xAxis || {}; - this.yOption = this.owner.options.yAxis || {}; - } - - init() { - const target: ['x', 'y'] = ['x', 'y']; - const { - chartEle: { main }, - size, - } = this.owner; - const { width, height } = size.main; - const w = this.isRotated ? height : width; - const h = this.isRotated ? width : height; - for (const v of target) { - const axisMain = main - .append('g') - .attr('class', `axis-${v}`) - .attr( - 'transform', - `translate(0, ${ - v === 'x' - ? this.isRotated - ? 0 - : Math.max(height, 0) - : this.isRotated - ? Math.max(height, 0) - : 0 - })`, - ) - .lower(); - - this[v] = axisMain; - - this[`${v}Axis`] = new AxisRender({ - owner: this.owner, - g: axisMain, - name: v, - option: this[`${v}Option`], - isRotated: !!this.isRotated, - config: { - range: v === 'x' ? [0, w] : [h, 0], - orient: ORIENT[v], - }, - }); - } - } - - render() { - this.xAxis.render(); - this.yAxis.render(); - } - - destroy() { - this.x.remove(); - this.y.remove(); - } - - updateAxis() { - const { height } = this.owner.size.main; - const maxH = Math.max(height, 0); - this.x.attr('transform', `translate(0, ${this.isRotated ? 0 : maxH})`); - this.y.attr('transform', `translate(0, ${this.isRotated ? maxH : 0})`); - this.xAxis.updateTick(); - this.yAxis.updateTick(); - } -} diff --git a/src/components/base.ts b/src/components/base.ts new file mode 100644 index 0000000..4cf4b38 --- /dev/null +++ b/src/components/base.ts @@ -0,0 +1,29 @@ +import { View } from '../chart/view.js'; + +export type ComponentCtor = new (view: View) => BaseComponent; + +export abstract class BaseComponent { + protected option: O; + + abstract get name(): string; + + abstract render(): void; + + abstract update(): void; + + container: HTMLElement; + + ctrl: View; + + constructor(ctrl: View) { + this.ctrl = ctrl; + // this.render(); + } + + destroy() { + if (this.container) { + this.container.innerHTML = ''; + this.container.remove(); + } + } +} diff --git a/src/components/coordinate.ts b/src/components/coordinate.ts new file mode 100644 index 0000000..e18e176 --- /dev/null +++ b/src/components/coordinate.ts @@ -0,0 +1,68 @@ +import { get } from 'lodash-es'; +import uPlot from 'uplot'; + +import { View } from '../chart/view.js'; +import { CoordinateOpt, CoordinateOption } from '../types/index.js'; + +export class Coordinate { + name = 'coordinate'; + + isTransposed = false; + + ctrl: View; + + option: CoordinateOpt; + + constructor(ctrl: View) { + this.ctrl = ctrl; + this.render(); + } + + render() { + // .. + this.setOption(); + } + + update() { + // .. + this.setOption(); + } + + setOption() { + this.option = get(this.ctrl.getOption(), this.name); + if (this.option) { + this.isTransposed = this.option?.transposed; + } + } + + transpose() { + this.isTransposed = true; + } + + getOptions(): { + scales?: { + [key: string]: uPlot.Scale; + }; + axes?: uPlot.Axis[]; + } { + const option: CoordinateOption = get(this.ctrl.getOption(), this.name); + if (typeof option === 'object' && option.transposed) { + this.isTransposed = option.transposed; + } + return this.isTransposed + ? { + scales: { + y: { + ori: 0, + }, + }, + axes: [ + {}, + { + side: 2, + }, + ], + } + : {}; + } +} diff --git a/src/components/header.ts b/src/components/header.ts new file mode 100644 index 0000000..dda2b3c --- /dev/null +++ b/src/components/header.ts @@ -0,0 +1,74 @@ +import { StyleSheet, css } from 'aphrodite/no-important.js'; + +import { View } from '../chart/view.js'; +import { DIRECTION } from '../types/index.js'; +import { generateName } from '../utils/index.js'; + +const styles = StyleSheet.create({ + top: { + alignItems: 'center', + flexDirection: 'column', + }, + 'top-left': { + alignItems: 'flex-start', + flexDirection: 'column', + }, + 'top-right': { + flexDirection: 'row', + }, +}); + +export class Header { + get name(): string { + return 'header'; + } + + position: 'top' | 'top-left' | 'top-right'; + + ctrl: View; + + container: HTMLElement; + + private readonly sizeObserver: ResizeObserver; + + constructor( + ctrl: View, + position: 'top' | 'top-left' | 'top-right' = DIRECTION.TOP_RIGHT, + ) { + this.ctrl = ctrl; + this.position = position; + this.render(); + // this.sizeObserver = resizeObserver(this.container, () => { + // this.ctrl.render(); + // }); + } + + render(): void { + this.create(); + } + + create() { + const headerName = generateName('header'); + const header: HTMLElement = this.ctrl.chartContainer.querySelector( + `.${headerName}`, + ); + if (!this.container) { + this.container = header || document.createElement('div'); + if (header) { + header.style.wordBreak = 'break-all;'; + } + this.container.style.display = 'flex'; + this.container.style.justifyContent = 'flex-end'; + if (!header) { + this.ctrl.chartContainer.append(this.container); + } + } + this.container.className = `${generateName('header')} ${css( + styles[this.position], + )}`; + } + + destroy() { + this.sizeObserver.disconnect(); + } +} diff --git a/src/components/index.ts b/src/components/index.ts index 1d60f13..5adea71 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,9 +1,44 @@ -export * from './axis/index.js'; +import { ComponentCtor } from './base.js'; + +const LOADED_COMPONENTS: Map = new Map(); + +/** + * 全局注册组件。 + * @param name 组件名称 + * @param plugin 注册的组件类 + * @returns void + */ +export function registerComponent(name: string, plugin: ComponentCtor) { + LOADED_COMPONENTS.set(name, plugin); +} +/** + * 删除全局组件。 + * @param name 组件名 + * @returns void + */ +export function unregisterComponent(name: string) { + LOADED_COMPONENTS.delete(name); +} + +/** + * 获取以注册的组件名。 + * @returns string[] 返回已注册的组件名称 + */ +export function getComponentNames(): string[] { + return [...LOADED_COMPONENTS.keys()]; +} + +/** + * 根据组件名获取组件类。 + * @param name 组件名 + * @returns 返回组件类 + */ +export function getComponent(name: string): ComponentCtor { + return LOADED_COMPONENTS.get(name); +} + +export * from './axis.js'; +export * from './coordinate.js'; export * from './legend.js'; -export * from './pie.js'; -export * from './series.js'; export * from './title.js'; export * from './tooltip.js'; -export * from './x-plot-line.js'; -export * from './y-plot-line.js'; -export * from './zoom.js'; diff --git a/src/components/legend.ts b/src/components/legend.ts index 8de0558..dd7e05b 100644 --- a/src/components/legend.ts +++ b/src/components/legend.ts @@ -1,299 +1,177 @@ -import { select, Selection } from 'd3'; -import { clone } from 'lodash'; +import { StyleSheet, css } from 'aphrodite/no-important.js'; +import { get, isBoolean, isObject } from 'lodash-es'; -import { UIController } from '../abstract/index.js'; -import { View } from '../chart/index.js'; -import { CLASS_NAME, LEGEND_EVENTS } from '../constant.js'; -import { D3Selection, Data, LegendOption } from '../types/index.js'; -import { getChartColor, template } from '../utils/index.js'; +import { ChartEvent, DIRECTION, LegendOption } from '../types/index.js'; +import { generateName } from '../utils/index.js'; + +import { BaseComponent } from './base.js'; +import { Header } from './header.js'; +import { symbolStyle } from './styles.js'; + +type PositionTop = 'top' | 'top-left' | 'top-right'; +type PositionBottom = 'bottom' | 'bottom-left' | 'bottom-right'; export interface LegendItem { name: string; - activate: boolean; color: string; + activated: boolean; } -const OPTIONS = { - x: 0, - y: 10, - margin: 12, - rectWidth: 16, - rectHeight: 2, - rectMargin: 4, - rx: 0, -}; -export class Legend extends UIController { - container!: D3Selection; - - legendCt!: Selection; - - legendRectCt!: Selection; - legendIconCt!: Selection; - - get name() { +const styles = StyleSheet.create({ + ul: { + listStyle: 'none', + padding: 0, + margin: 0, + display: 'flex', + flexWrap: 'wrap', + }, + item: { + padding: 0, + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + ':not(:last-child)': { + marginRight: 12, + }, + }, + name: { + display: 'block', + whiteSpace: 'nowrap', + }, + legend: { + display: 'flex', + marginTop: 8, + }, + bottom: { + justifyContent: 'center', + }, + 'bottom-left': { + justifyContent: 'flex-start', + }, + 'bottom-right': { + justifyContent: 'flex-end', + }, +}); + +export class Legend extends BaseComponent { + get name(): string { return 'legend'; } - get isCircle() { - return ['pie', 'scatter'].includes(this.owner.options.type); - } - - get isScatter() { - return this.owner.options.type === 'scatter'; - } - - get hasTpl() { - return ( - typeof this.option.formatter === 'function' || - this.owner.options.customHeader - ); - } - - get isMount() { - return this.option.isMount; - } - - get innerOptions() { - const circle = { - rectWidth: 6, - rectHeight: 6, - rx: 6, - }; - return { - ...OPTIONS, - ...(this.isCircle ? circle : {}), - }; - } - - disabledLegend: Set = new Set(); - - legendItems: LegendItem[] = []; - - constructor(owner: View) { - super(owner); - this.option = owner.options.legend || {}; - } - - init() { - if (!this.option.hide) { - const legendEl = this.hasTpl - ? (this.owner.chartEle.header || this.owner.chartEle.chart).append( - 'div', - ) - : this.owner.chartEle.svg.append('g'); - this.container = legendEl; - this.owner.chartEle.legend = legendEl; - } - } + inactivatedSet = new Set(); render() { - if (this.owner.chartEle.legend) { - this.updateLegend(); + const opt = this.ctrl.getOption(); + this.option = get(opt, this.name, {}); + if (!this.container) { + this.create(); + } else { + this.update(); } } - destroy() { - this.container.remove(); - } - - reset() { - this.disabledLegend.clear(); - } - - updateLegend() { - this.setLegendItemsData(); - if (this.container) { - const { offsetX, offsetY } = this.option; - const box = this.owner.chartEle.svg.node(); - const width = box.clientWidth - this.owner.basics.padding.left || 0; - const x = width + (offsetX || 0); - const y = this.innerOptions.y + (offsetY || 0); - if ( - !this.container.selectAll(`.${CLASS_NAME.legendItem}`).size() && - !this.isMount - ) { - this.setLegendItem(); - } - if (this.hasTpl) { - return this.container - .attr('class', CLASS_NAME.legend) - .attr( - 'style', - `padding-top: ${offsetY || 0}px; padding-right: ${ - this.owner.basics.margin.right + (offsetX || 0) - }px`, - ); + update() { + this.option = get(this.ctrl.getOption(), this.name); + this.createItem(); + } + + create() { + if (isObject(this.option) || this.option === true) { + const { position } = isBoolean(this.option) + ? { position: DIRECTION.TOP_RIGHT } + : this.option; + let dom: HTMLElement = this.ctrl.container; + this.container = document.createElement('div'); + this.container.className = generateName('legend'); + if (position?.includes('top') || !position) { + const header = new Header( + this.ctrl, + position?.includes('top') + ? (position as PositionTop) + : DIRECTION.TOP_RIGHT, + ); + dom = header.container; + dom.append(this.container); + this.createItem(); + } else { + this.ctrl.on(ChartEvent.U_PLOT_READY, () => { + dom.append(this.container); + this.container.className = `${generateName('legend')} ${css( + styles.legend, + )} ${css(styles[position as PositionBottom])}`; + this.createItem(); + }); } - this.container - .attr('class', CLASS_NAME.legend) - .attr('transform', `translate(${x}, ${y})`); } } - setLegendItem() { - if (this.hasTpl) { - if (typeof this.option.formatter === 'function') { - this.container.html(this.option.formatter(this.legendItems)); + createItem() { + if ( + (isObject(this.option) || this.option === true) && + !get(this.option, 'custom') + ) { + this.container.innerHTML = ''; + const ul = document.createElement('ul'); + ul.className = css(styles.ul); + const data = this.getLegend(); + for (const key of data.entries()) { + const li = document.createElement('li'); + const value = key[1]; + li.className = css(styles.item); + li.innerHTML = ` + + ${value.name}`; + ul.append(li); + // TODO: 挪到 interaction 管理 + li.addEventListener('click', () => { + const activated = li.style.opacity === '1' || !li.style.opacity; + li.style.opacity = activated ? '0.5' : '1'; + value.activated = !activated; + this.legendItemClick({ + name: value.name, + activated: !activated, + }); + }); + // li.addEventListener('mouseenter', e => { + // console.log(e); + // const item = this.container.querySelectorAll('li'); + // item.forEach(value => { + // if (li !== value) { + // value.style.opacity = '0.5'; + // } + // }); + // }); + + // li.addEventListener('mouseleave', () => { + // const item = this.container.querySelectorAll('li'); + // item.forEach(value => { + // value.style.opacity = '1'; + // }); + // }); } - } else { - this.createLegendItemDom(); + this.container.append(ul); } - this.bindEvent(this.legendCt); } - bindEvent(el: D3Selection) { - el?.on('click', (event: Event, data: LegendItem) => { - this.changeLegend(this.legendItems.find(d => data.name === d.name)); - this.setTargetClass(event, CLASS_NAME.legendItemHidden); - }); - } - - changeLegend(legend: LegendItem) { - const { name, activate } = legend; - if (activate) { - this.disabledLegend.add(name); + legendItemClick(props: { name: string; activated: boolean }) { + if (props.activated) { + this.inactivatedSet.delete(props.name); } else { - this.disabledLegend.delete(name); + this.inactivatedSet.add(props.name); } - this.setLegendItemsData(); - this.owner.emit(LEGEND_EVENTS.CLICK, { - legend, - source: this.legendItems, - }); - } - - legendSelectAll() { - this.disabledLegend.clear(); - this.owner.emit(LEGEND_EVENTS.SELECT_ALL); - } - - legendUnselectAll() { - const all = this.owner.options.data.map(d => d.name); - this.disabledLegend = new Set(all); - this.owner.emit(LEGEND_EVENTS.UNSELECT_ALL); - } - - setLegendItemsData() { - let cloneData = this.hasTpl - ? this.owner.options.data - : clone(this.owner.options.data).reverse(); - cloneData = cloneData.filter(d => d.name); - const values = cloneData.flatMap(item => item.values) as Array< - Data<{ x: string; color: string; name: string }> - >; - this.legendItems = - this.owner.options.type === 'bar' - ? values.reduce>>( - (pre, cur) => [ - ...pre, - ...(pre.some(d => d.name === cur.x) - ? [] - : [ - { - name: cur.x, - color: cur.color || getChartColor(pre.length), - activate: !this.disabledLegend.has(cur.x), - }, - ]), - ], - [], - ) - : cloneData.map(d => ({ - name: d.name, - color: d.color || '', - activate: !this.disabledLegend.has(d.name), - })); - } - - private setTargetClass(event: Event, className: string) { - const rect = this.getRectTarget(event); - const hide = rect.classed(className); - rect.classed(className, !hide); + this.ctrl.emit(ChartEvent.LEGEND_ITEM_CLICK, props); } - private getRectTarget(event: Event) { - const target = event.target as SVGRectElement; - return select(target.parentNode as SVGRectElement); + getLegend(): LegendItem[] { + const data = this.ctrl.getData(); + return data + .map(({ name, color }) => ({ + name, + color, + activated: !this.inactivatedSet.has(name), + })) + .filter(d => d.name); } - - private createLegendItemDom() { - this.legendCt = this.container - .selectAll(`.${CLASS_NAME.legend}`) - .data(this.legendItems) - .enter() - .append('g') - .attr('class', CLASS_NAME.legendItem) - .style('cursor', 'pointer'); - - this.legendRectCt = this.legendCt - .append('rect') - .attr('class', CLASS_NAME.legendItemEvent); - - this.legendIconCt = this.legendCt - .append('rect') - .attr('class', CLASS_NAME.legendItemIcon) - .attr('width', this.innerOptions.rectWidth) - .attr('height', this.innerOptions.rectHeight) - .attr('rx', this.innerOptions.rx) - .attr('fill', d => d.color || ''); - - this.setPosition(); - } - - private setPosition() { - this.legendCt - .append('text') - .text(data => { - if (this.option.itemFormatter) { - if (typeof this.option.itemFormatter === 'function') { - return this.option.itemFormatter(data.name); - } - return template(this.option.itemFormatter, data); - } - return data.name; - }) - .attr('x', (_, index, target) => -target[index].getBBox().width) - .attr('y', (_, index, target) => target[index].getBBox().height / 4); - - this.legendIconCt - .attr('y', -(this.innerOptions.rectHeight / 2)) - .attr( - 'x', - (_, index, target) => - -(getParentNode(index, target).width + this.innerOptions.rectMargin), - ); - - this.legendCt.attr('transform', (_, index, targets) => { - const width = targets[0].getBBox().width * (index - 1); - const x = index - ? targets[index - 1].getBBox().width + - width + - this.innerOptions.margin * index - : 0; - return `translate(${-x}, 0)`; - }); - - this.legendRectCt - .attr('width', (_, index, target) => getParentNode(index, target).width) - .attr( - 'height', - (_, index, target) => getParentNode(index, target).height || 0, - ) - .attr( - 'y', - (_, index, target) => -(getParentNode(index, target).height / 3), - ) - .attr( - 'x', - (_, index, target) => -(getParentNode(index, target).width / 2), - ) - .attr('opacity', 0); - } -} - -function getParentNode( - index: number, - target: ArrayLike | SVGRectElement[], -) { - return (target[index].parentNode as SVGRectElement).getBBox(); } diff --git a/src/components/pie.ts b/src/components/pie.ts deleted file mode 100644 index 1730ba3..0000000 --- a/src/components/pie.ts +++ /dev/null @@ -1,332 +0,0 @@ -import * as d3 from 'd3'; - -import { UIController } from '../abstract/index.js'; -import { View } from '../chart/index.js'; -import { PIE_EVENTS, CLASS_NAME } from '../constant.js'; -import { ChartData, PieSeriesOption } from '../types/index.js'; -import { getChartColor, isPercentage, rgbColor } from '../utils/index.js'; - -export const DEFAULT_RADIUS_DIFF = 8; -export const ACTIVE_RADIUS_ENLARGE_SIZE = 2; - -interface PieItemConfig { - startAngle: number; - endAngle: number; - innerRadius: number; - outerRadius: number; - borderRadius: number; - borderWidth: number; - color: string; -} - -interface PieItemValue { - path: string; - config: PieItemConfig; - data: ChartData; - selected?: boolean; -} - -export class Pie extends UIController { - get name() { - return 'pie'; - } - - override option: PieSeriesOption; - container!: d3.Selection; - - pieGuide!: d3.Selection; - - constructor(owner: View) { - super(owner); - this.option = (owner.options.seriesOption || {}) as PieSeriesOption; - } - - init() { - this.container = this.owner.chartEle.svg.append('g'); - } - - get nullData() { - return this.owner.chartData.every(d => !d.value); - } - - render() { - this.update(); - this.owner.on( - PIE_EVENTS.ITEM_HOVERED, - (res: { self: unknown; data: PieItemValue; event: MouseEvent }) => { - const item = res.data; - const path = getPath({ - ...item.config, - innerRadius: item.config.innerRadius - ACTIVE_RADIUS_ENLARGE_SIZE, - outerRadius: item.config.outerRadius + ACTIVE_RADIUS_ENLARGE_SIZE, - borderWidth: item.config.borderWidth - ACTIVE_RADIUS_ENLARGE_SIZE, - }); - d3.select(res.self as any) - .attr('opacity', 0.9) - .transition() - .attr('d', path); - }, - ); - - this.owner.on( - PIE_EVENTS.ITEM_MOUSEOUT, - (res: { self: unknown; data: PieItemValue; event: MouseEvent }) => { - const item = res.data; - d3.select(res.self as any) - .attr('opacity', 1) - .transition() - .attr('d', getPath(item.config)); - }, - ); - } - - update() { - this.option = (this.owner.options.seriesOption || {}) as PieSeriesOption; - this.renderLabel(); - this.updatePie(); - } - - updatePie() { - const paths = calculatePaths( - this.owner.chartData, - { - ...this.option, - backgroundColor: this.nullData - ? rgbColor('n-8') - : this.option.backgroundColor, - }, - this.owner.size.main.width, - ); - - const { clientWidth, clientHeight } = this.owner.chartEle.svg.node()!; - this.container - .attr('transform', `translate(${clientWidth / 2},${clientHeight / 2})`) - .selectAll('path') - .data(paths) - .join('path') - .attr('fill', e => e.config.color) - .attr('d', e => e.path); - const owner = this.owner; - const tooltip = this.owner.getController('tooltip'); - - tooltip?.mountPaths( - this.container.selectAll('path'), - this.owner.chartEle.main, - ); - const pieItems = ( - this.container.selectAll('path') as d3.Selection< - any, - { - path: string; - config: PieItemConfig; - data: ChartData; - }, - any, - any - > - ).filter(function (e) { - return !!e.data; - }); - - // pieItems.on('click', function (event: MouseEvent, res: PieItemValue) { - // console.log(res, event) - // owner.emit(PIE_EVENTS.ITEM_CLICK, res); - // // const item = res.data; - // const path = getPath({ - // ...res.config, - // innerRadius: res.config.innerRadius + SELECT_OFFSET, - // outerRadius: res.config.outerRadius + SELECT_OFFSET, - // }); - // d3.select(this) - // .attr('opacity', 0.9) - // .transition() - // .attr('d', path); - // }); - - if (!tooltip || this.owner.options.tooltip?.trigger === 'none') { - pieItems - .on('mouseover', function (event: MouseEvent, data) { - owner.emit(PIE_EVENTS.ITEM_HOVERED, { - self: this as unknown, - event, - data, - }); - }) - .on('mouseout', function (event: MouseEvent, data) { - owner.emit(PIE_EVENTS.ITEM_MOUSEOUT, { - self: this as unknown, - event, - data, - }); - }); - } - } - - renderLabel() { - if (this.option.label) { - const { x, y } = this.option.label.position; - if (!this.pieGuide) { - this.pieGuide = this.owner.chartEle.chart - .append('div') - .attr('class', CLASS_NAME.pieGuide) - .style('position', 'absolute'); - } - if (this.option.label.text) { - this.pieGuide.html(this.option.label.text); - } - this.pieGuide - .style('top', x) - .style('left', y) - .style('transform', 'translate(-50%, -50%)'); - } - } - - destroy(): void { - this.container.remove(); - } -} - -// path -function getPath(config: { - startAngle: number; - endAngle: number; - innerRadius: number; - outerRadius: number; - borderRadius: number; - borderWidth: number; - color: string; -}) { - const arc = d3 - .arc() - .cornerRadius(config.borderRadius) - .padAngle((config.borderWidth * Math.PI) / 180); - return arc({ - innerRadius: config.innerRadius, - outerRadius: config.outerRadius, - startAngle: config.startAngle, - endAngle: config.endAngle, - }); -} - -function calculatePaths( - data: ChartData[], - option: PieSeriesOption, - ownerWidth: number, -) { - const rawTotal = data.reduce((acc, curr) => acc + curr.value, 0); - const total = Math.max(option.total || 0, rawTotal); - const startAngle = (option.startAngle || 0) % (2 * Math.PI); - const endAngle = (option.endAngle || 0) % (2 * Math.PI) || 2 * Math.PI; - - const diffAngle = - endAngle < startAngle - ? ((endAngle - startAngle) % (2 * Math.PI)) + 2 * Math.PI - : endAngle - startAngle; - const angles = data.map(data => (data.value / total || 0) * diffAngle); - - const { outerRadius, innerRadius } = getRadius(option, ownerWidth); - - const { borderRadius = outerRadius - innerRadius, borderWidth = 1.5 } = - option?.itemStyle || {}; - - const arc = d3 - .arc() - .cornerRadius(borderRadius) - .padAngle((borderWidth * Math.PI) / 180); - - let accumulate = startAngle; - const baseConifg: Partial = { - innerRadius, - outerRadius, - borderRadius, - borderWidth, - }; - - const innerDisc = option.innerDisc - ? [ - { - path: arc({ - innerRadius: innerRadius - 10, - outerRadius: outerRadius - 16.5, - startAngle, - endAngle, - })!, - config: { - color: rgbColor('n-8'), - startAngle, - endAngle, - ...baseConifg, - }, - }, - ] - : []; - - return angles.reduce( - (acc, curr, ind) => { - const startAngle = accumulate; - const endAngle = accumulate + angles[ind]; - const result = [ - ...acc, - { - path: arc({ - innerRadius, - outerRadius, - startAngle, - endAngle, - })!, - config: { - color: data[ind].color || getChartColor(ind)!, - startAngle, - endAngle, - ...baseConifg, - }, - data: data[ind], - }, - ]; - accumulate += curr; - return result; - }, - [ - { - path: arc({ - innerRadius, - outerRadius, - startAngle, - endAngle, - })!, - config: { - color: option.backgroundColor || 'transparent', - startAngle, - endAngle, - ...baseConifg, - }, - data: null, - }, - ...innerDisc, - ], - ); -} - -function getRadius(option: PieSeriesOption, containerWidth: number) { - let outerRadius = - option.outerRadius && isPercentage(option.outerRadius) - ? (containerWidth * parseFloat(option.outerRadius)) / 200 - : option.outerRadius; - let innerRadius = - option.innerRadius && isPercentage(option.innerRadius) - ? (containerWidth * parseFloat(option.innerRadius)) / 200 - : option.innerRadius; - if (!outerRadius && !innerRadius) { - throw new Error('Either outerRadius or innerRadius is required!'); - } - if (!innerRadius) { - innerRadius = (outerRadius as number) - DEFAULT_RADIUS_DIFF; - } - if (!outerRadius) { - outerRadius = (innerRadius as number) + DEFAULT_RADIUS_DIFF; - } - return { - outerRadius: outerRadius as number, - innerRadius: innerRadius as number, - }; -} diff --git a/src/components/scale.ts b/src/components/scale.ts new file mode 100644 index 0000000..96ebece --- /dev/null +++ b/src/components/scale.ts @@ -0,0 +1,90 @@ +import { get, isNumber } from 'lodash-es'; + +import { UPlotViewStrategy } from '../strategy/index.js'; +import { ScaleOption } from '../types/index.js'; + +import { BaseComponent } from './base.js'; +import uPlot from 'uplot'; + +export class Scale extends BaseComponent> { + name = 'scale'; + + private get strategy() { + return this.ctrl.strategyManage.getStrategy('uPlot') as UPlotViewStrategy; + } + + render() { + this.option = get(this.ctrl.getOption(), this.name, { x: {}, y: {} }); + } + + update() { + // .. + this.option = get(this.ctrl.getOption(), this.name, { x: {}, y: {} }); + const x = get(this.option, 'x'); + const y = get(this.option, 'y'); + + x && this.setScale('x', { min: x.min, max: x.max }); + y && this.setScale('y', { min: y.min, max: y.max }); + } + + getOptions() { + return { + scales: { + x: this.getXOptions(), + y: this.getYOptions(), + }, + }; + } + + setScale(field: 'x' | 'y', limits: { min?: number; max?: number }) { + const iUPlot = this.strategy.getUPlotChart(); + const scale = iUPlot.scales[field]; + this.ctrl.setOption([this.name, field], limits); + iUPlot.setScale(field, { + min: limits.min || scale.min, + max: limits.max || scale.max, + }); + } + + private getXOptions() { + if (!this.option.x) { + return {}; + } + const { max, min } = this.option.x || {}; + const maxV = isNumber(max) ? { max } : {}; + const minV = isNumber(min) ? { min } : {}; + return { + ...maxV, + ...minV, + }; + } + + private getYOptions() { + const { max, min } = this.option.y || {}; + return { + auto: true, + range: (_u: uPlot, dataMin: number, dataMax: number) => { + const rangeConfig: uPlot.Range.Config = { + min: { + pad: 0.1, + hard: min ?? -Infinity, + soft: 0, + mode: 3, + }, + max: { + pad: 0.1, + hard: max ?? Infinity, + soft: 0, + mode: 3, + }, + }; + const minMax = uPlot.rangeNum( + min != null && min != undefined ? min : dataMin, + max != null && max != undefined ? max : dataMax, + rangeConfig, + ); + return [minMax[0], minMax[1]]; + }, + }; + } +} diff --git a/src/components/series.ts b/src/components/series.ts deleted file mode 100644 index 8636553..0000000 --- a/src/components/series.ts +++ /dev/null @@ -1,598 +0,0 @@ -import { - area, - curveMonotoneX, - line, - ScaleBand, - ScaleLinear, - ScaleTime, - select, - Selection, -} from 'd3'; - -import { UIController } from '../abstract/index.js'; -import { View } from '../chart/index.js'; -import { - CLASS_NAME, - DEFAULT_LINE_WIDTH, - DEFAULT_SCATTER_OPTIONS, - GRADIENT_PREFIX, - STROKE_WIDTH, -} from '../constant.js'; -import { - AreaSeriesOption, - BarSeriesOption, - ChartData, - ChartType, - D3ChartSelection, - D3Selection, - Data, - LineSeriesOption, - ScatterOption, - XData, -} from '../types/index.js'; -import { abs, defined, removeSymbol } from '../utils/index.js'; - -function handleData(d: ChartData) { - return d.values - .filter(d => d.y) - .map(item => ({ - ...item, - name: d.name, - total: d.values.filter(d => d.y)?.length, - })); -} - -export class Series extends UIController { - eventContainer!: D3Selection; - - type: ChartType; - - container!: D3Selection; - - get name(): string { - return 'series'; - } - - get isStack() { - return (this.option as BarSeriesOption).stack; - } - - get isRotated() { - return this.owner.isRotated; - } - - get isGroup() { - return (this.option as BarSeriesOption).isGroup; - } - - constructor(view: View) { - super(view); - this.type = view.options.type || 'line'; - this.option = view.options.seriesOption || {}; - } - - init() { - const main = this.owner.chartEle.main; - main.append('g').attr('class', CLASS_NAME.chart); - this.container = this.owner.chartEle.main - .select(`.${CLASS_NAME.chart}`) - .append('g') - .attr('class', CLASS_NAME[`${this.type}s`]); - - // create event rect - this.eventContainer = this.owner.chartEle.main - .select(`.${CLASS_NAME.chart}`) - .append('g') - .attr('class', CLASS_NAME.eventRect) - .style('fill-opacity', '0'); - this.eventContainer - .append('rect') - .attr('class', CLASS_NAME.eventRect) - .attr('width', this.owner.size.grid.width) - .attr('height', Math.max(this.owner.size.grid.height, 0)); - } - - render() { - this.updateSeries(); - } - - updateSeries() { - this.type = this.owner.options.type || 'line'; - // create event rect - // 会有切换area 变 line, 每次update 删除area - this.container.selectAll(`.${CLASS_NAME.area}`).remove(); - this.eventContainer - .select(`rect.${CLASS_NAME.chart}`) - .attr('class', CLASS_NAME.eventRect) - .attr('width', this.owner.size.grid.width) - .attr('height', Math.max(this.owner.size.grid.height, 0)); - switch (this.type) { - case 'line': - this.updateLineSeries(); - this.updateLineSeries(true); - break; - case 'area': - this.updateLineSeries(); - this.updateLineSeries(true); - this.updateAreaSeries(); - break; - case 'bar': - if (this.isGroup) { - this.updateBarSeries(); - this.updateBarSeries(true); - } else { - this.updateDefaultBarSeries(); - this.updateDefaultBarSeries(true); - } - break; - case 'scatter': - this.updateScatterSeries(); - this.updateScatterSeries(true); - break; - default: - break; - } - } - - destroy() { - // .. - } - - private updateLineSeries(isClone?: boolean) { - const container = isClone ? this.eventContainer : this.container; - const className = isClone ? CLASS_NAME.cloneLine : CLASS_NAME.line; - const line = container - .selectAll(`.${className}`) - .data(this.owner.chartData); - - line.exit().remove(); - - const res = line - .enter() - .append('g') - .attr('class', d => `${className} ${className}-${removeSymbol(d.name)}`) - .merge(line as D3ChartSelection) - .text(d => removeSymbol(d.name)); - const lineG = this.getLineGenerator(); - const path = res - .append('path') - .attr('fill', 'none') - .attr( - STROKE_WIDTH, - (this.option as LineSeriesOption).lineWidth || DEFAULT_LINE_WIDTH, - ) - .attr('stroke', d => d.color || '000') - .attr('d', d => lineG(d.values)); - if (isClone) { - path.attr('opacity', 0).attr(STROKE_WIDTH, 10); - this.owner - .getController('tooltip') - ?.mountPaths(path, this.eventContainer); - } - } - - private updateAreaSeries() { - const area = this.container - .selectAll(`.${CLASS_NAME.area}`) - .data(this.owner.chartData); - - area.exit().remove(); - - const res = area - .enter() - .append('g') - .attr('class', CLASS_NAME.area) - .merge(area as D3ChartSelection) - .text(d => removeSymbol(d.name)) - .each((d, index, el) => { - const id = `${GRADIENT_PREFIX}${removeSymbol(d.name)}-${ - this.owner.chartUId - }`; - this.createGradient(d, select(el[index]), id); - }); - - const areaG = this.getAraGenerator(); - res - .append('path') - .attr('fill', 'none') - .attr(STROKE_WIDTH, 1) - .attr('d', d => areaG(d.values)) - .attr( - 'fill', - d => - `url(#${GRADIENT_PREFIX}${removeSymbol(d.name)}-${ - this.owner.chartUId - })`, - ) - .raise(); - } - - // eslint-disable-next-line sonarjs/cognitive-complexity - private updateBarSeries(isClone?: boolean) { - const container = isClone ? this.eventContainer : this.container; - const className = isClone ? CLASS_NAME.cloneBar : CLASS_NAME.bar; - - const bar = container.selectAll(`.${className}`).data(this.owner.chartData); - - bar.exit().remove(); - - const barRes = bar - .enter() - .append('g') - .attr('class', className) - .merge(bar as D3ChartSelection) - .text(d => d.name); - - const barRectItem = barRes - .selectAll(`.${className}`) - .data(handleData) - .enter() - .append('rect') - .attr('class', CLASS_NAME.barItem); - - const { - radius, - bandwidth = 0, - columnClick, - } = this.option as BarSeriesOption; - - if (!isClone) { - this.owner.getController('tooltip')?.mountPaths(bar, this.eventContainer); - } - - if (isClone) { - barRectItem.on('click', (_, d) => { - columnClick?.({ name: d.x, value: d.y, color: d.color }); - }); - } - - const { xBarScale, x, y } = this.owner.getController('scale'); - const xScale = this.isStack ? (x as ScaleBand) : xBarScale; - barRes.attr('transform', d => { - const value = (x as ScaleBand)(d.name) || 0; - const offsetX = this.isRotated ? 0 : value; - const offsetY = this.isRotated ? value : 0; - - const width = bandwidth / 2; - const rate = 0.5; - const barPosition = (x as ScaleBand).bandwidth() * rate - width; - const offsetWidth = bandwidth - ? this.isStack - ? barPosition - : barPosition / 3 - width - : 0; - return `translate(${this.isRotated ? 0 : offsetX + offsetWidth},${ - this.isRotated ? offsetY + offsetWidth : 0 - })`; - }); - - if (this.isStack) { - const clipPath = barRes - .append('defs') - .selectAll(`.${className}`) - .data(handleData) - .enter() - .append('clipPath'); - - const clipRect = clipPath - .attr( - 'id', - d => - `bar-item-clip-${removeSymbol(d.name)}-${removeSymbol( - d.x as string, - )}`, - ) - .append('rect'); - this.setBarItemAttr(barRectItem, xScale); - this.setBarItemAttr(clipRect, xScale, true); - return; - } - - const { width, height } = this.owner.size.main; - const h = this.isRotated ? width : height; - barRectItem - .attr( - `${this.isRotated ? 'height' : 'width'}`, - abs(bandwidth || xScale.bandwidth() || 0), - ) - .attr('fill', d => d.color) - .attr(`${this.isRotated ? 'y' : 'x'}`, d => xScale(d.x as string) || 0) - .attr(`${this.isRotated ? 'width' : 'height'}`, d => - !d.y ? 0 : abs(this.isRotated ? y(d.y) : h - y(d.y) || 0), - ) - .attr('rx', radius) - .attr(`${this.isRotated ? 'x' : 'y'}`, (d, index, target) => - this.handleRectXY(d, index, target, y), - ); - } - - private updateScatterSeries(isClone?: boolean) { - const container = isClone ? this.eventContainer : this.container; - const className = isClone ? CLASS_NAME.cloneScatter : CLASS_NAME.scatter; - const scatter = container - .selectAll(`.${className}`) - .data(this.owner.chartData); - - scatter.exit().remove(); - - const scatterRes = scatter - .enter() - .append('g') - .attr('class', d => `${className} ${className}-${removeSymbol(d.name)}`) - .merge(scatter as D3ChartSelection) - .text(d => removeSymbol(d.name)); - const { x, y } = this.owner.getController('scale'); - const scatterItem = scatterRes - .selectAll(`.${className}`) - .data(item => - item.values.map(d => ({ - pName: item.name, - name: d.x, - color: item.color, - value: d.y, - ...d, - })), - ) - .enter() - .append('circle') - .attr( - 'class', - d => - `${CLASS_NAME.scatterItem} ${CLASS_NAME.scatterItem}-${removeSymbol( - d.name as string, - )}`, - ); - - const { size, type, opacity, minSize, maxSize } = this - .option as ScatterOption; - const def = DEFAULT_SCATTER_OPTIONS; - const scatterItemCircle = scatterItem - .attr('fill', 'none') - .attr( - STROKE_WIDTH, - (this.option as LineSeriesOption).lineWidth || DEFAULT_LINE_WIDTH, - ) - .attr('cx', d => (x as ScaleTime)(d.x as number)) - .attr('cy', d => y(d.y)) - .attr('r', d => { - if (type === 'bubble') { - const val = d.size || size || def.size; - const min = Math.max(val, minSize || def.minSize); - const max = Math.min(val, maxSize || def.maxSize); - return Math.max(min, max); - } - return size; - }) - .attr('fill', d => d.color) - .attr('fill-opacity', type === 'bubble' ? opacity || def.opacity : 1) - .attr('stroke', d => d.color) - .attr('stroke-width', 1); - - if (isClone) { - scatterItemCircle.attr('opacity', 0).attr(STROKE_WIDTH, 10); - this.owner - .getController('tooltip') - ?.mountPaths( - scatterItemCircle as unknown as D3ChartSelection, - this.eventContainer, - ); - } - } - - handleRectXY( - d: any, - index: number, - target: SVGRectElement[] | ArrayLike, - y: ScaleLinear, - ) { - const preTarget = target[index - 1]; - const curTarget = target[index]; - const preEl = index ? preTarget.getBBox() : { height: 0, y: 0 }; - if (this.isRotated && !this.isStack) { - return 0; - } - if (this.isStack && this.isRotated) { - return this.getStackX(preTarget, index); - } - return this.isStack - ? index - ? preEl.y - curTarget.getBBox().height - : +y(d.y as number) - : +y(d.y as number); - } - - // eslint-disable-next-line sonarjs/cognitive-complexity - private setBarItemAttr( - rect: Selection< - SVGRectElement, - { - name: string; - x: string | number | Date; - y: number; - color?: string; - value?: number; - total: number; - }, - any, - ChartData - >, - xScale: ScaleBand, - isClip?: boolean, - ) { - const { - radius, - bandwidth = 0, - closeRadiusLadder, - } = this.option as BarSeriesOption; - const { y } = this.owner.getController('scale'); - const { width, height } = this.owner.size.main; - const h = this.isRotated ? width : height; - - const itemsRect = rect - .attr( - `${this.isRotated ? 'height' : 'width'}`, - abs(bandwidth || xScale.bandwidth() || 0), - ) - .attr('fill', d => d.color) - .attr(`${this.isRotated ? 'y' : 'x'}`, d => xScale(d.x as string) || 0) - .attr(`${this.isRotated ? 'width' : 'height'}`, (d, index) => { - let num = !isClip ? 0 : index + 1 === d.total ? 2 : 0; - if (isClip && index === 0 && d.total !== 1 && this.isRotated) { - num = -2.5; - } - return ( - (!d.y ? 0 : abs(this.isRotated ? y(d.y) : h - y(d.y))) + num || 0 - ); - }) - .attr(`${this.isRotated ? 'x' : 'y'}`, (d, index, target) => - this.handleRectXY(d, index, target, y), - ); - - if (!isClip && this.isStack) { - itemsRect - .attr('clip-path', (d, index) => - (!index && d.total === 1) || closeRadiusLadder - ? '' - : `url(#bar-item-clip-${removeSymbol(d.name)}-${removeSymbol( - d.x as string, - )})`, - ) - .attr('rx', (_, index) => (index ? 0 : radius)); - } else { - itemsRect.attr('rx', (d, index) => - !index ? 0 : index === d.total - 1 ? radius : radius - radius / 2, - ); - } - } - - // eslint-disable-next-line sonarjs/cognitive-complexity - private updateDefaultBarSeries(isClone?: boolean) { - const container = isClone ? this.eventContainer : this.container; - const className = isClone ? CLASS_NAME.cloneBar : CLASS_NAME.bar; - const bar = container.selectAll(`.${className}`).data(this.owner.chartData); - bar.exit().remove(); - - const barRes = bar - .enter() - .append('g') - .attr('class', className) - .merge(bar as D3ChartSelection) - .text(d => d.name); - - const barRectItem = barRes - .selectAll(`.${className}`) - .data(item => - item.values.map(d => ({ - name: d.x, - color: item.color, - value: d.y, - ...d, - })), - ) - .enter() - .append('rect') - .attr('class', CLASS_NAME.barItem); - - const { x, y } = this.owner.getController('scale'); - const scaleX = x as ScaleBand; - - if (!isClone) { - this.owner - .getController('tooltip') - ?.mountPaths( - barRectItem as unknown as D3ChartSelection, - this.eventContainer, - ); - } - const { - radius, - bandwidth = 0, - columnClick, - minHeight, - } = this.option as BarSeriesOption; - - if (isClone) { - barRectItem.on('click', (_, d) => { - columnClick?.(d); - }); - } - const { width, height } = this.owner.size.main; - const h = this.isRotated ? width : height; - const gy = (n: number) => { - const top = y(n); - if (!n) { - return top; - } - if (this.isRotated) { - return Math.max(minHeight, top); - } - return h - top < minHeight ? h - minHeight : top; - }; - barRectItem - .attr('x', d => { - const width = bandwidth ? (scaleX.bandwidth() - bandwidth) / 2 : 0; - return this.isRotated ? 0 : scaleX(d.x as string) + width; - }) - .attr('y', d => (this.isRotated ? scaleX(d.x as string) : gy(d.y))) - .attr( - `${this.isRotated ? 'height' : 'width'}`, - abs(bandwidth || scaleX.bandwidth()), - ) - .attr(`${this.isRotated ? 'width' : 'height'}`, d => - abs(this.isRotated ? gy(d.y) : h - gy(d.y) || 0), - ) - .attr('rx', radius || 0) - .attr('fill', d => d.color); - } - - private getStackX(pre: SVGRectElement, index: number) { - // firefox getBBox 不计算隐藏的元素 - return index - ? (pre.getBBox().width || pre.width.baseVal.value) + - (pre.getBBox().x || pre.x.baseVal.value) - : 0; - } - - private createGradient(data: ChartData, el: D3Selection, id: string) { - const defs = el.append('defs'); - const linearGradient = defs.append('linearGradient').attr('id', id); - const { startOpacity, endOpacity } = this.option as AreaSeriesOption; - const opacity = startOpacity || 0.15; - linearGradient - .attr('x1', '0%') - .attr('y1', '0%') - .attr('x2', '0%') - .attr('y2', '100%'); - - linearGradient - .append('stop') - .attr('offset', '0%') - .attr('stop-opacity', opacity) - .attr('stop-color', data.color || ''); - - linearGradient - .append('stop') - .attr('offset', '100%') - .attr('stop-opacity', endOpacity || 0) - .attr('stop-color', data.color || ''); - } - - private getLineGenerator() { - const { x, y } = this.owner.getController('scale'); - return line>() - .defined(defined) - .curve((this.option as AreaSeriesOption).curveType || curveMonotoneX) - .x(d => (x as ScaleTime)(d.x as number)) - .y(d => y(d.y) || 0); - } - - private getAraGenerator() { - const { x, y } = this.owner.getController('scale'); - return area>() - .defined(defined) - .curve((this.option as AreaSeriesOption).curveType || curveMonotoneX) - .x(d => (x as ScaleTime)(d.x as number)) - .y0(y.range()[0]) - .y1(d => y(d.y) || 0); - } -} diff --git a/src/components/shape/area.ts b/src/components/shape/area.ts new file mode 100644 index 0000000..c7bd373 --- /dev/null +++ b/src/components/shape/area.ts @@ -0,0 +1,44 @@ +import { omit } from 'lodash-es'; +import { View } from '../../chart/view.js'; +import { getSeriesPathType } from '../../strategy/utils.js'; +import { ShapeOptions } from '../../types/options.js'; +import { ShapeType } from '../../utils/index.js'; + +import { Shape } from './index.js'; + +/** + * Area 面积图 + */ +export default class Area extends Shape { + override type = ShapeType.Area; + + constructor(ctrl: View, opt: ShapeOptions = {}) { + super(ctrl, opt); + this.ctrl.setShape(this.type, this); + } + + map(name: string) { + this.mapName = name; + return this; + } + + getSeries() { + const baseSeries = this.getBaseSeries(); + return this.getData().map(({ color, name }) => { + return { + stroke: color, + label: name, + ...(omit(this.option, 'alpha')), + ...getSeriesPathType(this.type, color, this.option), + ...baseSeries, + ...(baseSeries.points && { + points: { + ...baseSeries.points, + fill: color, + space: 0, + }, + }), + }; + }); + } +} diff --git a/src/components/shape/bar.ts b/src/components/shape/bar.ts new file mode 100644 index 0000000..2837e6a --- /dev/null +++ b/src/components/shape/bar.ts @@ -0,0 +1,94 @@ +import { get } from 'lodash-es'; + +import { View } from '../../chart/view.js'; +import { UPlotViewStrategy } from '../../strategy/index.js'; +import { getSeriesPathType } from '../../strategy/utils.js'; +import { BarShapeOption, ShapeOptions } from '../../types/options.js'; +import { ShapeType } from '../../utils/index.js'; +import { seriesBarsPlugin, stack } from '../uplot-lib/index.js'; + +import { Shape } from './index.js'; + +export type AdjustType = 'stack' | 'group'; +export interface AdjustOption { + type?: AdjustType; // 默认 group + marginRatio?: number; // type group 下有效 0-1 范围 默认 0.1 +} + +/** + * Bar 柱状图 + */ +export default class Bar extends Shape { + override type = ShapeType.Bar; + + private get transposed() { + return this.ctrl.getCoordinate().isTransposed; + } + + private adjustOption: AdjustOption = { + type: 'group', + marginRatio: 0.2, + }; + + private get strategy() { + return this.ctrl.strategyManage.getStrategy('uPlot') as UPlotViewStrategy; + } + + constructor(ctrl: View, opt: ShapeOptions = {}) { + super(ctrl, opt); + this.ctrl.setShape(this.type, this); + const option: BarShapeOption = get(this.ctrl.getOption(), this.type); + if (typeof option === 'object') { + this.option = option; + this.adjustOption = { + ...this.adjustOption, + ...option.adjust, + }; + } + } + + map(name: string): this { + this.mapName = name; + return this; + } + + adjust(adjustOpt: AdjustType | AdjustOption) { + if (typeof adjustOpt === 'string') { + this.adjustOption.type = adjustOpt; + } + if (typeof adjustOpt === 'object') { + this.adjustOption = adjustOpt; + } + } + + getSeries() { + return this.getData().map(({ color, name }) => { + return { + stroke: color, + label: name, + points: { + show: false, + }, + ...getSeriesPathType(this.type, color, this.option), + }; + }); + } + + override getOptions() { + const data = this.strategy.getData(); + const isStack = this.adjustOption.type === 'stack'; + const stackOpt = isStack ? stack(data) : {}; + return { + ...stackOpt, + plugins: [ + seriesBarsPlugin({ + time: !!get(this.ctrl.getOption()?.scale?.x, 'time'), + ori: this.transposed ? 1 : 0, + dir: this.transposed ? -1 : 1, + stacked: isStack, + marginRatio: this.adjustOption.marginRatio / 10, + }), + ], + }; + } +} diff --git a/src/components/shape/gauge.ts b/src/components/shape/gauge.ts new file mode 100644 index 0000000..46289a4 --- /dev/null +++ b/src/components/shape/gauge.ts @@ -0,0 +1,405 @@ +import { select } from 'd3'; +import * as d3 from 'd3'; +import { get, isFunction } from 'lodash-es'; + +import { measureText } from '../../strategy/utils.js'; +import { Data, GaugeShapeOption, PieShapeOption } from '../../types/index.js'; +import { + createSvg, + getChartColor, + PolarShapeType, + template, +} from '../../utils/index.js'; + +import { getRadius } from './pie.js'; + +import { PolarShape } from './index.js'; + +const START_ANGLE = -(Math.PI / 1.5); +const END_ANGLE = Math.PI / 1.5; + +/** + * Gauge + */ +export default class Gauge extends PolarShape { + override type = PolarShapeType.Gauge; + + pieGuide!: d3.Selection; + pieDescription!: d3.Selection; + + svgEl: d3.Selection; + + data = this.getData(); + + get nullData() { + return this.data.every(d => !d.value); + } + + get colorVar() { + return this.ctrl.getTheme().colorVar; + } + + get total() { + return this.getData().reduce((pre, cur) => (cur.value || 0) + pre, 0); + } + + get max() { + return Math.max(this.option?.max || 100, this.total); + } + + startAngle = -(Math.PI / 1.5); + endAngle = Math.PI / 1.5; + init() { + // do nothing. + } + + render() { + this.option = get(this.ctrl.getOption(), this.type); + this.svgEl = this.svgEl || createSvg(select(this.ctrl.container)); + this.container = this.container || this.svgEl.append('g'); + this.renderPie(); + requestAnimationFrame(() => { + this.renderText(); + this.renderLabel(); + }); + } + + renderText() { + if (this.option?.text?.show !== false && this.option?.text) { + const { color, size = 12 } = this.option?.text; + const majorTicks = 5; + const scale = d3.scaleLinear().range([0, 1]).domain([0, 100]); + const labelInset = 0; + const ticks = scale.ticks(majorTicks); + const { clientHeight } = this.svgEl.node()!; + const minAngle = (this.startAngle * 180) / Math.PI; + const maxAngle = (this.endAngle * 360) / Math.PI; + const spacing = 2; + const r = clientHeight / 2 + spacing; + const lg = this.container + .append('g') + .attr('class', 'label') + .attr('transform', `translate(${0},${0})`); + lg.selectAll('text') + .data(ticks) + .enter() + .append('text') + .attr('font-size', size) + .attr('transform', function (d, i, v) { + const ratio = scale(d); + const { width } = measureText(String(d)); + const newAngle = minAngle + ratio * maxAngle; + const angle = v.length - 1 === i ? newAngle - (width - 4) : newAngle; + return 'rotate(' + angle + ') translate(0,' + (labelInset - r) + ')'; + }) + .attr('fill', (value: number) => { + if (isFunction(color)) { + return color(value); + } + return color || this.colorVar['n-4']; + }) + .text(v => v); + } + } + + renderPie() { + const { clientWidth, clientHeight } = this.svgEl.node()!; + const radius = Math.min(clientWidth, clientHeight) / 2; + if (!clientWidth && !clientHeight) { + return; + } + const colors = this?.option?.colors + ?.sort((a, b) => b[0] - a[0]) + ?.reduce((pre, cur) => { + const value = + Math.max(this.max - cur[0], 0) - pre.reduce((p, c) => p + c[0], 0); + return [...pre, [parseFloat(value.toFixed(2)), cur[1]]]; + }, []); + + const data = + colors?.reverse()?.map(([value, color]) => ({ + name: color, + color, + value, + })) || []; + + const colorPaths = data.length + ? calculatePaths( + data, + { + ...(this.option as any), + startAngle: this.startAngle, + endAngle: this.endAngle, + itemStyle: { + borderRadius: 0, + borderWidth: 0, + }, + innerRadius: 0.95, + outerRadius: this.option?.outerRadius ?? radius, + backgroundColor: this.colorVar['n-8'], + }, + this.colorVar['n-8'], + ) + : []; + const outerRadius = this.option?.outerRadius ?? radius; + const innerRadius = this.option?.innerRadius || 0.85; + // const r = (END_ANGLE * 180) / Math.PI; + // const padding = 8; + // const padding = (clientHeight - r) / 2; + const values = this.ctrl.getData().map(item => ({ + ...item, + color: + this.handlePieColor( + item.value, + this?.option?.colors?.sort((a, b) => b[0] - a[0]) || [], + ) || item.color, + })); + const valuePaths = calculatePaths( + values as any, + { + ...(this.option as any), + total: this.option?.max || 100, + startAngle: START_ANGLE, + endAngle: END_ANGLE, + itemStyle: { + borderRadius: 0, + borderWidth: 0, + }, + innerRadius, + outerRadius: outerRadius - radius * 0.08, + backgroundColor: this.colorVar['n-8'], + }, + this.colorVar['n-8'], + 0.01, + ); + this.container + .attr('transform', `translate(${clientWidth / 2},${0})`) + .selectAll('path') + .data([...colorPaths, ...valuePaths]) + .join('path') + .attr('fill', e => e.config.color) + .attr('d', e => e.path); + requestAnimationFrame(() => { + const { height } = this.container.node().getBBox(); + const ww = Math.min(clientWidth, clientHeight); + const cH = ww < height ? 0 : (ww - height) / 2; + this.container.attr( + 'transform', + `translate(${clientWidth / 2},${height - cH})`, + ); + }); + } + + handlePieColor(value: number, colors: Array<[number, string]>) { + for (const item of colors) { + if (value >= item[0] && item[0] !== null) { + return item[1]; + } + } + return ''; + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + renderLabel() { + if (this.option?.label) { + const { colors } = this.option; + const { text, description, position, textStyle, descriptionStyle } = + this.option?.label; + const isColors = !!colors?.length; + + if (!this.pieGuide) { + this.pieGuide = this.svgEl.append('text'); + } + if (!this.pieDescription) { + this.pieDescription = this.svgEl.append('text'); + } + + const { clientWidth, clientHeight } = this.svgEl.node()!; + const { height } = this.container.node().getBBox(); + const ww = Math.min(clientWidth, clientHeight); + const cH = ww < height ? 0 : (ww - height) / 2; + const textColor = + textStyle?.color || this.ctrl.getTheme().gauge.textColor; + const descriptionColor = + descriptionStyle?.color || this.ctrl.getTheme().gauge.descriptionColor; + if (description) { + const centerDesc = this.pieDescription + .attr('class', 'centerText') + .attr('text-anchor', 'middle') + .attr('x', clientWidth / 2 + (position?.x || 0)) + .attr('y', ww - cH + (position?.y || 0) + cH / (isColors ? 1.2 : 5)) + .attr('stroke', descriptionColor) + .attr('fill', descriptionColor); + const fontSize = Math.min(clientWidth, clientHeight) / 15; + const data = this.getData(); + const str = isFunction(description) + ? (description as (data: Data) => string)(data) + : template(description, { data }); + centerDesc.style('font-size', fontSize + 'px') + .text(this.truncateText(str, fontSize, clientWidth * 0.8)); + } + if (text) { + const centerText = this.pieGuide + .attr('class', 'centerText') + .attr('text-anchor', 'middle') + .attr('x', clientWidth / 2 + (position?.x || 0)) + .attr('y', ww - cH + (position?.y || 0) + (isColors ? 0 : -(cH / 2))) + .attr('stroke', textColor) + .attr('fill', textColor); + const fontSize = Math.min(clientWidth, clientHeight) / 8; + const data = this.getData(); + const str = isFunction(text) + ? text(data, this.total) + : template(text, { value: this.total, data }) || text; + centerText.style('font-size', fontSize + 'px') + .text(this.truncateText(str, fontSize, clientWidth * 0.8)); + } + } + } + + /** + * 截断文本并添加省略号 + * @param text 原始文本 + * @param fontSize 字体大小 + * @param maxWidth 最大宽度 + * @returns 处理后的文本 + */ + truncateText(text: string, fontSize: number, maxWidth: number): string { + if (!text) return ''; + + const { width } = measureText(text, fontSize); + if (width <= maxWidth) return text; + + // 如果文本宽度超过最大宽度,则进行截断 + const ellipsis = '...'; + const ellipsisWidth = measureText(ellipsis, fontSize).width; + let truncatedText = text; + let truncatedWidth = width; + + // 逐个字符截断直到文本宽度小于最大宽度 + while (truncatedWidth > maxWidth - ellipsisWidth && truncatedText.length > 0) { + truncatedText = truncatedText.slice(0, -1); + truncatedWidth = measureText(truncatedText, fontSize).width; + } + + return truncatedText + ellipsis; + } + + override redraw() { + const { textStyle, descriptionStyle } = this.option?.label || {}; + if (this.pieDescription) { + const descriptionColor = + descriptionStyle?.color || this.ctrl.getTheme().gauge.descriptionColor; + this.pieDescription + .attr('stroke', descriptionColor) + .attr('fill', descriptionColor); + } + + if (this.pieGuide) { + const textColor = + textStyle?.color || this.ctrl.getTheme().gauge.textColor; + this.pieGuide.attr('stroke', textColor).attr('fill', textColor); + } + } +} + +function calculatePaths( + data: Array<{ color: string; value: number; values?: any }>, + option: PieShapeOption, + color: string, + angleMin?: number, +) { + const sum = data.reduce((acc, curr) => acc + curr.value, 0); + const total = Math.max(option.total ?? sum, sum); + const startAngle = (option.startAngle || 0) % (2 * Math.PI); + const endAngle = (option.endAngle || 0) % (2 * Math.PI) || 2 * Math.PI; + const diffAngle = + endAngle < startAngle + ? ((endAngle - startAngle) % (2 * Math.PI)) + 2 * Math.PI + : endAngle - startAngle; + const angles = data.map( + data => + Math.max( + (data.value / total) * diffAngle, + data.value === 0 ? 0 : angleMin || 0, + ), + 5, + ); + const { outerRadius, innerRadius } = getRadius(option); + + const { borderRadius = 2, borderWidth = 0 } = option?.itemStyle || {}; + const arc = d3 + .arc() + .cornerRadius(borderRadius) + .padAngle((borderWidth * Math.PI) / 180); + + let accumulate = startAngle; + const baseConifg: Partial = { + innerRadius, + outerRadius, + borderRadius, + borderWidth, + }; + const innerDisc = option.innerDisc + ? [ + { + path: arc({ + innerRadius, + outerRadius: innerRadius - 4, + startAngle, + endAngle, + })!, + config: { + color, + startAngle, + endAngle, + ...baseConifg, + }, + }, + ] + : []; + return angles.reduce( + (acc, curr, ind) => { + const startAngle = accumulate; + const endAngle = accumulate + angles[ind]; + const result = [ + ...acc, + { + path: arc({ + innerRadius, + outerRadius, + startAngle, + endAngle, + })!, + config: { + color: data[ind].color || getChartColor(ind)!, + startAngle, + endAngle, + ...baseConifg, + }, + data: data[ind], + }, + ]; + accumulate += curr; + return result; + }, + [ + { + path: arc({ + innerRadius, + outerRadius, + startAngle: option.startAngle || startAngle, + endAngle: option.endAngle || endAngle, + })!, + config: { + color: option.backgroundColor || 'transparent', + ...baseConifg, + startAngle: option.startAngle || startAngle, + endAngle: option.endAngle || endAngle, + }, + data: null, + }, + ...innerDisc, + ], + ); +} diff --git a/src/components/shape/index.ts b/src/components/shape/index.ts new file mode 100644 index 0000000..33348ce --- /dev/null +++ b/src/components/shape/index.ts @@ -0,0 +1,90 @@ +import { View } from '../../chart/view.js'; +import { ShapeOptions } from '../../types/index.js'; + +export type ShapeCtor = new (view: View, opt: unknown) => Shape | PolarShape; + +export abstract class Shape { + readonly type: string; + + protected option: ShapeOptions; + + /** 是否连接空值 */ + connectNulls = false; + + // 映射值 + mapName: string; + + ctrl: View; + + abstract map(name: string): T; + + abstract getSeries(): any; + + constructor(ctrl: View, opt: ShapeOptions = {}) { + this.ctrl = ctrl; + const { connectNulls = false } = opt; + this.connectNulls = connectNulls; + this.option = opt; + } + + getData() { + const data = this.ctrl.getData(); + const values = this.mapName + ? data.filter(d => + d.id ? d.id === this.mapName : d.name === this.mapName, + ) + : data; + return values; + } + + getBaseSeries() { + const { points } = this.option; + return { + spanGaps: this.connectNulls, + points: points + ? { show: true, ...(points as uPlot.Series.Points) } + : { show: false }, + }; + } + + getOptions() { + return {}; + } + + destroy() { + this.mapName = ''; + } + // map(name: string) { + // this.mapName = name; + // console.log(this.type, this.option, this.mapName, this); + // } +} + +export abstract class PolarShape { + readonly type: string; + + protected option: T; + + ctrl: View; + + container: d3.Selection; + + abstract init(): void; + + abstract render(): void; + + abstract redraw(): void; + + constructor(ctrl: View, opt = {}) { + this.ctrl = ctrl; + this.option = opt as T; + } + + getData() { + return this.ctrl.getData(); + } + + destroy() { + this.container?.remove(); + } +} diff --git a/src/components/shape/line.ts b/src/components/shape/line.ts new file mode 100644 index 0000000..1c733c6 --- /dev/null +++ b/src/components/shape/line.ts @@ -0,0 +1,61 @@ +import { get } from 'lodash-es'; + +import { View } from '../../chart/view.js'; +import { getSeriesPathType } from '../../strategy/utils.js'; +import { LineShapeOption, ShapeOptions } from '../../types/options.js'; +import { ShapeType } from '../../utils/index.js'; + +import { Shape } from './index.js'; + +export type StepType = 'start' | 'end'; +/** + * Line 折线图 + */ +export default class Line extends Shape { + override type = ShapeType.Line; + + private stepType: StepType; + + map(name: string) { + this.mapName = name; + return this; + } + + step(type: 'start' | 'end') { + this.stepType = type; + } + + constructor(ctrl: View, opt: ShapeOptions = {}) { + super(ctrl, opt); + this.ctrl.setShape(this.type, this); + const option: LineShapeOption = get(this.ctrl.getOption(), this.type); + if (typeof option === 'object' && option.step) { + this.stepType = option.step; + } + } + + getSeries() { + const baseSeries = this.getBaseSeries(); + return this.getData().map(({ color, name }) => { + return { + stroke: color, + label: name, + spanGaps: this.connectNulls, + ...getSeriesPathType(this.type, color, this.option, this.stepType), + ...this.option, + ...(baseSeries.points && { + points: { + ...baseSeries.points, + fill: color, + space: 0, + }, + }), + }; + }); + } + + override destroy() { + this.stepType = null; + super.destroy(); + } +} diff --git a/src/components/shape/pie.ts b/src/components/shape/pie.ts new file mode 100644 index 0000000..b68b2ca --- /dev/null +++ b/src/components/shape/pie.ts @@ -0,0 +1,516 @@ +import { select } from 'd3'; +import * as d3 from 'd3'; + +import { ChartEvent } from '../../types/index.js'; +import { Data, PieShapeOption } from '../../types/options.js'; +import { + createSvg, + generateName, + getChartColor, + PolarShapeType, + template, +} from '../../utils/index.js'; +import { Tooltip } from '../tooltip.js'; + +import { PolarShape } from './index.js'; +import { get, isFunction, isNumber } from 'lodash-es'; +import { Legend } from '../legend.js'; + +export const DEFAULT_RADIUS_DIFF = 8; +export const ACTIVE_RADIUS_ENLARGE_SIZE = 2; + +interface PieItemConfig { + padAngle: number; + startAngle: number; + endAngle: number; + innerRadius: number; + outerRadius: number; + borderRadius: number; + borderWidth: number; + color: string; +} + +interface PieItemValue { + path: string; + config: PieItemConfig; + data: Data; + selected?: boolean; + polylinePoints?: [number, number][]; +} + +/** + * Pie 饼图 环形图 + */ +export default class Pie extends PolarShape { + override type = PolarShapeType.Pie; + + pieGuide!: d3.Selection; + svgEl: d3.Selection; + + data = this.getData(); + + get nullData() { + return this.data.every(d => !isNumber(d.value)); + } + + get totalValue() { + return this.data.reduce((prev, item) => prev + item.value, 0); + } + + get colorVar() { + return this.ctrl.getTheme().colorVar; + } + + init() { + // do nothing. +} + + render() { + this.ctrl.container.style.display = 'flex'; + this.ctrl.container.style.flexDirection = 'column'; + this.option = get(this.ctrl.getOption(), this.type, {}); + this.svgEl = this.svgEl || createSvg(select(this.ctrl.container)); + this.container = this.container || this.svgEl.append('g'); + const legendRef = this.ctrl.components.get('legend') as Legend; + const { clientHeight } = this.ctrl.container; + this.ctrl.emit(ChartEvent.U_PLOT_READY); + requestAnimationFrame(() => { + const legendEl = this.ctrl.chartContainer.querySelector( + `.${generateName('legend')}`, + ); + const position: string = get( + this.ctrl.getOption().legend, + 'position', + '', + ); + const legendH = position.includes('bottom') ? legendEl?.clientHeight : 0; + const headerH = + this.ctrl.chartContainer.querySelector(`.${generateName('header')}`) + ?.clientHeight || 0; + const height = clientHeight - legendH - headerH; + this.renderPie(height); + this.renderLabel(); + this.ctrl.on(ChartEvent.LEGEND_ITEM_CLICK, () => { + this.data = this.getData().filter( + d => !legendRef.inactivatedSet.has(d.name), + ); + this.renderPie(height); + this.renderLabel(); + }); + }); + } + + calculateLabelLength(data: any) { + let labelText = ''; + if (!this.option?.labelLine?.labels) { + return 0; + } + const percent = +((data.value / this.totalValue || 0) * 100).toFixed(2); + if (this.option.labelLine.labels.includes('name')) { + labelText += data.name; + } + if (this.option.labelLine.labels.includes('value')) { + labelText += `${labelText ? ': ' : ''}${data.value}`; + } + if (this.option.labelLine.labels.includes('percent')) { + labelText += ` ${percent}%`; + } + const formatter = this.option.labelLine.formatter; + if (formatter) { + labelText = isFunction(formatter) + ? formatter(data.name, data.value, percent) + : template(formatter, { + name: data.name, + value: data.value, + percent, + }); + } + return labelText.length; + } + + renderPie(clientHeight: number) { + const { clientWidth } = this.svgEl.node()!; + let radius = + Math.min(clientWidth, clientHeight) / 2 - ACTIVE_RADIUS_ENLARGE_SIZE; + + // 动态调整半径以确保引导线和标签不会超出屏幕 + const maxLabelLength = this.calculateLabelLength(this.data); + const labelSpace = Math.min(maxLabelLength * 8, clientWidth / 2); // 假设每个字符占8个像素,可以根据实际情况调整 + const maxRadius = Math.min( + clientWidth / 2 - labelSpace, + clientHeight / 2 - (this.option?.labelLine?.labels?.length ? clientHeight / 5: 10), + ); // 动态调整高度 + radius = Math.min(radius, maxRadius); + + const paths = calculatePaths( + this.data, + { + ...this.option, + outerRadius: radius, + backgroundColor: this.nullData + ? this.colorVar['n-8'] + : this.option?.backgroundColor || this.colorVar['n-8'], + }, + this.colorVar['n-8'], + ); + + this.container.selectAll('path').remove(); + this.container + .attr('transform', `translate(${clientWidth / 2},${clientHeight / 2})`) + .selectAll('path') + .data(paths) + .join('path') + .attr('fill', e => e.config.color) + .attr('d', e => e.path); + + this.addListener(); + this.renderGuidelines(paths); + } + + renderGuidelines(paths: any) { + this.container.selectAll('.guideline-group').remove(); + if ( + !this.option?.labelLine?.show || + this.nullData || + !this.option?.labelLine?.labels?.length + ) { + return; + } + const guidelineGroup = this.container + .selectAll('.guideline-group') + .data([null]); + guidelineGroup.enter().append('g').attr('class', 'guideline-group'); + const guidelines = this.container + .select('.guideline-group') + .selectAll('.guideline') + .data(paths.filter((d: any) => d.data)); + + const arcGenerator = d3.arc(); + guidelines + .join('path') + .attr('class', 'guideline') + .attr('fill', 'none') + .attr('stroke', (d: any) => d.config.color) + .attr('d', (d: any) => { + const [x, y] = arcGenerator.centroid({ + innerRadius: d.config.outerRadius, + outerRadius: d.config.outerRadius, + startAngle: d.config.startAngle, + endAngle: d.config.endAngle, + }); + const labelX = x * 1.1; + const labelY = y * 1.1; + const endX = labelX + (labelX > 0 ? 20 : -20); + + return `M${x},${y}L${labelX},${labelY}L${endX},${labelY}`; + }); + + // 添加文本标签 + const labels = this.container + .select('.guideline-group') + .selectAll('.label') + .data(paths.filter((d: any) => d.data)); + + labels + .join('text') + .attr('class', 'label') + .attr('x', (d: any) => { + const [x] = arcGenerator.centroid({ + innerRadius: d.config.outerRadius, + outerRadius: d.config.outerRadius, + startAngle: d.config.startAngle, + endAngle: d.config.endAngle, + }); + return x * 1.1 + (x > 0 ? 20 : -20); + }) + .attr('dy', '0.35em') // 垂直居中对齐 + .attr('y', (d: any) => { + const [, y] = arcGenerator.centroid({ + innerRadius: d.config.outerRadius, + outerRadius: d.config.outerRadius, + startAngle: d.config.startAngle, + endAngle: d.config.endAngle, + }); + const yOffset = 0; // 定义一个偏移量,根据需要调整 + return y * 1.1 - yOffset; // 将标签向上移动以放置在引导线的垂直居中上方 + }) + .attr('text-anchor', (d: any) => { + const [x] = arcGenerator.centroid({ + innerRadius: d.config.outerRadius, + outerRadius: d.config.outerRadius, + startAngle: d.config.startAngle, + endAngle: d.config.endAngle, + }); + return x > 0 ? 'start' : 'end'; + }) + .text((d: any) => { + let labelText = ''; + const percent = +((d.data.value / this.totalValue || 0) * 100).toFixed( + 2, + ); + if (this.option.labelLine.labels.includes('name')) { + labelText += d.data.name; + } + if (this.option.labelLine.labels.includes('value')) { + labelText += `${labelText ? ': ' : ''}${d.data.value}`; + } + if (this.option.labelLine.labels.includes('percent')) { + labelText += ` ${percent}%`; + } + const formatter = this.option.labelLine.formatter; + if (formatter) { + labelText = isFunction(formatter) + ? formatter(d.data.name, d.data.value, percent) + : template(formatter, { + name: d.data.name, + value: d.data.value, + percent, + }); + } + return labelText; + }); + } + + onMousemove(res: { self: unknown; data: PieItemValue; event: MouseEvent }) { + const item = res.data; + const path = getPath({ + ...item.config, + padAngle: item.config.padAngle, + innerRadius: item.config.innerRadius, + outerRadius: item.config.outerRadius + ACTIVE_RADIUS_ENLARGE_SIZE, + borderWidth: item.config.borderWidth - ACTIVE_RADIUS_ENLARGE_SIZE, + }); + d3.select(res.self as any) + .attr('opacity', 0.9) + .transition() + .attr('d', path); + } + + onMouseleave(res: { self: unknown; data: PieItemValue; event: MouseEvent }) { + const item = res.data; + d3.select(res.self as any) + .attr('opacity', 1) + .transition() + .attr('d', getPath(item.config)); + } + + addListener() { + const pieItems = ( + this.container.selectAll('path') as d3.Selection< + any, + { + path: string; + config: PieItemConfig; + data: Data; + }, + any, + any + > + ).filter(function (e) { + return !!e.data; + }); + const ctrl = this.ctrl; + pieItems + .on('mouseover', function (event: MouseEvent, data) { + ctrl.emit(ChartEvent.ELEMENT_MOUSEMOVE, { + self: this as unknown, + event, + data, + }); + if (!ctrl.hideTooltip) { + ctrl.emit(ChartEvent.U_PLOT_SET_CURSOR, { + anchor: event.target, + values: [data.data], + }); + (ctrl.components.get('tooltip') as Tooltip).showTooltip(); + } + }) + .on('mouseout', function (event: MouseEvent, data) { + ctrl.emit(ChartEvent.ELEMENT_MOUSELEAVE, { + self: this as unknown, + event, + data, + }); + if (!ctrl.hideTooltip) { + (ctrl.components.get('tooltip') as Tooltip).hideTooltip(); + } + }); + } + + renderLabel() { + if (this.option.label) { + const { x = 0, y = 0 } = this.option.label.position || {}; + if (!this.pieGuide) { + this.pieGuide = select(this.ctrl.container) + .append('div') + .style('position', 'absolute'); + } + if (this.option.label.text) { + this.pieGuide.html(this.option.label.text); + } + this.pieGuide + .style('top', `calc(50% + ${x}px`) + .style('left', `calc(50% + ${y}px`) + .style('transform', 'translate(-50%, -50%)'); + } + } + + redraw() {} +} + +export function getPath(config: { + padAngle: number; + startAngle: number; + endAngle: number; + innerRadius: number; + outerRadius: number; + borderRadius: number; + borderWidth: number; + color: string; +}) { + const arc = d3 + .arc() + .cornerRadius(config.borderRadius) + .padAngle(config.padAngle || (config.borderWidth * Math.PI) / 180); + return arc({ + innerRadius: config.innerRadius, + outerRadius: config.outerRadius, + startAngle: config.startAngle, + endAngle: config.endAngle, + }); +} + +export function calculatePaths( + data: Data, + option: PieShapeOption, + color: string, +) { + const rawTotal = data.reduce((acc, curr) => acc + curr.value, 0); + const total = Math.max(option.total || 0, rawTotal); + const startAngle = (option.startAngle || 0) % (2 * Math.PI); + const endAngle = (option.endAngle || 0) % (2 * Math.PI) || 2 * Math.PI; + const diffAngle = + endAngle < startAngle + ? ((endAngle - startAngle) % (2 * Math.PI)) + 2 * Math.PI + : endAngle - startAngle; + const angles = data.map(data => (data.value / total || 0) * diffAngle); + + const { outerRadius, innerRadius } = getRadius({ + ...option, + innerRadius: + data.length === 1 && !option.innerRadius + ? 0 + : option.innerRadius || option.padAngle - 0.01, + }); + + const { borderRadius = 2, borderWidth = 0 } = option?.itemStyle || {}; + const arc = d3 + .arc() + .cornerRadius(borderRadius) + .padAngle(data.length === 1 ? 0 : option.padAngle || 0); + + let accumulate = startAngle; + const baseConfig: Partial = { + padAngle: data.length === 1 || !data?.length ? 0 : option.padAngle || 0, + innerRadius, + outerRadius, + borderRadius, + borderWidth, + }; + const padding = 14; + const innerDisc = option.innerDisc + ? [ + { + path: arc({ + innerRadius: innerRadius - padding, + outerRadius: innerRadius - padding - 4, + startAngle, + endAngle, + })!, + config: { + color, + startAngle, + endAngle, + ...baseConfig, + }, + }, + ] + : []; + return angles.reduce( + (acc, curr, ind) => { + const startAngle = accumulate; + const endAngle = accumulate + angles[ind]; + + const midAngle = (startAngle + endAngle) / 2; + const x1 = Math.cos(midAngle) * outerRadius; + const y1 = Math.sin(midAngle) * outerRadius; + const x2 = Math.cos(midAngle) * (outerRadius + 10); + const y2 = Math.sin(midAngle) * (outerRadius + 10); + const x3 = x2 + (midAngle < Math.PI ? 1 : -1) * 50; + + const polylinePoints = [ + [x1, y1], + [x2, y2], + [x3, y2], + ]; + + const result = [ + ...acc, + { + path: arc({ + innerRadius, + outerRadius, + startAngle, + endAngle, + })!, + config: { + color: data[ind].color || getChartColor(ind)!, + startAngle, + endAngle, + ...baseConfig, + }, + data: data[ind], + polylinePoints, // Add polyline points + }, + ]; + accumulate += curr; + return result; + }, + [ + { + path: arc({ + innerRadius: 0, + outerRadius, + startAngle: option.startAngle || startAngle, + endAngle: option.endAngle || endAngle, + })!, + config: { + color: option.backgroundColor || 'transparent', + ...baseConfig, + startAngle: option.startAngle || startAngle, + endAngle: option.endAngle || endAngle, + }, + data: null, + }, + ...innerDisc, + ], + ); +} + +export function getRadius(option: PieShapeOption) { + let outerRadius = option.outerRadius; + let innerRadius = outerRadius * option.innerRadius || 0; + if (!outerRadius && !innerRadius) { + throw new Error('Either outerRadius or innerRadius is required!'); + } + if (!innerRadius && innerRadius !== 0) { + innerRadius = outerRadius - DEFAULT_RADIUS_DIFF; + } + if (!outerRadius) { + outerRadius = innerRadius + DEFAULT_RADIUS_DIFF; + } + return { + outerRadius: outerRadius, + innerRadius, + }; +} diff --git a/src/components/shape/point.ts b/src/components/shape/point.ts new file mode 100644 index 0000000..293dae4 --- /dev/null +++ b/src/components/shape/point.ts @@ -0,0 +1,292 @@ +import { get, isFunction, merge } from 'lodash-es'; +import uPlot from 'uplot'; + +import { UPLOT_DEFAULT_OPTIONS } from '../../strategy/config.js'; +import { UPlotViewStrategy } from '../../strategy/index.js'; +import { pointWithin, Quadtree } from '../../strategy/quadtree.js'; +import { Nilable, ShapeOptions } from '../../types/index.js'; +import { convertRgba, ShapeType } from '../../utils/index.js'; + +import { Shape } from './index.js'; +import { View } from '../../chart/view.js'; + +export type SizeCallback = (...args: unknown[]) => number; + +const MAX_SIZE = 1000; + +interface CustomSeries extends uPlot.Series { + fill: () => string; + stroke: () => string; +} + +/** + * Point 点图 + */ +export default class Point extends Shape { + override type = ShapeType.Point; + + private pointSize = 5; + + private sizeField = 'size'; + + private readonly maxPointSize = MAX_SIZE; + + private sizeRange: [number, number] = [this.pointSize, this.maxPointSize]; + + private sizeCallback: Nilable; + + private hRect: Quadtree; + + private get strategy() { + return this.ctrl.strategyManage.getStrategy('uPlot') as UPlotViewStrategy; + } + + private get qt() { + return this.strategy.qt; + } + + map(name: string) { + this.mapName = name; + return this; + } + + /** + * 设置 point 大小 + * @param field size 映射的数据字段 或者是大小 + * @param cfg [最大值,最小值] 或者 回调 + * @returns + */ + size( + field: number | string, + options?: [number, number] | SizeCallback, + ): Point { + if (typeof field === 'number') { + this.pointSize = field; + } + if (typeof field === 'string') { + this.sizeField = field; + } + + if (Array.isArray(options)) { + this.sizeRange = options; + } + + if (isFunction(options)) { + this.sizeCallback = options; + } + + return this; + } + + constructor(ctrl: View, opt: ShapeOptions = {}) { + super(ctrl, opt); + this.ctrl.setShape(this.type, this); + } + + getSeries() { + const baseSeries = this.getBaseSeries(); + return this.getData().map(({ color, name }) => { + return { + stroke: color, + label: name, + paths: makeDrawPoints({ + disp: { + size: { + unit: 3, // raw CSS pixels + values: (_, seriesIdx: number) => { + const chartData = this.ctrl.getData(); + const data = chartData[seriesIdx - 1]?.values; + return data?.map(d => { + const field: number = + get(d, this.sizeField) || this.pointSize; + const [min, max] = this.sizeRange; + let value = field; + if (field < min) { + value = min; + } else if (field > max) { + value = max; + } + if (d.y === null) { + return 0; + } + return this.sizeCallback ? this.sizeCallback(value) : value; + }); + }, + }, + }, + each: ( + u: uPlot, + seriesIdx: number, + dataIdx: number, + lft: number, + top: number, + wid: number, + hgt: number, + ) => { + // we get back raw canvas coords (included axes & padding). translate to the plotting area origin + lft -= u.bbox.left; + top -= u.bbox.top; + this.qt.add({ + x: lft, + y: top, + w: wid, + h: hgt, + sidx: seriesIdx, + didx: dataIdx, + }); + }, + }), + points: { + show: false, + }, + // ...getSeriesPathType(this.type, color), + ...baseSeries, + fill: convertRgba(color, 0.3), + }; + }); + } + + override getOptions() { + return { + cursor: merge(UPLOT_DEFAULT_OPTIONS.cursor, this.getCursorOption()), + }; + } + + private readonly getCursorOption = (): uPlot.Cursor => { + return { + x: false, + dataIdx: (u: uPlot, seriesIdx: number) => { + if (seriesIdx === 1) { + this.hRect = null; + + let dist = Infinity; + const cx = u.cursor.left * devicePixelRatio; + const cy = u.cursor.top * devicePixelRatio; + + this.qt.getQ(cx, cy, 1, 1, o => { + if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { + const ocx = o.x + o.w / 2; + const ocy = o.y + o.h / 2; + + const dx = ocx - cx; + const dy = ocy - cy; + + const d = Math.sqrt(dx ** 2 + dy ** 2); + + if (d <= o.w / 2 && d <= dist) { + dist = d; + this.hRect = o; + } + } + }); + } + return this.hRect && seriesIdx === this.hRect.sidx + ? this.hRect.didx + : null; + }, + points: { + fill: 'transparent', + width: 1.5, + size: (_u, seriesIdx) => { + return this.hRect && seriesIdx === this.hRect.sidx + ? this.hRect.w / devicePixelRatio + : 0; + }, + }, + }; + }; +} + +function makeDrawPoints( + opts: uPlot.Series.BarsPathBuilderOpts, + maxSize = MAX_SIZE, +) { + const { + disp, + each = () => { + // default empty fn + }, + } = opts; + + return (u: uPlot, seriesIdx: number, idx0: number, idx1: number): any => { + uPlot.orient( + u, + seriesIdx, + ( + series: CustomSeries, + _dataX, + _dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + yDim, + ) => { + const d = u.data; + const strokeWidth = 1; + u.ctx.save(); + + u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + u.ctx.fillStyle = series.fill(); + u.ctx.strokeStyle = series.stroke(); + u.ctx.lineWidth = strokeWidth; + + const deg360 = 2 * Math.PI; + + // compute bubble dims + const sizes = disp.size.values(u, seriesIdx, idx0, idx1); + + // todo: this depends on direction & orientation + // todo: calc once per redraw, not per path + const filtLft = u.posToVal(-maxSize / 2, scaleX.key); + const filtRgt = u.posToVal( + u.bbox.width / devicePixelRatio + maxSize / 2, + scaleX.key, + ); + const filtBtm = u.posToVal( + u.bbox.height / devicePixelRatio + maxSize / 2, + scaleY.key, + ); + const filtTop = u.posToVal(-maxSize / 2, scaleY.key); + for (let i = 0; i < d[0].length; i++) { + const xVal = d[0][i]; + const yVal = d[seriesIdx][i]; + const size = (sizes[i] as number) * devicePixelRatio; + if ( + xVal >= filtLft && + xVal <= filtRgt && + yVal >= filtBtm && + yVal <= filtTop + ) { + const cx = valToPosX(xVal, scaleX, xDim, xOff); + const cy = valToPosY(yVal, scaleY, yDim, yOff); + + u.ctx.moveTo(cx + size / 2, cy); + u.ctx.beginPath(); + u.ctx.arc(cx, cy, size / 2, 0, deg360); + u.ctx.fill(); + u.ctx.stroke(); + + each( + u, + seriesIdx, + i, + cx - size / 2 - strokeWidth / 2, + cy - size / 2 - strokeWidth / 2, + size + strokeWidth, + size + strokeWidth, + ); + } + } + + u.ctx.restore(); + }, + ); + + return null; + }; +} diff --git a/src/components/styles.ts b/src/components/styles.ts new file mode 100644 index 0000000..0a2bb5d --- /dev/null +++ b/src/components/styles.ts @@ -0,0 +1,21 @@ +import { StyleSheet } from 'aphrodite/no-important.js'; + +export const symbolStyle = StyleSheet.create({ + symbol: { + display: 'inline-block', + marginRight: 4, + }, + line: { + width: 12, + height: 2, + }, + square: { + width: 12, + height: 12, + }, + circle: { + width: 12, + height: 12, + borderRadius: '50%', + }, +}); diff --git a/src/components/title.ts b/src/components/title.ts index 3f2eee1..5c40ab8 100644 --- a/src/components/title.ts +++ b/src/components/title.ts @@ -1,86 +1,70 @@ -import { UIController } from '../abstract/index.js'; -import { View } from '../chart/index.js'; -import { CLASS_NAME } from '../constant.js'; -import { D3Selection, TitleOption } from '../types/index.js'; -import { template } from '../utils/index.js'; +import { StyleSheet, css } from 'aphrodite/no-important.js'; +import { get } from 'lodash-es'; -const OFFSET = { - x: 0, - y: 20, -}; +import { TitleOption } from '../types/index.js'; +import { generateName, template } from '../utils/index.js'; -export class Title extends UIController { - container: D3Selection; +import { BaseComponent } from './base.js'; +import { Header } from './header.js'; - get hasTpl() { - return ( - typeof this.option.formatter === 'function' || - this.owner.options.customHeader - ); - } +const styles = StyleSheet.create({ + title: { + wordBreak: 'break-all', + width: '100%', + }, +}); - get name() { +export class Title extends BaseComponent { + get name(): string { return 'title'; } - constructor(view: View) { - super(view); - this.option = view.options.title || {}; - } - - init() { - if (this.option.text && !this.option.hide) { - this.createContainer(); - } - } + headerContainer: HTMLElement; render() { - this.update(this.option); + const opt = this.ctrl.getOption(); + this.option = get(opt, this.name); + if (!this.headerContainer) { + this.createTitle(); + } else { + this.update(); + } } - update(option?: TitleOption) { - this.option = option || this.option; - const { formatter, hide } = this.option; - this.createContainer(); - if (this.container && !hide) { - const { offsetX, offsetY, text } = this.option; - const y = offsetY || 0; - const x = this.owner.basics.padding.left + (offsetX || 0); - if (typeof formatter === 'function') { - if (this.hasTpl) { - this.container.attr( - 'style', - `padding-top: ${y}px; padding-left: ${x}px`, - ); - } - this.container.html(formatter(text)); - return; - } - this.container.selectAll('*').remove(); - const textEl = this.container - .append('text') - .attr('class', CLASS_NAME.titleText); - textEl.attr('transform', `translate(${x}, ${y})`); - textEl - .append('tspan') - .attr('x', OFFSET.x) - .attr('dy', OFFSET.y) - .text(template(formatter, { text }) || text); + createTitle() { + if (typeof this.option === 'object') { + this.container = document.createElement('div'); + this.container.style.wordBreak = 'break-all'; + this.container.style.flex = '1'; + this.container.className = `${generateName('title')} ${css( + styles.title, + )}`; + this.update(); + const header = new Header(this.ctrl); + header.container.append(this.container); + this.headerContainer = header.container; } } - private createContainer() { - if (this.container) { - return; + update() { + this.option = get(this.ctrl.getOption(), this.name); + if (this.container && !get(this.option, 'custom')) { + this.container.innerHTML = this.getTitleValue(); } - const title = this.hasTpl - ? (this.owner.chartEle.header || this.owner.chartEle.chart).append('div') - : this.owner.chartEle.svg.append('g'); - title.attr('class', CLASS_NAME.title); - this.container = title; } - destroy() { - this.container?.remove(); + /** + * 获取标题数据 + */ + private getTitleValue(): string { + if (typeof this.option === 'object') { + const { text, formatter } = this.option; + return ( + (typeof formatter === 'function' + ? formatter(text) + : template(formatter, { text })) || text + ); + } + return ''; } } diff --git a/src/components/tooltip.ts b/src/components/tooltip.ts index 11bf5d0..e1707e2 100644 --- a/src/components/tooltip.ts +++ b/src/components/tooltip.ts @@ -1,304 +1,234 @@ -import { isFunction, noop } from 'lodash'; - -import { UIController } from '../abstract/index.js'; -import { View } from '../chart/index.js'; -import { CLASS_NAME } from '../constant.js'; +import { StyleSheet, css } from 'aphrodite/no-important.js'; import { - AxisTooltipStrategy, - ItemTooltipStrategy, - NoneTooltipStrategy, - TooltipStrategy, -} from '../service/index.js'; + get, + isArray, + isElement, + isFunction, + isNil, + isString, +} from 'lodash-es'; +import placement from 'placement.js'; + import { - ChartData, - D3Selection, - TooltipContext, - TooltipContextItem, + ChartEvent, + TooltipItemActive, + TooltipOpt, TooltipOption, + TooltipValue, } from '../types/index.js'; -import { isHtml, rgbColor, template } from '../utils/index.js'; - -export class Tooltip extends UIController { - container!: D3Selection; - rectElement!: D3Selection; - - crosshairLine!: D3Selection; - - title!: D3Selection; - - list!: D3Selection; - - activeName: string | null; - - get name() { +import { NOT_AVAILABLE } from '../utils/constant.js'; +import { generateName, template } from '../utils/index.js'; + +import { BaseComponent } from './base.js'; +import { symbolStyle } from './styles.js'; + +const styles = StyleSheet.create({ + overlay: { + position: 'absolute', + visibility: 'hidden', + pointerEvents: 'none', + padding: '12px', + boxShadow: '0 2px 8px #00000029', + margin: 8, + zIndex: 999, + transition: 'transform 0.1s ease-out 0s', + // transition: + // 'top 0.3s cubic-bezier(0.23, 1, 0.32, 1) 0s', + fontSize: 12, + }, + 'tooltip-title': { + marginBottom: 8, + }, + 'tooltip-list': { + listStyle: 'none', + padding: 0, + margin: 0, + }, + 'tooltip-list-item': { + listStyleType: 'none', + padding: '2px 8px', + display: 'flex', + alignItems: 'center', + }, + 'tooltip-marker': { + marginRight: 4, + }, + 'tooltip-name': { + maxWidth: '197px', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + 'tooltip-value': { + marginLeft: 30, + flex: 1, + textAlign: 'right', + whiteSpace: 'nowrap', + }, +}); + +export class Tooltip extends BaseComponent { + get name(): string { return 'tooltip'; } - get isRotated() { - return !!this.owner.isRotated; - } - - strategy: TooltipStrategy; - - constructor(view: View) { - super(view); - this.option = view.options.tooltip || {}; - this.setStrategy(view); - } - - setStrategy(view: View) { - const option = view.options.tooltip; - if (option?.trigger === 'item') { - this.strategy = new ItemTooltipStrategy(view); - } else if (option?.trigger === 'none') { - this.strategy = new NoneTooltipStrategy(view); - } else { - this.strategy = new AxisTooltipStrategy(view); - } - // TODO: 加上自动更新上一次注册的 strategy 配置信息 @zangguodong - } - - mountPaths( - paths: d3.Selection, - panel: D3Selection, - ) { - this.strategy?.registerPaths(paths, panel); - } - - init() { - this.initTooltip(); - this.initCrosshairLine(); - } - render() { - this.resize(); - this.renderTooltipTemplate(); + const opt = this.ctrl.getOption(); + this.option = get(opt, this.name, {}); + this.create(); } - destroy = noop; - update() { - this.resize(); - } - - setActive(name: string | null) { - this.activeName = name; - } - - private initTooltip() { - if (!this.owner.chartEle.tooltip || this.owner.chartEle.tooltip.empty()) { - this.container = this.owner.chartEle.chart - .style('position', 'relative') - .append('div') - .attr('class', CLASS_NAME.tooltip) - .style('position', 'absolute') - .style('pointer-events', 'none') - .style('display', 'none'); - this.owner.chartEle.tooltip = this.container; + this.option = get(this.ctrl.getOption(), this.name); + this.createItem(); + } + + create() { + if (this.option) { + if (!this.container) { + const overlay = document.createElement('div'); + overlay.className = `${generateName('tooltip')} ${css(styles.overlay)}`; + (get(this.option, 'popupContainer') || document.body).append(overlay); + overlay.style.visibility = 'hidden'; + this.container = overlay; + } + this.createItem(); + this.eventListener(); } } - private initCrosshairLine() { - const crosshairContainer = this.owner.chartEle.main - .append('g') - .attr('class', CLASS_NAME.crosshair); - this.crosshairLine = crosshairContainer - .append('line') - .style('pointer-events', 'none'); - } - - resize() { - const { width, height } = this.owner.size.grid; - this.owner.chartEle.main - .selectAll(`.${CLASS_NAME.eventRect}`) - .attr('y', this.owner.headerTotalHeight) - .attr('width', width > 0 ? width : 0) - .attr('height', height > 0 ? height : 0); - } - - updateCrosshairLine(x: number) { - const { width, height } = this.owner.size.main; - const h = this.isRotated ? width : height; - this.crosshairLine - .attr(`${this.isRotated ? 'y1' : 'x1'}`, x) - .attr(`${this.isRotated ? 'y2' : 'x2'}`, x) - .attr(`${this.isRotated ? 'x1' : 'y1'}`, this.owner.headerTotalHeight) - .attr(`${this.isRotated ? 'x2' : 'y2'}`, h) - .attr('stroke', rgbColor('n-5')) - .attr('stroke-dasharray', '3 2'); - } - - setVisibility(show = true) { - if (this.owner.noData && show) { + /** + * 创建 tooltip item + * @param title + * @param values + */ + private createItem(title?: string, values?: TooltipValue[]) { + const itemTpl = this.getTooltipItem(values); + if (!itemTpl) { return; } - this.container.style('display', show ? '' : 'none'); - this.crosshairLine.attr('visibility', show ? 'visible' : 'hidden'); - } - - private renderTooltipTemplate() { - if (!this.option.hideTitle) { - this.title = this.container - .append('div') - .attr('class', CLASS_NAME.tooltipTitle); + const isEl = isElement(itemTpl); + const list = `
    ${ + itemTpl as string + }
`; + this.container.innerHTML = ` + ${this.getTooltipTitle(title, values)} + ${isEl ? '' : list} + `; + if (isEl) { + this.container.remove(); + this.container.append(itemTpl); } - this.list = this.container - .append('ul') - .attr('class', CLASS_NAME.tooltipList); - } - - setTooltipContext( - { - offsetX, - offsetY, - event, - }: { offsetX: number; offsetY: number; event: MouseEvent }, - content: TooltipContext, - ) { - this.setTitle(content); - this.list.html(this.getTooltipItemHtml(content.values)); - // this.container - // .style('display', '') - // .style('left', `${offsetX}px`) - // .style('top', `${offsetY}px`); - - // return - const { width, height } = ( - this.container.node() as HTMLElement - ).getBoundingClientRect(); - const mainW = this.owner.size.main.width; - const windowH = document.documentElement.clientHeight; - const { x: vX = 0, y: vY = 0 } = this.owner.options.offset; - const { margin } = this.owner.basics; - const marginX = margin.left + 20 + vX; - const tipMargin = width / 2 + vX; - const x = this.isRotated ? offsetY + tipMargin + 10 : offsetX + marginX; - const y = this.isRotated - ? offsetX + vY - : offsetY + vY + this.owner.options.grid.top; - - const eventTop = (event.target as HTMLElement).getBoundingClientRect().top; - // 实际tip top 的位置 - const actualY = event.clientY - (event.pageY - eventTop); - // tip 底部的位置 - const tipBottom = actualY + height + (event.pageY - eventTop); - const top = - tipBottom > windowH - ? windowH - - height - - this.owner.chartEle.main.node().getBoundingClientRect().top - : y; - - const left = x + width > mainW ? x - (width + 20) : x; - - this.container - .style('display', '') - .style('left', `${left}px`) - .style('top', `${top}px`); } - private setTitle(context: TooltipContext) { - if (this.option.hideTitle) { - return; + private getTooltipTitle(title: string, values: TooltipValue[]) { + const { showTitle, titleFormatter } = this.option as TooltipOpt; + if (String(showTitle) === 'false' || !title) { + return ''; } - const { titleFormatter } = this.option; - if (!titleFormatter) { - this.title.text(context.title as string); - return; + let tpl: string = title || NOT_AVAILABLE; + if (isString(titleFormatter)) { + tpl = template(titleFormatter, { title }); } if (isFunction(titleFormatter)) { - this.title.html(titleFormatter(context)); - return; + tpl = titleFormatter(title, values); } - this.title.text(template(titleFormatter, context)); + return `
${tpl}
`; } - private getTooltipItemHtml(items?: TooltipContextItem[]) { - if (!items) { + private getTooltipItem(values?: TooltipValue[]): string | Element { + if (!values || !values?.length) { return ''; } - const values = this.option.sort ? items.sort(this.option.sort) : items; - const { itemFormatter } = this.option; + const { nameFormatter, valueFormatter, itemFormatter, sort } = this + .option as TooltipOpt; + let items = sort ? values.sort(sort) : values; if (itemFormatter) { - const data = itemFormatter(values); - return !isHtml(data) && Array.isArray(data) - ? this.generateTooltipItem(data) - : data; + const itemValue = itemFormatter(items); + if (isString(itemValue)) { + return itemValue; + } + if (isElement(itemValue)) { + return (itemValue as HTMLElement).innerHTML; + } + if (isArray(itemValue)) { + items = itemValue; + } } - return this.generateTooltipItem(values); + return items + ?.map(item => { + const value = this.handleTemplateString( + item.value, + valueFormatter, + item, + ); + const name = this.handleTemplateString( + String(item.name), + nameFormatter, + item, + ); + return `
  • + + ${name || NOT_AVAILABLE} + ${isNil(value) ? NOT_AVAILABLE : value} +
  • `; + }) + .join(''); } - getItemValue( - value: number | string, - data: TooltipContextItem, - formatter: string | ((value: TooltipContextItem) => string), + handleTemplateString( + text: string | number, + formatter: string | ((v: string | number, data: TooltipValue) => string), + data?: TooltipValue, ) { - return isFunction(formatter) - ? formatter(data) || value - : template(formatter, data) || value; + let value = text; + if (isString(formatter)) { + value = template(formatter, { value: text }); + } + if (isFunction(formatter)) { + value = formatter(value, data); + } + return value; } - getTooltipContext( - index: number, - xValue: Date | number | string, - ): TooltipContext { - const values: TooltipContextItem[] = - this.owner.isBar && this.owner.isGroup - ? this.owner.chartData[index]?.values.map(d => ({ - ...d, - x: d.x as string, - name: d.x as string, - color: d.color, - activated: d.x === this.activeName, - })) - : this.owner.chartData.reduce( - (acc: TooltipContextItem[], cur: ChartData) => { - const item = cur.values[index]; - return [ - ...acc, - { - ...item, - name: cur.name, - color: cur.color, - activated: cur.name === this.activeName, - }, - ] as TooltipContextItem[]; - }, - [], - ); - return { - title: xValue, - values, - }; - } + showTooltip = () => { + this.container.style.visibility = 'visible'; + this.container.style.background = this.ctrl.getTheme().tooltip.background; + this.container.style.color = this.ctrl.getTheme().tooltip.color; + }; - private readonly generateTooltipItem = (values: TooltipContextItem[]) => { - const { nameFormatter, valueFormatter } = this.option; - const isCircle = ['pie', 'scatter'].includes(this.owner.options.type); - return values - .map( - d => `
  • -
    - - ${this.getItemValue( - d.name, - d, - nameFormatter, - )} -
    -
    - ${this.getItemValue( - d.y, - d, - valueFormatter, - )} -
    -
  • `, - ) - .join(''); + hideTooltip = () => { + if (this.container) { + this.container.style.visibility = 'hidden'; + } }; + + /** + * 添加事件监听 + */ + private eventListener() { + // TODO: 是否纳管到 interaction + this.ctrl.on( + ChartEvent.U_PLOT_SET_CURSOR, + ({ anchor, title, values, position }: TooltipItemActive) => { + if (values?.length) { + // @ts-ignore + placement(anchor, this.container, { + placement: position || 'right', + }); + this.createItem(title, values); + } + }, + ); + } } diff --git a/src/components/uplot-lib/axis.ts b/src/components/uplot-lib/axis.ts new file mode 100644 index 0000000..bf815b8 --- /dev/null +++ b/src/components/uplot-lib/axis.ts @@ -0,0 +1,61 @@ +import uPlot, { Axis, SidesWithAxes } from 'uplot'; + +export function axisAutoSize( + u: uPlot, + values: number[], + axisIdx: number, + cycleNum: number, +) { + const axis: any = u.axes[axisIdx]; + + if (cycleNum > 1) return axis._size; + + let axisSize = axis.ticks.size + axis.gap; + + const longestVal = (values ?? []).reduce( + (acc, val: any) => (val.length > acc.length ? val : acc), + '', + ); + + if (longestVal !== '') { + u.ctx.font = axis.font[0]; + axisSize += u.ctx.measureText(longestVal).width / devicePixelRatio; + } + return Math.ceil(axisSize + 2); +} + +export function autoPadRight(right = 8): uPlot.PaddingSide { + return ( + self: any, + _side: Axis.Side, + _sidesWithAxes: SidesWithAxes, + cycleNum: number, + ) => { + const xAxis = self.axes[0]; + const xVals = xAxis._values; + + if (xVals != null) { + // bail out, force convergence + if (cycleNum > 2) return self._padding[1]; + + const xSplits = xAxis._splits; + const rightSplit = xSplits[xSplits.length - 1]; + const rightSplitCoord = self.valToPos(rightSplit, 'x'); + const leftPlotEdge = self.bbox.left / devicePixelRatio; + const rightPlotEdge = leftPlotEdge + self.bbox.width / devicePixelRatio; + const rightChartEdge = rightPlotEdge + self._padding[1]; + + const pxPerChar = right; + const rightVal = xVals[xVals.length - 1] + ''; + const valHalfWidth = pxPerChar * (rightVal.length / 2); + + const rightValEdge = leftPlotEdge + rightSplitCoord + valHalfWidth; + + if (rightValEdge >= rightChartEdge) { + return rightValEdge - rightPlotEdge; + } + } + + return right; + }; +} diff --git a/src/components/uplot-lib/grouped-bars.ts b/src/components/uplot-lib/grouped-bars.ts new file mode 100644 index 0000000..ae61f5f --- /dev/null +++ b/src/components/uplot-lib/grouped-bars.ts @@ -0,0 +1,511 @@ +import uPlot, { Axis } from 'uplot'; + +import { pointWithin, Quadtree } from '../../strategy/quadtree.js'; + +interface SeriesBarsPluginProps { + time: boolean; + radius?: number; + marginRatio?: number; + ori: number; + dir: number; + stacked?: boolean; + ignore?: number[]; + disp?: any; +} + +const SPACE_BETWEEN = 1; +const SPACE_AROUND = 2; +const SPACE_EVENLY = 3; + +function roundDec(val: number, dec: number) { + return Math.round(val * (dec = 10 ** dec)) / dec; +} + +const coord = (i: number, offs: number, iwid: number, gap: number) => + roundDec(offs + i * (iwid + gap), 6); + +// eslint-disable-next-line sonarjs/cognitive-complexity +export function distr( + numItems: number, + sizeFactor: number, + justify: number, + onlyIdx: number, + each: (index: number, coord: number, id: number) => void, +) { + const space = 1 - sizeFactor; + + let gap = + justify === SPACE_BETWEEN + ? space / (numItems - 1) + : justify === SPACE_AROUND + ? space / numItems + : justify === SPACE_EVENLY + ? space / (numItems + 1) + : 0; + + if (isNaN(gap) || gap === Infinity) gap = 0; + + const offs = + justify === SPACE_BETWEEN + ? 0 + : justify === SPACE_AROUND + ? gap / 2 + : justify === SPACE_EVENLY + ? gap + : 0; + + const iwid = sizeFactor / numItems; + const _iwid = roundDec(iwid, 6); + + if (onlyIdx == null) { + for (let i = 0; i < numItems; i++) + each(i, coord(i, offs, iwid, gap), _iwid); + } else each(onlyIdx, coord(onlyIdx, offs, iwid, gap), _iwid); +} + +export function seriesBarsPlugin(opts: SeriesBarsPluginProps) { + let pxRatio: number; + // let font: string; + + const { + time, + ignore = [], + radius: _radius, + ori: _ori, + dir: _dir, + stacked: _stacked, + marginRatio, + disp, + } = opts; + + const radius = _radius ?? 0; + + // function setPxRatio() { + // pxRatio = devicePixelRatio; + // font = Math.round(10 * pxRatio) + 'px Arial'; + // } + + // setPxRatio(); + + // window.addEventListener('dppxchange', setPxRatio); + + const ori = _ori; + const dir = _dir; + const stacked = _stacked; + + const groupWidth = 0.9; + const groupDistr = SPACE_BETWEEN; + + const barWidth = 1 - (marginRatio || 0); + const barDistr = SPACE_BETWEEN; + + function distrTwo( + groupCount: number, + barCount: number, + _groupWidth = groupWidth, + ) { + const out = Array.from({ length: barCount }, () => ({ + offs: Array.from({ length: groupCount }).fill(0), + size: Array.from({ length: groupCount }).fill(0), + })); + + distr( + groupCount, + _groupWidth, + groupDistr, + null, + (groupIdx, groupOffPct, groupDimPct) => { + distr( + barCount, + barWidth, + barDistr, + null, + (barIdx, barOffPct, barDimPct) => { + out[barIdx].offs[groupIdx] = groupOffPct + groupDimPct * barOffPct; + out[barIdx].size[groupIdx] = groupDimPct * barDimPct; + }, + ); + }, + ); + + return out; + } + + function distrOne(groupCount: number, barCount: number) { + const out = Array.from({ length: barCount }, () => ({ + offs: Array.from({ length: groupCount }).fill(0), + size: Array.from({ length: groupCount }).fill(0), + })); + + distr( + groupCount, + groupWidth, + groupDistr, + null, + (groupIdx, groupOffPct, groupDimPct) => { + distr( + barCount, + barWidth, + barDistr, + null, + (barIdx, _barOffPct, _barDimPct) => { + out[barIdx].offs[groupIdx] = groupOffPct; + out[barIdx].size[groupIdx] = groupDimPct; + }, + ); + }, + ); + + return out; + } + + let barsPctLayout: any; + // eslint-disable-next-line sonarjs/no-unused-collection + let barsColors: Array<{ fill: string; stroke: string }>; + let qt: Quadtree; + + const barsBuilder = uPlot.paths.bars({ + radius, + disp: { + x0: { + unit: 2, + // discr: false, (unary, discrete, continuous) + values: (_u, seriesIdx, _idx0, _idx1) => barsPctLayout[seriesIdx].offs, + }, + size: { + unit: 2, + // discr: true, + values: (_u, seriesIdx, _idx0, _idx1) => barsPctLayout[seriesIdx].size, + }, + ...disp, + /* + // e.g. variable size via scale (will compute offsets from known values) + x1: { + units: 1, + values: (u, seriesIdx, idx0, idx1) => bucketEnds[idx], + }, + */ + }, + each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => { + // we get back raw canvas coords (included axes & padding). translate to the plotting area origin + lft -= u.bbox.left; + top -= u.bbox.top; + qt.add({ + x: lft, + y: top, + w: wid, + h: hgt, + sidx: seriesIdx, + didx: dataIdx, + }); + }, + }); + + // function drawPoints(u: uPlot, sidx: number, i0: number, i1: number) { + // u.ctx.save(); + + // u.ctx.font = font; + // u.ctx.fillStyle = 'black'; + + // uPlot.orient( + // u, + // sidx, + // ( + // _series, + // _dataX, + // dataY, + // _scaleX, + // scaleY, + // _valToPosX, + // valToPosY, + // xOff, + // yOff, + // xDim, + // yDim, + // _moveTo, + // _lineTo, + // _rect, + // ) => { + // const _dir = dir * (ori == 0 ? 1 : -1); + + // const wid = Math.round(barsPctLayout[sidx].size[0] * xDim); + + // barsPctLayout[sidx].offs.forEach((offs: number, ix: number) => { + // if (dataY[ix] != null) { + // let x0 = xDim * offs; + // let lft = Math.round(xOff + (_dir == 1 ? x0 : xDim - x0 - wid)); + // let barWid = Math.round(wid); + + // let yPos = valToPosY(dataY[ix], scaleY, yDim, yOff); + + // let x = ori == 0 ? Math.round(lft + barWid / 2) : Math.round(yPos); + // let y = ori == 0 ? Math.round(yPos) : Math.round(lft + barWid / 2); + + // u.ctx.textAlign = + // ori == 0 ? 'center' : dataY[ix] >= 0 ? 'left' : 'right'; + // u.ctx.textBaseline = + // ori == 1 ? 'middle' : dataY[ix] >= 0 ? 'bottom' : 'top'; + + // u.ctx.fillText(String(dataY[ix]), x, y); + // } + // }); + // }, + // ); + + // u.ctx.restore(); + // } + + function range(_u: uPlot, _dataMin: number, dataMax: number) { + const [min, max] = uPlot.rangeNum(0, dataMax, 0.05, true); + return [min || 0, max]; + } + + return { + hooks: { + drawClear: (u: uPlot) => { + qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); + + qt.clear(); + + // force-clear the path cache to cause drawBars() to rebuild new quadtree + u.series.forEach((s: any) => { + s._paths = null; + }); + + if (stacked) + barsPctLayout = [null].concat( + distrOne(u.data.length - 1 - ignore.length, u.data[0].length), + ); + else if (u.series.length === 2) + barsPctLayout = [null].concat(distrOne(u.data[0].length, 1)); + else + barsPctLayout = [null].concat( + distrTwo( + u.data[0].length, + u.data.length - 1 - ignore.length, + u.data[0].length === 1 ? 1 : groupWidth, + ), + ); + + // TODOL only do on setData, not every redraw + const { disp } = opts; + if (disp?.fill != null) { + barsColors = [null]; + + for (let i = 1; i < u.data.length; i++) { + barsColors.push({ + fill: disp.fill.values(u, i), + stroke: disp.stroke.values(u, i), + }); + } + } + }, + }, + // eslint-disable-next-line sonarjs/cognitive-complexity + opts: (_u: uPlot, opts: any) => { + const { axes, series } = opts; + const yScaleOpts = { + range, + ori: ori === 0 ? 1 : 0, + }; + // hovered + let hRect: Quadtree; + + uPlot.assign(opts, { + select: { show: false }, + cursor: { + x: false, + y: false, + dataIdx: (u: uPlot, seriesIdx: number) => { + if (seriesIdx === 1) { + hRect = null; + + const cx = u.cursor.left * pxRatio; + const cy = u.cursor.top * pxRatio; + + qt.getQ(cx, cy, 1, 1, o => { + if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) + hRect = o; + }); + } + + return hRect && seriesIdx === hRect.sidx ? hRect.didx : null; + }, + points: { + show: false, + // fill: 'rgba(255,255,255, 0.3)', + // bbox: (u: uPlot, seriesIdx: number) => { + // let isHovered = hRect && seriesIdx == hRect.sidx; + + // return { + // left: isHovered ? hRect.x / pxRatio : -10, + // top: isHovered ? hRect.y / pxRatio : -10, + // width: isHovered ? hRect.w / pxRatio : 0, + // height: isHovered ? hRect.h / pxRatio : 0, + // }; + // }, + }, + }, + scales: { + x: { + distr: 2, + ori, + dir, + // auto: true, + range: (u: uPlot) => { + let min = 0; + let max = Math.max(1, u.data[0].length - 1); + + let pctOffset = 0; + + distr( + u.data[0].length, + groupWidth, + groupDistr, + 0, + (_di, lftPct, widPct) => { + pctOffset = lftPct + widPct / 2; + }, + ); + + const rn = max - min; + + if (pctOffset === 0.5) min -= rn; + else { + const upScale = 1 / (1 - pctOffset * 2); + const offset = (upScale * rn - rn) / 2; + + min -= offset; + max += offset; + } + + return [min, max]; + }, + }, + rend: yScaleOpts, + size: yScaleOpts, + mem: yScaleOpts, + inter: yScaleOpts, + toggle: yScaleOpts, + }, + }); + + if (ori === 1) { + opts.padding = [0, null, 0, null]; + } + const { xSplits } = getBarConfig({ + dir, + ori, + xSpacing: dir === 1 ? 100 : -100, + }); + const values = time === false ? { values: (u: uPlot) => u.data[0] } : {}; + uPlot.assign(axes[0], { + // splits: (u: any, _axisIdx: number) => { + // const _dir = dir * (ori === 0 ? 1 : -1); + // // TODO???? + // const splits = u._data[0].slice(); + // return _dir === 1 ? splits : splits.reverse(); + // }, + splits: xSplits, + // 设置 x 坐标展示 + ...values, + gap: 15, + size: ori === 0 ? 40 : 150, + labelSize: 20, + grid: { show: false }, + ticks: { show: false }, + + side: ori === 0 ? 2 : 3, + }); + + series.forEach((s: object, i: number) => { + if (i > 0 && !ignore.includes(i)) { + uPlot.assign(s, { + // pxAlign: false, + // stroke: "rgba(255,0,0,0.5)", + paths: barsBuilder, + points: { + // show: drawPoints, + show: false, + }, + }); + } + }); + }, + }; +} + +/** + * @internal + */ +export function getBarConfig(opts: { + ori: number; + dir: number; + xSpacing: number; +}) { + const { ori = 0, dir = 1, xSpacing = 0 } = opts; + const isXHorizontal = ori === 0; + const xSplits: Axis.Splits = (u: uPlot) => { + const dim = isXHorizontal ? u.bbox.width : u.bbox.height; + const _dir = dir * (isXHorizontal ? 1 : -1); + + const dataLen = u.data[0].length; + const lastIdx = dataLen - 1; + + let skipMod = 0; + + if (xSpacing !== 0) { + const cssDim = dim / devicePixelRatio; + // let maxTicks = Math.abs(Math.floor(cssDim / xSpacing)); + const maxTicks = Math.abs( + Math.floor(cssDim / (isXHorizontal ? xSpacing : xSpacing / 3)), + ); + + skipMod = dataLen < maxTicks ? 0 : Math.ceil(dataLen / maxTicks); + } + + const splits: number[] = []; + + // for distr: 2 scales, the splits array should contain indices into data[0] rather than values + u.data[0].forEach((_v, i) => { + const shouldSkip = + skipMod !== 0 && (xSpacing > 0 ? i : lastIdx - i) % skipMod > 0; + + if (!shouldSkip) { + splits.push(i); + } + }); + + return _dir === 1 ? splits : splits.reverse(); + }; + + return { xSplits }; +} + +export function stack( + data: uPlot.AlignedData, + omit: (i: number) => boolean = () => false, +): { data: uPlot.AlignedData; bands: uPlot.Band[] } { + const data2 = []; + let bands: uPlot.Band[] = []; + const d0Len = data[0].length; + const accuse: any[] = Array.from({ length: d0Len }); + + for (let i = 0; i < d0Len; i++) accuse[i] = 0; + + for (let i = 1; i < data.length; i++) + data2.push(omit(i) ? data[i] : data[i].map((v, i) => (accuse[i] += +v))); + + for (let i = 1; i < data.length; i++) + !omit(i) && + bands.push({ + series: [data.findIndex((_s, j) => j > i && !omit(j)), i], + }); + + bands = bands.filter(b => b.series[1] > -1); + + return { + data: [data[0]].concat(data2) as uPlot.AlignedData, + bands, + }; +} diff --git a/src/components/uplot-lib/index.ts b/src/components/uplot-lib/index.ts new file mode 100644 index 0000000..124a98a --- /dev/null +++ b/src/components/uplot-lib/index.ts @@ -0,0 +1,2 @@ +export * from './axis.js'; +export * from './grouped-bars.js'; diff --git a/src/components/x-plot-line.ts b/src/components/x-plot-line.ts deleted file mode 100644 index ba0a851..0000000 --- a/src/components/x-plot-line.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { UIController } from '../abstract/index.js'; -import { View } from '../chart/index.js'; -import { CLASS_NAME, STROKE_WIDTH } from '../constant.js'; -import { D3Selection, XPlotLineOptions } from '../types/index.js'; -import { getTextWidth, rgbColor } from '../utils/index.js'; - -export class XPlotLine extends UIController { - container: D3Selection; - - path: D3Selection; - - text: D3Selection; - - get name() { - return 'xPlotLine'; - } - - get dashType() { - return !this.option?.dashType || this.option?.dashType === 'dash' - ? '4,2' - : '0,0'; - } - - constructor(view: View) { - super(view); - this.option = this.owner.options.xPlotLine; - } - - init() { - if (!this.option?.hide) { - const plotLine = this.owner.chartEle.main.append('g'); - plotLine.attr('class', CLASS_NAME.xPlotLine); - this.container = plotLine; - } - } - - render() { - if (this.container) { - const { color } = this.option; - this.path = this.container - .append('path') - .attr('fill', 'none') - .attr(STROKE_WIDTH, 1) - .attr('stroke-dasharray', this.dashType) - .attr('stroke', color || rgbColor('n-6')); - this.text = this.container - .append('text') - .attr('fill', color || rgbColor('n-2')) - .attr('text-anchor', 'end') - .attr('alignment-baseline', 'middle') - .attr('font-size', 12) - .attr('x', 16); - this.update(this.option); - } - } - - update(option?: XPlotLineOptions) { - this.option = option || this.option; - const data = this.option.value; - if (this.owner.noData) { - this.container.attr('visibility', 'hidden'); - this.path.attr('d', ''); - return; - } - if (data) { - this.container.attr('visibility', ''); - const scale = this.owner.getController('scale'); - const y = scale.y(data); - this.path.attr('d', this.getPathD(y)); - this.text - .attr('y', y - 10) - .attr('x', getTextWidth(data)) - .text(data); - } - } - - private getPathD(y: number) { - return `M0 ${y}L${this.owner.size.main.width} ${y}`; - } - - destroy() { - this.container?.remove(); - } -} diff --git a/src/components/y-plot-line.ts b/src/components/y-plot-line.ts deleted file mode 100644 index cc17c33..0000000 --- a/src/components/y-plot-line.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { isFunction } from 'lodash'; - -import { UIController } from '../abstract/index.js'; -import { View } from '../chart/index.js'; -import { CLASS_NAME, STROKE_DASHARRAY, STROKE_WIDTH } from '../constant.js'; -import { Scale } from '../service/index.js'; -import { - D3Selection, - TooltipContext, - TooltipContextItem, - XScaleValue, - YPlotLineOptions, -} from '../types/index.js'; -import { rgbColor, template } from '../utils/index.js'; - -export class YPlotLine extends UIController { - container: D3Selection; - - tipContainer: D3Selection; - - tipContainerText: D3Selection; - - path: D3Selection; - - get hasTpl() { - return typeof this.option.textFormatter === 'function'; - } - - get name() { - return 'yPlotLine'; - } - - get dashType() { - return !this.option?.dashType || this.option?.dashType === 'dash' - ? STROKE_DASHARRAY - : '0,0'; - } - - constructor(view: View) { - super(view); - this.option = this.owner.options.yPlotLine || {}; - } - - init() { - if (!this.option.hide) { - const plotLine = this.owner.chartEle.main.append('g'); - plotLine.attr('class', CLASS_NAME.yPlotLine); - this.container = plotLine; - this.tipContainer = this.owner.chartEle.chart - .style('position', 'relative') - .append('div') - .attr('class', CLASS_NAME.yPlotLineTip) - .style('position', 'absolute') - .style('pointer-events', 'none') - .style('display', 'none'); - this.tipContainerText = this.tipContainer - .append('div') - .attr('class', CLASS_NAME.yPlotLineTipText); - } - } - - render() { - if (this.container) { - this.path = this.container - .append('path') - .attr('fill', 'none') - .attr(STROKE_WIDTH, 1) - .attr('stroke-dasharray', this.dashType) - .attr('stroke', rgbColor('n-6')); - this.update(this.option.value); - } - } - - update(value?: TooltipContext) { - this.option.value = value || this.option.value; - const data = this.option.value; - if (this.owner.noData) { - this.container.attr('visibility', 'hidden'); - this.tipContainer.style('display', 'none'); - this.path.attr('d', ''); - return; - } - if (data) { - this.container.attr('visibility', ''); - this.tipContainer.style('display', 'block'); - const scale = this.owner.getController('scale'); - this.path.attr('d', this.getPathD(data, scale)); - this.generateCircle(data.values, scale); - this.updateTip(data, scale); - } - } - - private updateTip(value: TooltipContext, scale: Scale) { - const text = this.option.text || (value.title as string); - const textValue = isFunction(this.option.textFormatter) - ? this.option.textFormatter(text) - : template(this.option.textFormatter, { name: text }); - this.tipContainerText.text(textValue || text); - - const x = scale.x(value.values[0].x as XScaleValue); - const { clientWidth: tipWidth, clientHeight: tipHight } = - this.tipContainer.node() as HTMLElement; - const mainW = this.owner.size.main.width; - const offsetX = x + this.owner.basics.margin.left; - const containerX = offsetX - tipWidth / 2; - const position = - tipWidth / 2 + (containerX + this.owner.basics.margin.left); - const offset = position > mainW ? containerX - tipWidth / 2 : containerX; - - const { - margin: { top }, - } = this.owner.basics; - const manH = top + this.owner.headerTotalHeight; - this.tipContainer.style('top', `${manH - (tipHight || 19) - 2}px`); - this.tipContainer.style('left', `${offset}px`); - this.tipContainer.style('display', 'block'); - } - - private generateCircle(values: TooltipContextItem[], scale: Scale) { - this.container.selectAll(`.${CLASS_NAME.yPlotLineCircle}`).remove(); - this.container - .selectAll(`.${CLASS_NAME.yPlotLineCircle}`) - .data(values) - .enter() - .append('circle') - .attr('class', CLASS_NAME.yPlotLineCircle) - .attr('r', 2.5) - .attr('stroke', d => d.color) - .attr(STROKE_WIDTH, 1) - .attr('fill', rgbColor('n-10')) - .attr('cx', d => scale.x(d.x as XScaleValue)) - .attr('cy', d => scale.y(d.y)); - } - - private getPathD(value: TooltipContext, scale: Scale) { - const x = scale.x(value.values[0].x as XScaleValue); - const { - margin: { top }, - } = this.owner.basics; - const manH = top + this.owner.headerTotalHeight; - return `M${x},${manH} L${x} ${this.owner.size.main.height}`; - } - - destroy() { - this.container?.remove(); - } -} diff --git a/src/components/zoom.ts b/src/components/zoom.ts deleted file mode 100644 index b201fba..0000000 --- a/src/components/zoom.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { drag, DragBehavior, NumberValue } from 'd3'; -import { get } from 'lodash'; - -import { UIController } from '../abstract/index.js'; -import { View } from '../chart/index.js'; -import { CLASS_NAME, CLONE_PATCH_EVENTS, RECT_EVENTS } from '../constant.js'; -import { D3Selection, Nilable, ZoomOption } from '../types/index.js'; -import { findClosestPointIndex, getPos } from '../utils/index.js'; - -export interface AreaParams { - x: number; - value: { - x: number; - y: string | number; - }; -} - -export class Zoom extends UIController { - container: Nilable; - - behaviour: DragBehavior; - - eventRect: D3Selection; - - private open = false; - - private start = 0; - - private end = 0; - - get name() { - return 'zoom'; - } - - get isRotated() { - return !!this.owner.isRotated; - } - - constructor(view: View) { - super(view); - this.option = view.options.zoom; - } - - init() { - this.container = this.owner.chartEle.main - .append('rect') - .attr('class', CLASS_NAME.zoomBrush) - .style('pointer-events', 'none'); - - this.initZoomBehaviour(); - this.bindZoomOnEventRect(); - } - - render() { - // .. - } - - // TODO: drag 会组织原有事件 mousemove 等, - // 暂时通过 监听 rect 事件触发 drag @zhaoyongping - initZoomBehaviour() { - let start = 0; - let end = 0; - this.behaviour = drag() - .clickDistance(4) - .on('start', (e: DragEvent) => { - const event = get(e, 'sourceEvent') as MouseEvent; - const params = this.getContext(event); - start = params.x; - this.option?.onzoomStart(params); - this.container - .attr('x', params.x) - .attr('height', this.owner.size.main.height); - }) - .on('drag', (e: DragEvent) => { - const event = get(e, 'sourceEvent') as MouseEvent; - const params = this.getContext(event); - this.option?.onzoom?.(params); - end = params.x; - const value = Math.abs(end - start); - - this.container.attr('x', end < start ? start - value : start); - - this.container.attr('width', value); - }) - .on('end', e => { - const event = get(e, 'sourceEvent') as MouseEvent; - this.option?.onzoomEnd?.(this.getContext(event)); - this.container.attr('width', 0).attr('x', 0); - }); - } - - bindZoomOnEventRect() { - this.owner.on(RECT_EVENTS.MOUSEDOWN, this.dragStart); - - this.owner.on(RECT_EVENTS.MOUSEMOVE, this.drag); - - this.owner.on(RECT_EVENTS.MOUSEUP, this.dragEnd); - - this.owner.on(CLONE_PATCH_EVENTS.MOUSEDOWN, this.dragStart); - - this.owner.on(CLONE_PATCH_EVENTS.MOUSEMOVE, this.drag); - - this.owner.on(CLONE_PATCH_EVENTS.MOUSEUP, this.dragEnd); - } - - private readonly dragStart = (event: MouseEvent) => { - this.open = true; - const params = this.getContext(event); - this.start = params.x; - this.option?.onzoomStart(params); - this.container - .attr('x', params.x) - .attr('height', this.owner.size.main.height); - }; - - private readonly drag = (event: MouseEvent) => { - if (this.open) { - const params = this.getContext(event); - this.option?.onzoom?.(params); - this.end = params.x; - const value = Math.abs(this.end - this.start); - - this.container.attr( - 'x', - this.end < this.start ? this.start - value : this.start, - ); - - this.container.attr('width', value); - } - }; - - private readonly dragEnd = (event: MouseEvent) => { - this.open = false; - this.option?.onzoomEnd?.(this.getContext(event)); - this.container.attr('width', 0).attr('x', 0); - }; - - private readonly getContext = (event: MouseEvent): AreaParams => { - const scale = this.owner.getController('scale'); - const xPos = getPos(event, this.isRotated); - const closestIndex = findClosestPointIndex( - xPos, - this.owner, - this.isRotated, - ); - const x = scale.xSeriesValue[closestIndex] as string & - number & - (NumberValue & (Date | NumberValue)); - return { - x: xPos, - value: { - x, - y: scale.x(x), - }, - }; - }; - - destroy() { - this.container?.remove(); - } -} diff --git a/src/constant.ts b/src/constant.ts deleted file mode 100644 index 1b6b1c3..0000000 --- a/src/constant.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { ControllerCtor } from './abstract/index.js'; -import { Pie, Series, Axis } from './components/index.js'; -import { Scale } from './service/index.js'; -import { ChartType } from './types/index.js'; - -export enum LEGEND_EVENTS { - // click 事件 - CLICK = 'legendItem:click', - SELECT_ALL = 'legendItem:selectAll', - UNSELECT_ALL = 'legendItem:unselectALl', -} - -export enum PIE_EVENTS { - ITEM_HOVERED = 'pieItem:hovered', - ITEM_MOUSEOUT = 'pieItem:mouseout', - ITEM_CLICK = 'pieItem:click', -} - -export enum RECT_EVENTS { - MOUSEDOWN = 'rect:mousedown', - MOUSEMOVE = 'rect:mousemove', - MOUSEUP = 'rect:mouseup', - CLICK = 'rect:click', -} - -export enum CLONE_PATCH_EVENTS { - MOUSEDOWN = 'clone_path:mousedown', - MOUSEMOVE = 'clone_path:mousemove', - MOUSEUP = 'clone_path:mouseup', -} - -export enum SCATTER_EVENTS { - ITEM_HOVERED = 'scatterItem:hovered', - ITEM_MOUSEOUT = 'scatterItem:mouseout', - ITEM_CLICK = 'scatterItem:click', -} - -export enum VIEW_HOOKS { - AFTER_RENDER = 'after-render', - CHANGE_DATA = 'change-data', - SET_DATA = 'set-data', - BEFORE_DESTROY = 'before-destroy', - CHANGE_SIZE = 'change-size', -} - -export const DEFAULT_COLORS = [ - '#006eff', - '#24b37a', - '#8b37c1', - '#ffbb00', - '#d42d3d', - '#1fc0cc', - '#a5d936', - '#d563c4', - '#c55a05', - '#6b8fbb', - '#1292d2', - '#36d940', - '#ea0abb', - '#ead925', - '#b0b55c', -]; - -export const basics = { - margin: { top: 0, right: 0, bottom: 20, left: 20 }, - padding: { left: 0 }, // title - main: { top: 20 }, // grid - tickLabelWidth: 0, -}; - -export const AC_PREFIX = 'ac'; - -export const CLASS_NAME = { - title: getPrefixName('title'), - titleText: getPrefixName('title-text'), - legend: getPrefixName('legend'), - legendItem: getPrefixName('legend-item'), - legendItemEvent: getPrefixName('legend-item-event'), - legendItemHidden: getPrefixName('legend-item-hidden'), - legendItemIcon: getPrefixName('legend-icon'), - legendItemActive: getPrefixName('legend-item-active'), - - chart: getPrefixName('chart'), - lines: getPrefixName('lines'), - line: getPrefixName('line'), - cloneLine: getPrefixName('clone-line'), - - areas: getPrefixName('areas'), - area: getPrefixName('area'), - - bars: getPrefixName('bars'), - bar: getPrefixName('bar'), - barItem: getPrefixName('bar-item'), - cloneBar: getPrefixName('clone-bar'), - - scatter: getPrefixName('scatter'), - scatters: getPrefixName('scatters'), - cloneScatter: getPrefixName('clone-scatter'), - scatterItem: getPrefixName('scatter-item'), - - xAxis: getPrefixName('x-axis'), - yAxis: getPrefixName('y-axis'), - - tooltip: getPrefixName('tooltip'), - - tooltipTitle: getPrefixName('tooltip-title'), - - tooltipList: getPrefixName('tooltip-list'), - - tooltipListItem: getPrefixName('tooltip-list-item'), - - tooltipListItemActivated: getPrefixName('tooltip-list-item-activated'), - - eventRects: getPrefixName('event-rects'), - - eventRect: getPrefixName('event-rect'), - - crosshair: getPrefixName('crosshair'), - - zoomBrush: getPrefixName('zoom-brush'), - - yPlotLine: getPrefixName('y-plot-line'), - yPlotLineTip: getPrefixName('y-plot-line-tip'), - yPlotLineTipText: getPrefixName('y-plot-line-tip-text'), - yPlotLineCircles: getPrefixName('y-plot-line-circles'), - yPlotLineCircle: getPrefixName('y-plot-line-circle'), - xPlotLine: getPrefixName('x-plot-line'), - - pie: getPrefixName('pie'), - pies: getPrefixName('pies'), - pieGuide: getPrefixName('pie-guide'), -} as const; - -function getPrefixName(name: T) { - return `${AC_PREFIX}-${name}` as const; -} - -export const GRADIENT_PREFIX = 'gradient-'; - -export const STROKE_WIDTH = 'stroke-width'; - -export const DEFAULT_LINE_WIDTH = 2; - -export const ACTIVE_STROKE_WIDTH = 3; - -export const STROKE_DASHARRAY = '3,2'; - -export const DEFAULT_Y_SCALE_MAX = 1; - -export const DEFAULT_Y_SCALE_MIN = 1; - -export const DEFAULT_SCATTER_OPTIONS = { - size: 5, - minSize: 5, - maxSize: 20, - opacity: 0.2, -}; - -const COMMON_2D_DEPENDENCY = [Scale, Series, Axis]; - -export const CHART_DEPENDS_MAP: Record = { - pie: [Pie], - scatter: COMMON_2D_DEPENDENCY, - area: COMMON_2D_DEPENDENCY, - line: COMMON_2D_DEPENDENCY, - bar: COMMON_2D_DEPENDENCY, -}; - -// 环形图hover时,环形放缩半径 -export const DEFAULT_PIE_ACTIVE_ENLARGE_RADIUS = 4; diff --git a/src/index.ts b/src/index.ts index 9d88585..ddbf039 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,137 @@ -/// +import { registerShape } from './chart/view.js'; +import { Annotation } from './components/annotation.js'; +import { Axis } from './components/axis.js'; +import { registerComponent } from './components/index.js'; +import { Legend } from './components/legend.js'; +import { Scale } from './components/scale.js'; +import Area from './components/shape/area.js'; +import Bar from './components/shape/bar.js'; +import Gauge from './components/shape/gauge.js'; +import Line from './components/shape/line.js'; +import Pie from './components/shape/pie.js'; +import Point from './components/shape/point.js'; +import { Title } from './components/title.js'; +import { Tooltip } from './components/tooltip.js'; +import { BrushXAction } from './interaction/action/brush-x.js'; +import { ElementAction } from './interaction/action/element.js'; +import { registerAction } from './interaction/action/index.js'; +import { LegendToggle } from './interaction/action/legend.js'; +import { TooltipAction } from './interaction/action/tooltip.js'; +import { registerInteraction } from './interaction/index.js'; +import { Dark, registerTheme } from './theme/index.js'; +import { ActionType, ChartEvent } from './types/index.js'; +import { + AreaShapeOption, + BarShapeOption, + GaugeShapeOption, + LineShapeOption, + PieShapeOption, + PointShapeOption, +} from './types/options.js'; -import { Chart as AChart, View } from './chart/index.js'; -import { Options, Theme } from './types/index.js'; +export * from './chart/index.js'; +export * from './types/index.js'; +export * from './utils/index.js'; + +// register component +registerComponent('title', Title); + +registerComponent('legend', Legend); + +registerComponent('tooltip', Tooltip); + +registerComponent('axis', Axis); + +registerComponent('scale', Scale); + +registerComponent('annotation', Annotation); + +// 注册黑暗主题 +registerTheme('dark', Dark()); + +/** + * 往 View 原型上添加的创建 shape 的方法 + * + * Tips: + * view module augmentation, detail: http://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ +declare module './chart/view.js' { + interface View { + line(option?: LineShapeOption): Line; + + area(option?: AreaShapeOption): Area; + + bar(option?: BarShapeOption): Bar; + + point(option?: PointShapeOption): Point; + + pie(option?: PieShapeOption): Pie; + + gauge(option?: GaugeShapeOption): Pie; + } +} -export const Chart = (options: Options) => new AChart(options); +// register shape +registerShape('line', Line); -export const setTheme = (theme: Theme) => View.setTheme(theme); +registerShape('area', Area); + +registerShape('point', Point); + +registerShape('Bar', Bar); + +registerShape('Pie', Pie); + +registerShape('Gauge', Gauge); + +// register interaction action +registerAction('tooltip', TooltipAction); + +registerAction('element-active', ElementAction); + +registerAction('legend', LegendToggle); + +registerAction('brush-x', BrushXAction); + +// register interaction +registerInteraction('tooltip', { + start: [ + { trigger: ChartEvent.PLOT_MOUSEMOVE, action: ActionType.TOOLTIP_SHOW }, + ], + end: [ + { trigger: ChartEvent.PLOT_MOUSELEAVE, action: ActionType.TOOLTIP_HIDE }, + ], +}); + +registerInteraction('legend-active', { + start: [ + { trigger: ChartEvent.LEGEND_ITEM_CLICK, action: ActionType.LEGEND_TOGGLE }, + ], + // end: [ + // { trigger: ChartEvent.LEGEND_ITEM_RESEAT, action: ActionType.TOOLTIP_HIDE }, + // ], +}); + +registerInteraction('brush-x', { + start: [ + { trigger: ChartEvent.PLOT_MOUSEDOWN, action: ActionType.BRUSH_X_START }, + ], + end: [{ trigger: ChartEvent.PLOT_MOUSEUP, action: ActionType.BRUSH_X_END }], +}); + +registerInteraction('element-active', { + start: [ + { + trigger: ChartEvent.ELEMENT_MOUSEMOVE, + action: ActionType.ELEMENT_ACTIVE, + }, + ], + end: [ + { + trigger: ChartEvent.ELEMENT_MOUSELEAVE, + action: ActionType.ELEMENT_RESET, + }, + ], +}); -export { type ViewOptions, Chart as AChart, View } from './chart/index.js'; export * from './components/index.js'; -export * from './constant.js'; -export * from './service/index.js'; -export * from './types/index.js'; -export * from './utils/index.js'; diff --git a/src/interaction/.DS_Store b/src/interaction/.DS_Store new file mode 100644 index 0000000..1b5299f Binary files /dev/null and b/src/interaction/.DS_Store differ diff --git a/src/interaction/action/action.ts b/src/interaction/action/action.ts new file mode 100644 index 0000000..018db4b --- /dev/null +++ b/src/interaction/action/action.ts @@ -0,0 +1,14 @@ +import { View } from '../../chart/view.js'; + +/** + * Action 基类 + */ +export abstract class Action { + view: View; + + abstract get name(): string; + + constructor(view: View) { + this.view = view; + } +} diff --git a/src/interaction/action/brush-x.ts b/src/interaction/action/brush-x.ts new file mode 100644 index 0000000..8008d7a --- /dev/null +++ b/src/interaction/action/brush-x.ts @@ -0,0 +1,26 @@ +import { get } from 'lodash-es'; +import uPlot from 'uplot'; + +import { Action } from './action.js'; + +/** + * legend toggle + * @ignore + */ +export class BrushXAction extends Action { + get name(): string { + return 'brush-X'; + } + + get uPlot() { + return get(this.view.strategyManage.getStrategy('uPlot'), 'uPlot') as uPlot; + } + + start() { + // do nothing. + } + + end() { + // do nothing. + } +} diff --git a/src/interaction/action/element.ts b/src/interaction/action/element.ts new file mode 100644 index 0000000..fbf5311 --- /dev/null +++ b/src/interaction/action/element.ts @@ -0,0 +1,30 @@ +import Pie from '../../components/shape/pie.js'; + +import { Action } from './action.js'; + +/** + * 元素激活 + */ +export class ElementAction extends Action { + get name(): string { + return 'element'; + } + + get pieCtrl() { + return this.view.shapeComponents.get('pie') as Pie; + } + + /** + * 激活 + */ + active(context: any) { + this.pieCtrl?.onMousemove(context); + } + + /** + * 重制 + */ + reset(context: any) { + this.pieCtrl?.onMouseleave(context); + } +} diff --git a/src/interaction/action/index.ts b/src/interaction/action/index.ts new file mode 100644 index 0000000..7b579da --- /dev/null +++ b/src/interaction/action/index.ts @@ -0,0 +1,27 @@ +import { View } from '../../chart/view.js'; + +import { Action } from './action.js'; + +// type-coverage:ignore-next-line +type ActionCtr = new (view: View, opt?: any) => Action; + +const ACTIONS: Map = new Map(); + +/** + * 全局注册 action。 + * @param name action 名称 + * @param action action 实例 + * @returns void + */ +export function registerAction(name: string, action: ActionCtr) { + ACTIONS.set(name, action); +} + +/** + * 获取动作类 + * @param name action 名称 + * @returns 返回动作类 + */ +export function getAction(name: string): ActionCtr { + return ACTIONS.get(name); +} diff --git a/src/interaction/action/legend.ts b/src/interaction/action/legend.ts new file mode 100644 index 0000000..bebc879 --- /dev/null +++ b/src/interaction/action/legend.ts @@ -0,0 +1,36 @@ +import { get } from 'lodash-es'; +import uPlot from 'uplot'; + +// import { LegendItemActive } from '../../index.js'; + +import { Action } from './action.js'; + +/** + * legend toggle + * @ignore + */ +export class LegendToggle extends Action { + get name(): string { + return 'legend'; + } + + get component() { + return this.view.components.get('legend'); + } + + get uPlot() { + return get(this.view.strategyManage.getStrategy('uPlot'), 'uPlot') as uPlot; + } + + /** + * 切换 legend + */ + // toggle(value: LegendItemActive) { + // if (this.uPlot) { + // this.uPlot?.setSeries(value.index + 1, { show: !value.activated }, true); + // } + // } + toggle() { + // TODO + } +} diff --git a/src/interaction/action/tooltip.ts b/src/interaction/action/tooltip.ts new file mode 100644 index 0000000..e613c25 --- /dev/null +++ b/src/interaction/action/tooltip.ts @@ -0,0 +1,31 @@ +import { Tooltip } from '../../components/index.js'; + +import { Action } from './action.js'; + +/** + * Tooltip 显示隐藏的 Action + * @ignore + */ +export class TooltipAction extends Action { + get name(): string { + return 'tooltip'; + } + + get component() { + return this.view.components.get('tooltip') as Tooltip; + } + + /** + * 显示 Tooltip + */ + show() { + // this.component.showTooltip() + } + + /** + * 隐藏 Tooltip + */ + hide() { + // this.component.hideTooltip(); + } +} diff --git a/src/interaction/index.ts b/src/interaction/index.ts new file mode 100644 index 0000000..61bd431 --- /dev/null +++ b/src/interaction/index.ts @@ -0,0 +1,25 @@ +import { InteractionSteps } from '../types/index.js'; + +const INTERACTIONS: Map = new Map(); + +/** + * 全局注册组件。 + * @param name 交互名称 + * @param interaction 注册的组件类 + * @returns void + */ +export function registerInteraction( + name: string, + interaction: InteractionSteps, +) { + INTERACTIONS.set(name, interaction); +} + +/** + * 根据交互名获取交互类。 + * @param name 交互名称 + * @returns 返回交互类 + */ +export function getInteraction(name: string): InteractionSteps { + return INTERACTIONS.get(name); +} diff --git a/src/interaction/interaction.ts b/src/interaction/interaction.ts new file mode 100644 index 0000000..dac830e --- /dev/null +++ b/src/interaction/interaction.ts @@ -0,0 +1,140 @@ +import { each } from 'lodash-es'; + +import { View } from '../chart/view.js'; +import { + ActionObject, + InteractionStep, + InteractionSteps, +} from '../types/index.js'; + +import { getAction } from './action/index.js'; + +export type InteractionCtor = new (view: View, opt: unknown) => Interaction; + +// 执行 Action +function executeAction(actionObject: ActionObject, context?: any) { + const action = actionObject.action as any; + const { methodName } = actionObject; + if (action?.[methodName]) { + action[methodName](context); + } else { + throw new Error( + `Action(${ + action.name as string + }) doesn't have a method called ${methodName}`, + ); + } +} + +/** + * 交互的基类。 + */ +export default class Interaction { + /** view 或者 chart */ + protected view: View; + /** 配置项 */ + steps: InteractionSteps; + + private readonly callbackCaches: Map void> = + new Map(); + + constructor(view: View, steps: InteractionSteps) { + this.view = view; + this.steps = steps; + } + + /** + * 初始化。 + */ + init() { + this.initActionObject(); + this.initEvents(); + } + + /** + * 绑定事件 + */ + protected initEvents() { + // TODO: point 下 tooltip trigger 为 element + const point = this.view.shapeComponents.get('point'); + each(this.steps, (value: InteractionStep[], stepName) => { + value.forEach(step => { + const callback = this.getActionCallback(stepName, step); + const isPlot = step.trigger.includes('plot'); + this.view.on( + point && isPlot + ? step.trigger.replace('plot', 'element') + : step.trigger, + callback, + ); + }); + }); + } + + // 初始化 action object + private initActionObject() { + const steps = this.steps; + // 生成具体的 Action + each(steps, subSteps => { + each(subSteps, step => { + step.actionObject = this.getActionObject(step.action); + }); + }); + } + + private getActionCallback( + stepName: string, + step: InteractionStep, + ): (e: object) => void { + const callbackCaches = this.callbackCaches; + if (step.action) { + const key = stepName; + if (!callbackCaches.get(key)) { + // 生成执行的方法,执行对应 action 的名称 + const actionCallback = (context: any) => { + executeAction(step.actionObject, context); + step.callback?.(context); // 执行callback + }; + callbackCaches.set(stepName, actionCallback); + } + return callbackCaches.get(stepName); + } + return null; + } + + private readonly getActionObject = (actionStr: string): ActionObject => { + const [actionName, methodName] = actionStr.split(':'); + const Action = getAction(actionName); + if (!Action) { + throw new Error(`There is no action named ${actionName}`); + } + return { + action: new Action(this.view), + methodName, + }; + }; + + /** + * 销毁事件 + */ + protected clearEvents() { + const point = this.view.shapeComponents.get('point'); + each(this.steps, (value: InteractionStep[]) => { + value.forEach(step => { + const isPlot = step.trigger.includes('plot'); + this.view.off( + point && isPlot + ? step.trigger.replace('plot', 'element') + : step.trigger, + ); + }); + }); + } + + /** + * 销毁。 + */ + destroy() { + this.clearEvents(); + } +} diff --git a/src/reactivity/index.ts b/src/reactivity/index.ts new file mode 100644 index 0000000..aaa4df3 --- /dev/null +++ b/src/reactivity/index.ts @@ -0,0 +1 @@ +export * from './reactive.js'; diff --git a/src/reactivity/reactive.ts b/src/reactivity/reactive.ts new file mode 100644 index 0000000..2d5bcbc --- /dev/null +++ b/src/reactivity/reactive.ts @@ -0,0 +1,83 @@ +import { isObjectLike, merge } from 'lodash-es'; +import onChange, { ApplyData } from 'on-change'; + +import { View } from '../chart/view.js'; +import { Data } from '../types/index.js'; + +interface ReactiveContext { + path: string; + value: unknown; + previousValue: unknown; + applyData: ApplyData; +} + +export class Reactive { + source: object; + reactiveObject: object; + dep: Dep; + constructor(target: object, chart: View) { + this.dep = new Dep(chart); + this.source = target; + this.createReactiveObject(this.source); + } + + createReactiveObject(target: object) { + this.reactiveObject = onChange( + target, + ( + path: string, + value: unknown, + previousValue: unknown, + applyData: ApplyData, + ) => { + this.dep.notify({ path, value, previousValue, applyData }); + }, + ); + return this.reactiveObject; + } + + unsubscribe() { + onChange?.unsubscribe(this.createReactiveObject); + } +} + +export function reactive(source: object, chart: View): Reactive { + return new Reactive(source, chart); +} + +export class Dep { + ctrl: View; + constructor(chart: View) { + this.ctrl = chart; + } + + notify({ path, value, previousValue }: ReactiveContext) { + // console.log(path, value, previousValue, applyData); + const names = path.split('.'); + this.syncConfig(names, value, previousValue); + this.update(names, value); + } + + /** + * 同步 chart option config + */ + private syncConfig(names: string[], value: unknown, previousValue: unknown) { + let option = value; + if (isObjectLike(value)) { + option = merge(previousValue, value); + } + // TODO: isArray? + const [str, ...name] = names; + this.ctrl.setOption(str === 'options' ? name : names, option); + } + + private update(names: string[], value: unknown) { + if (names.includes('data') && names.length === 1) { + this.ctrl.data(value as Data); + } + if (names.includes('options') && names.length > 1) { + const component = this.ctrl.components.get(names[1]); + component?.update(); + } + } +} diff --git a/src/service/controller.context.ts b/src/service/controller.context.ts deleted file mode 100644 index ad8144d..0000000 --- a/src/service/controller.context.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ControllerCtor } from '../abstract/index.js'; - -export class ControllerContextService { - private readonly LOADED_COMPONENTS: Set = new Set(); - - registerComponent(ctl: ControllerCtor) { - this.LOADED_COMPONENTS.add(ctl); - } - - unregisterComponent(ctl: ControllerCtor) { - this.LOADED_COMPONENTS.delete(ctl); - } - - getComponents() { - return Array.from(this.LOADED_COMPONENTS); - } -} diff --git a/src/service/index.ts b/src/service/index.ts deleted file mode 100644 index 743404d..0000000 --- a/src/service/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './controller.context.js'; -export * from './scale.js'; -export * from './tooltip/axis.strategy.js'; -export * from './tooltip/item.strategy.js'; -export * from './tooltip/none.strategy.js'; -export * from './tooltip/strategy.js'; diff --git a/src/service/scale.ts b/src/service/scale.ts deleted file mode 100644 index 78bebb6..0000000 --- a/src/service/scale.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { scaleTime, scaleLinear, scalePoint, scaleBand, ScaleBand } from 'd3'; - -import { ServiceController } from '../abstract/index.js'; -import { View } from '../chart/index.js'; -import { DEFAULT_Y_SCALE_MAX, DEFAULT_Y_SCALE_MIN } from '../constant.js'; -import { AxisOption, BarSeriesOption, ScaleType } from '../types/index.js'; - -export class Scale extends ServiceController { - xOptions: AxisOption; - - yOptions: AxisOption; - - get isGroup() { - return (this.owner.options.seriesOption as BarSeriesOption)?.isGroup; - } - - constructor(owner: View) { - super(owner); - this.xOptions = this.owner.options.xAxis || {}; - this.yOptions = this.owner.options.yAxis || {}; - } - - get name() { - return 'scale'; - } - - get dataValues() { - return this.owner.chartData.flatMap(item => item.values); - } - - get xSeriesValue(): Array { - return [...new Set(this.dataValues.map(d => d.x))]; - } - - get xBarSeriesValue() { - return this.isGroup - ? this.owner.chartData.map(d => d.name) - : this.owner.chartData[0].values.map(d => d.x); - } - - get ySeriesValue() { - const seriesOption = (this.owner.options.seriesOption || - {}) as BarSeriesOption; - if (seriesOption.stack) { - return this.owner.chartData.reduce( - (pre, cur) => [...pre, cur.values.reduce((acc, d) => acc + d.y, 0)], - [], - ); - } - return this.dataValues.map(d => d.y); - } - - get scaleType() { - return this.xOptions.type || getScaleType(this.xSeriesValue); - } - - get isRotated() { - return this.owner.isRotated; - } - - init() { - // .. - } - - destroy() { - // .. - } - - get xDomain() { - return this.owner.options.type === 'bar' && this.isGroup - ? this.owner.chartData.map(d => d.name) - : getXDomain(this.xSeriesValue, this.scaleType); - } - - get x() { - return this.getXScale(this.xDomain); - } - - get xBarScale() { - const width = (this.x as ScaleBand).bandwidth(); - const defaultPadding = 8; - const barPadding = this.isRotated ? 3 : defaultPadding; - const spacing = this.dataValues.length / (width / barPadding); - return scaleBand() - .range([0, width]) - .paddingInner(spacing) - .domain(this.xSeriesValue as string[]); - } - - get yDomain() { - const defaultDomain = getMaxMinValue(this.ySeriesValue); - const min = Math.min( - 0, - this.yOptions.min || DEFAULT_Y_SCALE_MIN, - defaultDomain[0], - ); - const max = Math.max( - 0, - this.yOptions.max || DEFAULT_Y_SCALE_MAX, - defaultDomain[1], - ); - return [min, max]; - } - - get y() { - const { width, height } = this.owner.size.main; - const h = this.isRotated ? width : height; - const top = this.owner.headerTotalHeight; - return scaleLinear() - .domain(this.yDomain) - .range(this.isRotated ? [0, h] : [h, top]) - .nice(); - } - - getXScale(domain: Array) { - const { width, height } = this.owner.size.main; - const w = this.isRotated ? height : width; - const t = this.isRotated ? this.owner.headerTotalHeight : 0; - if (this.owner.options.type === 'bar') { - const dm = this.isGroup ? domain : this.xSeriesValue; - const scaleB = scaleBand() - .range([t, w]) - .domain(dm) - .padding(3 / 10); - return scaleB.rangeRound([t, w]); - } - switch (this.scaleType) { - case ScaleType.TIME: - return scaleTime().domain(domain).range([0, w]); - case ScaleType.LINEAR: - return scaleLinear().domain(domain).range([0, w]); - case ScaleType.ORDINAL: - return scalePoint().domain(domain).range([0, w]); - } - } -} - -export function getXDomain( - data: Array, - scaleType: ScaleType, -) { - if ([ScaleType.TIME, ScaleType.LINEAR].includes(scaleType)) { - return getMaxMinValue(data); - } - return data; -} - -function getMaxMinValue(data: Array): [number, number] { - return data.reduce( - (acc, pre) => { - const value = +pre || 0; - return [ - acc[0] < +value ? acc[0] : +value, - acc[1] > +value ? acc[1] : +value, - ]; - }, - [+data[0], +data[0]], - ) as [number, number]; -} - -export function getScaleType(values: Array): ScaleType { - const allDates = values.every(value => value instanceof Date); - if (allDates) { - return ScaleType.TIME; - } - const allNumbers = values.every(value => typeof value === 'number'); - if (allNumbers) { - return ScaleType.LINEAR; - } - return ScaleType.ORDINAL; -} diff --git a/src/service/tooltip/axis.strategy.ts b/src/service/tooltip/axis.strategy.ts deleted file mode 100644 index 34e908f..0000000 --- a/src/service/tooltip/axis.strategy.ts +++ /dev/null @@ -1,156 +0,0 @@ -import * as d3 from 'd3'; -import { BaseType, NumberValue, ScalePoint } from 'd3'; - -import { - ACTIVE_STROKE_WIDTH, - CLASS_NAME, - CLONE_PATCH_EVENTS, - DEFAULT_LINE_WIDTH, - RECT_EVENTS, - STROKE_WIDTH, -} from '../../constant.js'; -import { - ChartData, - D3Selection, - LineSeriesOption, - Nilable, -} from '../../types/index.js'; -import { - findClosestPointIndex, - getPos, - removeSymbol, -} from '../../utils/index.js'; - -import { TooltipStrategy } from './strategy.js'; - -export class AxisTooltipStrategy extends TooltipStrategy { - registerPaths( - paths: d3.Selection, - panel: D3Selection, - ) { - const tooltip = this.owner.getController('tooltip'); - const options = this.owner.options; - panel - .on('click', (event: MouseEvent) => { - const target = event.target as SVGElement; - const isTarget = - target.nodeName === 'path' || - Array.from(target.classList).includes(CLASS_NAME.barItem); - const eventDom = isTarget - ? (this.owner.chartEle.main - .selectAll(`.${CLASS_NAME.eventRect} rect`) - .node() as HTMLElement) - : null; - const { index: closestIndex, value: xValue } = this.getCurrentParams( - event, - eventDom, - ); - const value = tooltip.getTooltipContext(closestIndex, xValue); - this.owner.emit(RECT_EVENTS.CLICK, value); - }) - .on('mousedown', (event: MouseEvent) => { - this.owner.emit(RECT_EVENTS.MOUSEDOWN, event); - }) - .on('mouseup', (event: MouseEvent) => { - this.owner.emit(RECT_EVENTS.MOUSEUP, event); - }) - .on('mousemove', (event: MouseEvent) => { - if (this.owner.noData) { - tooltip?.setVisibility(false); - return; - } - const target = event.target as SVGElement; - const isTarget = - target.nodeName === 'path' || - Array.from(target.classList).includes(CLASS_NAME.barItem); - const eventDom = isTarget - ? (this.owner.chartEle.main - .selectAll(`.${CLASS_NAME.eventRect} rect`) - .node() as HTMLElement) - : null; - const yPos = getPos(event, !this.owner.isRotated, eventDom); - tooltip?.setVisibility(true); - const scale = this.owner.getController('scale'); - const { - index: closestIndex, - value: xValue, - xPos, - } = this.getCurrentParams(event, eventDom); - const width = this.owner.isBar - ? (scale.x as ScalePoint).bandwidth() / 2 - : 0; - if (!this.owner.isBar) { - tooltip.updateCrosshairLine((scale.x(xValue) || 0) + width); - } - tooltip.setTooltipContext( - { offsetX: xPos, offsetY: yPos, event }, - tooltip.getTooltipContext(closestIndex, xValue), - ); - this.owner.emit(RECT_EVENTS.MOUSEMOVE, event); - }) - .on('mouseout', () => { - this.owner.chartEle.main - .selectAll(`.${CLASS_NAME.line}`) - .selectAll('path') - .attr( - STROKE_WIDTH, - (this.owner.options.seriesOption as Nilable) - ?.lineWidth || DEFAULT_LINE_WIDTH, - ); - tooltip?.setActive(null); - tooltip?.setVisibility(false); - }); - paths - .on('mousedown', (event: MouseEvent) => { - this.owner.emit(CLONE_PATCH_EVENTS.MOUSEDOWN, event); - }) - .on('mouseup', (event: MouseEvent) => { - this.owner.emit(CLONE_PATCH_EVENTS.MOUSEUP, event); - }) - .on('mouseover', (event: MouseEvent, d) => { - const name = d.name; - const seriesOpt = options as LineSeriesOption; - this.owner.emit(CLONE_PATCH_EVENTS.MOUSEMOVE, event); - this.owner.chartEle.main - .selectAll( - `.${CLASS_NAME.line}:not(.${CLASS_NAME.line}-${removeSymbol( - name, - )})`, - ) - .selectAll('path') - .attr('opacity', 0.3); - this.owner.chartEle.main - .selectAll(`.${CLASS_NAME.line}-${removeSymbol(name)}`) - .selectAll('path') - .attr(STROKE_WIDTH, seriesOpt.activeLineWidth || ACTIVE_STROKE_WIDTH); - tooltip?.setActive(name); - }) - .on('mouseout', () => { - this.owner.chartEle.main - .selectAll(`.${CLASS_NAME.line}`) - .selectAll('path') - .attr( - STROKE_WIDTH, - (this.owner.options.seriesOption as Nilable) - ?.lineWidth || DEFAULT_LINE_WIDTH, - ) - .attr('opacity', 1); - tooltip?.setActive(null); - }); - } - - private getCurrentParams(event: MouseEvent, rectDom?: HTMLElement) { - const tooltip = this.owner.getController('tooltip'); - const xPos = getPos(event, tooltip.isRotated, rectDom); - const scale = this.owner.getController('scale'); - const closestIndex = findClosestPointIndex( - xPos, - this.owner, - tooltip.isRotated, - ); - const value = ( - this.owner.isBar ? scale.xBarSeriesValue : scale.xSeriesValue - )[closestIndex] as string & (NumberValue & (Date | NumberValue)); - return { index: closestIndex, value, xPos }; - } -} diff --git a/src/service/tooltip/item.strategy.ts b/src/service/tooltip/item.strategy.ts deleted file mode 100644 index 93317b1..0000000 --- a/src/service/tooltip/item.strategy.ts +++ /dev/null @@ -1,121 +0,0 @@ -import * as d3 from 'd3'; -import { BaseType } from 'd3'; -import { get } from 'lodash'; - -import { CLASS_NAME, PIE_EVENTS, SCATTER_EVENTS } from '../../constant.js'; -import { ChartData } from '../../types/index.js'; -import { removeSymbol } from '../../utils/index.js'; - -import { TooltipStrategy } from './strategy.js'; - -export class ItemTooltipStrategy extends TooltipStrategy { - registerPaths(paths: d3.Selection) { - const tooltip = this.owner.getController('tooltip'); - const owner = this.owner; - const { type } = owner.options; - paths - .on( - 'mouseover', - function ( - event: MouseEvent, - value: { pName: string; name: string; data: ChartData }, - ) { - if (type === 'scatter') { - const point = owner.chartEle.main.selectAll( - `.${CLASS_NAME.scatter}-${removeSymbol(value.pName)} .${ - CLASS_NAME.scatterItem - }-${removeSymbol(value.name)}`, - ); - const r = point.attr('r'); - point - .transition() - .duration(200) - .attr('stroke-width', 2) - .attr('r', +r + 2) - .attr('defaultR', r); - owner.emit(SCATTER_EVENTS.ITEM_HOVERED, { - self: this, - event, - data: value, - }); - } - if (value.data && type === 'pie') { - owner.emit(PIE_EVENTS.ITEM_HOVERED, { - self: this, - event, - data: value, - }); - } - }, - ) - .on('mousemove', (event: MouseEvent, res) => { - const value = res as { - data: ChartData; - name: string; - color: string; - x: string; - value: number; - }; - if (!this.owner.noData && (value.data || value.name)) { - const data = value.data || value; - const margin = type === 'pie' ? 20 : -20; - const offsetX = type === 'pie' ? 0 : -40; - tooltip.setTooltipContext( - { - offsetX: event.offsetX + margin + offsetX, - offsetY: event.offsetY + margin, - event, - }, - { - title: type === 'scatter' ? '' : data?.name, - values: [ - { - name: data.name, - color: data.color, - x: get(data, 'x') as string, - y: data.value, - activated: false, - }, - ], - }, - ); - } - }) - .on( - 'mouseout', - function ( - event: MouseEvent, - value: { pName: string; name: string; data: ChartData }, - ) { - if (value.data && type === 'pie') { - owner.emit(PIE_EVENTS.ITEM_MOUSEOUT, { - self: this, - event, - data: value, - }); - } - if (type === 'scatter') { - const point = owner.chartEle.main.selectAll( - `.${CLASS_NAME.scatter}-${removeSymbol(value.pName)} .${ - CLASS_NAME.scatterItem - }-${removeSymbol(value.name)}`, - ); - const dR = point.attr('defaultR'); - point - .transition() - .duration(200) - .attr('stroke-width', 1) - .attr('r', dR); - - owner.emit(SCATTER_EVENTS.ITEM_HOVERED, { - self: this, - event, - data: value, - }); - } - tooltip?.setActive(null); - tooltip?.setVisibility(false); - }, - ); - } -} diff --git a/src/service/tooltip/none.strategy.ts b/src/service/tooltip/none.strategy.ts deleted file mode 100644 index 1418d5e..0000000 --- a/src/service/tooltip/none.strategy.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { noop } from 'lodash'; - -import { TooltipStrategy } from './strategy.js'; - -export class NoneTooltipStrategy extends TooltipStrategy { - registerPaths = noop; -} diff --git a/src/service/tooltip/strategy.ts b/src/service/tooltip/strategy.ts deleted file mode 100644 index 00933c1..0000000 --- a/src/service/tooltip/strategy.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { View } from '../../chart/index.js'; -import { ChartData, D3Selection } from '../../types/index.js'; - -export abstract class TooltipStrategy { - constructor(public owner: View) {} - - abstract registerPaths( - paths?: d3.Selection, - panel?: D3Selection, - ): void; -} diff --git a/src/strategy/abstract.ts b/src/strategy/abstract.ts new file mode 100644 index 0000000..f8eba10 --- /dev/null +++ b/src/strategy/abstract.ts @@ -0,0 +1,55 @@ +import { View } from '../chart/view.js'; +import { BaseComponent } from '../components/base.js'; +import { getComponent, getComponentNames } from '../components/index.js'; + +/** + * view strategy 视图策略抽象类 + * 规范 strategy 的实现 + */ +export abstract class ViewStrategy { + // 当前策略名称 + abstract get name(): string; + + // 当前策略需要的组件 + abstract get component(): string[]; + + // 存储当前策略下实力化的组件 + components: BaseComponent[] = []; + + // 初始化 + abstract init(): void; + + // 渲染函数 + abstract render(): void; + + // 全局配置的组件 + readonly usedComponent: string[] = getComponentNames(); + + readonly ctrl: View; + + get options() { + return this.ctrl.getOption(); + } + + constructor(view: View) { + this.ctrl = view; + this.initComponent(); + this.init(); + } + + /** + * 根据当前策略初始化组件 + */ + initComponent() { + for (const name of this.component) { + const Component = getComponent(name); + if (Component) { + this.components.push(new Component(this.ctrl)); + } + } + } + + destroy() { + this.components = []; + } +} diff --git a/src/strategy/color-untils.ts b/src/strategy/color-untils.ts new file mode 100644 index 0000000..7bf117b --- /dev/null +++ b/src/strategy/color-untils.ts @@ -0,0 +1,237 @@ +/** + * @internal + */ +let _context: CanvasRenderingContext2D; +export function getCanvasContext() { + if (!_context) { + _context = document.createElement('canvas').getContext('2d')!; + } + return _context; +} + + +export function getOpacityGradientFn( + color: string, + opacity: number, +): (self: uPlot, seriesIdx: number) => CanvasGradient { + return (plot: uPlot, _seriesIdx: number) => { + const ctx = getCanvasContext(); + const gradient = makeDirectionalGradient( + plot.scales.x!.ori === ScaleOrientation.Horizontal + ? GradientDirection.Down + : GradientDirection.Left, + plot.bbox, + ctx, + ); + + gradient.addColorStop(0, alpha(color, opacity)); + gradient.addColorStop(1, alpha(color, 0)); + + return gradient; + }; +} + +export function alpha(color: string, value: number) { + if (color === '') { + return '#000000'; + } + + value = clamp(value); + + // hex 3, hex 4 (w/alpha), hex 6, hex 8 (w/alpha) + if (color[0] === '#') { + if (color.length === 9) { + color = color.substring(0, 7); + } else if (color.length <= 5) { + let c = '#'; + for (let i = 1; i < 4; i++) { + c += color[i] + color[i]; + } + color = c; + } + + return ( + color + + Math.round(value * 255) + .toString(16) + .padStart(2, '0') + ); + } + // rgb(, hsl( + else if (color[3] === '(') { + // rgb() and hsl() do not require the "a" suffix to accept alpha values in modern browsers: + // https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb()#accepts_alpha_value + return color.replace(')', `, ${value})`); + } + // rgba(, hsla( + else if (color[4] === '(') { + return color.substring(0, color.lastIndexOf(',')) + `, ${value})`; + } + + const parts = decomposeColor(color); + + if (parts.type === 'color') { + parts.values[3] = `/${value}`; + } else { + parts.values[3] = value; + } + + return recomposeColor(parts); +} + +interface DecomposeColor { + type: string; + values: any; + colorSpace?: string; +} + +/** + * Converts a color object with type and values to a string. + * @param {object} color - Decomposed color + * @param color.type - One of: 'rgb', 'rgba', 'hsl', 'hsla' + * @param {array} color.values - [n,n,n] or [n,n,n,n] + * @returns A CSS color string + * @beta + */ +export function recomposeColor(color: DecomposeColor) { + const { type, colorSpace } = color; + let values = color.values; + + if (type.indexOf('rgb') !== -1) { + // Only convert the first 3 values to int (i.e. not alpha) + values = values.map((n: string, i: number) => + i < 3 ? parseInt(n, 10) : n, + ); + } else if (type.indexOf('hsl') !== -1) { + values[1] = `${values[1]}%`; + values[2] = `${values[2]}%`; + } + if (type.indexOf('color') !== -1) { + values = `${colorSpace} ${values.join(' ')}`; + } else { + values = `${values.join(', ')}`; + } + + return `${type}(${values})`; +} + +export enum GradientDirection { + Right = 0, + Up = 1, + Left = 2, + Down = 3, +} + +export enum ScaleOrientation { + Horizontal = 0, + Vertical = 1, +} + +function makeDirectionalGradient( + direction: GradientDirection, + bbox: uPlot.BBox, + ctx: CanvasRenderingContext2D, +) { + let x0 = 0, + y0 = 0, + x1 = 0, + y1 = 0; + + if (direction === GradientDirection.Down) { + y0 = bbox.top; + y1 = bbox.top + bbox.height; + } else if (direction === GradientDirection.Left) { + x0 = bbox.left + bbox.width; + x1 = bbox.left; + } else if (direction === GradientDirection.Up) { + y0 = bbox.top + bbox.height; + y1 = bbox.top; + } else if (direction === GradientDirection.Right) { + x0 = bbox.left; + x1 = bbox.left + bbox.width; + } + + return ctx.createLinearGradient(x0, y0, x1, y1); +} + +export function decomposeColor(color: string | DecomposeColor): DecomposeColor { + // Idempotent + if (typeof color !== 'string') { + return color; + } + + if (color.charAt(0) === '#') { + return decomposeColor(hexToRgb(color)); + } + + const marker = color.indexOf('('); + const type = color.substring(0, marker); + + if (['rgb', 'rgba', 'hsl', 'hsla', 'color'].indexOf(type) === -1) { + throw new Error( + `Unsupported '${color}' color. The following formats are supported: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()`, + ); + } + + let values: any = color.substring(marker + 1, color.length - 1); + let colorSpace; + + if (type === 'color') { + values = values.split(' '); + colorSpace = values.shift(); + if (values.length === 4 && values[3].charAt(0) === '/') { + values[3] = values[3].slice(1); + } + if ( + ['srgb', 'display-p3', 'a98-rgb', 'prophoto-rgb', 'rec-2020'].indexOf( + colorSpace, + ) === -1 + ) { + throw new Error( + `Unsupported ${colorSpace} color space. The following color spaces are supported: srgb, display-p3, a98-rgb, prophoto-rgb, rec-2020.`, + ); + } + } else { + values = values.split(','); + } + + values = values.map((value: string) => parseFloat(value)); + return { type, values, colorSpace }; +} + +export function hexToRgb(color: string) { + color = color.slice(1); + + const re = new RegExp(`.{1,${color.length >= 6 ? 2 : 1}}`, 'g'); + let result = color.match(re); + + if (!result) { + return ''; + } + + let colors = Array.from(result); + + if (colors[0].length === 1) { + colors = colors.map(n => n + n); + } + + return colors + ? `rgb${colors.length === 4 ? 'a' : ''}(${colors + .map((n, index) => { + return index < 3 + ? parseInt(n, 16) + : Math.round((parseInt(n, 16) / 255) * 1000) / 1000; + }) + .join(', ')})` + : ''; +} + +function clamp(value: number, min = 0, max = 1) { + if (process.env.NODE_ENV !== 'production') { + if (value < min || value > max) { + console.error(`The value provided ${value} is out of range [${min}, ${max}].`); + } + } + + return Math.min(Math.max(min, value), max); +} diff --git a/src/strategy/config.ts b/src/strategy/config.ts new file mode 100644 index 0000000..9958c1b --- /dev/null +++ b/src/strategy/config.ts @@ -0,0 +1,87 @@ +import { axesSpace } from './utils.js'; + +const M_D_H_M = '{MM}-{DD} {HH}:{mm}'; +export const AXES_X_VALUES = [ + // tick incr default year month day hour min sec mode + [3600 * 365, '{YYYY}', null, null, null, null, null, null, 1], + [3600 * 24 * 30, '{YYYY}-{MM}-{DD}', null, null, null, null, null, null, 1], + [3600 * 24 * 7, '{MM}-{DD}', null, null, null, null, null, null, 1], + [3600 * 24 * 3, M_D_H_M, null, null, null, null, null, null, 1], + [3600 * 24, M_D_H_M, null, null, null, null, null, null, 1], + [3600 * 12, M_D_H_M, null, null, null, null, null, null, 1], + [3600, '{HH}:{mm}', null, null, null, null, null, null, 1], + [60, '{HH}:{mm}', null, null, null, null, null, null, 1], + [1, '{HH}:{mm}', null, null, null, null, null, null, 1], + [0.001, '{mm}:{ss}', null, null, null, null, null, null, 1], +]; +const DEFAULT_FONT = '12px "Roboto", "Helvetica", "Arial", sans-serif'; +export const UPLOT_DEFAULT_OPTIONS = { + padding: [16, 8, 0, 0], + legend: { + show: false, + live: false, // 关闭当前值 + }, + scales: { + y: { + range: (_u: uPlot, dataMin: number, dataMax: number) => { + const maxV = Math.max(dataMax ? dataMax + 5 : dataMax, 1); + return [dataMin || 0, maxV]; + }, + }, + }, + axes: [ + { + space: axesSpace, + size: 20, + border: { + show: true, + width: 1, + }, + font: DEFAULT_FONT, + values: AXES_X_VALUES, + grid: { + show: false, + // width: 1, + }, + ticks: { + width: 1, + size: 5, + }, + + // border: { + // show: true, + // width: 1, + // } + }, + { + font: DEFAULT_FONT, + border: { + show: true, + width: 1, + }, + grid: { + width: 1, + dash: [4, 6], + }, + ticks: { + width: 1, + size: 5, + }, + }, + ], + cursor: { + y: false, + points: { + show: false, + }, + drag: { + x: false, + y: false, + setScale: false, + uni: 10, + }, + focus: { + prox: 30, // 鼠标移入点激活 像素激活距离 + }, + }, +}; diff --git a/src/strategy/gradient-fills.ts b/src/strategy/gradient-fills.ts new file mode 100644 index 0000000..6952b6e --- /dev/null +++ b/src/strategy/gradient-fills.ts @@ -0,0 +1,190 @@ +import { Annotation } from '../components/annotation.js'; +import { AnnotationLineOption } from '../types/options.js'; +import { getCanvasContext } from './color-untils.js'; +import tinycolor from 'tinycolor2'; + +export enum ScaleOrientation { + Horizontal = 0, + Vertical = 1, +} + +export function addAreas( + u: uPlot, + yScaleKey: string, + steps: AnnotationLineOption[], + self: Annotation, +) { + let ctx = u.ctx; + let grd = scaleGradient( + u, + yScaleKey, + steps.map(step => { + let color = tinycolor( + step.style?.stroke || self.ctrl.getTheme().colorVar.red, + ); + + if (color.getAlpha() === 1) { + color.setAlpha(0.15); + } + + return [+step.data, color.toString()]; + }), + true, + ); + + ctx.fillStyle = grd; + ctx.fillRect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); +} + +type ValueStop = [value: number, color: string]; + +type ScaleValueStops = ValueStop[]; + +export function scaleGradient( + u: uPlot, + scaleKey: string, + scaleStops: ScaleValueStops, + discrete = false, +) { + let scale = u.scales[scaleKey]; + + // we want the stop below or at the scaleMax + // and the stop below or at the scaleMin, else the stop above scaleMin + let minStopIdx: number | null = null; + let maxStopIdx: number | null = null; + + for (let i = 0; i < scaleStops.length; i++) { + let stopVal = scaleStops[i][0]; + + if (stopVal <= scale.min! || minStopIdx == null) { + minStopIdx = i; + } + + maxStopIdx = i; + + if (stopVal >= scale.max!) { + break; + } + } + + if (minStopIdx === maxStopIdx) { + return scaleStops[minStopIdx!][1]; + } + + let minStopVal = scaleStops[minStopIdx!][0]; + let maxStopVal = scaleStops[maxStopIdx!][0]; + + if (minStopVal === -Infinity) { + minStopVal = scale.min!; + } + + if (maxStopVal === Infinity) { + maxStopVal = scale.max!; + } + + let minStopPos = Math.round(u.valToPos(minStopVal, scaleKey, true)); + let maxStopPos = Math.round(u.valToPos(maxStopVal, scaleKey, true)); + + let range = minStopPos - maxStopPos; + + if (range === 0) { + return scaleStops[maxStopIdx!][1]; + } + + let x0, y0, x1, y1; + + if (u.scales.x!.ori === ScaleOrientation.Horizontal) { + x0 = x1 = 0; + y0 = minStopPos; + y1 = maxStopPos; + } else { + y0 = y1 = 0; + x0 = minStopPos; + x1 = maxStopPos; + } + + let ctx = getCanvasContext(); + let grd = ctx.createLinearGradient(x0, y0, x1, y1 || 0); + + let prevColor: string; + + for (let i = minStopIdx!; i <= maxStopIdx!; i++) { + let s = scaleStops[i]; + + let stopPos = + i === minStopIdx + ? minStopPos + : i === maxStopIdx + ? maxStopPos + : Math.round(u.valToPos(s[0], scaleKey, true)); + + let pct = (minStopPos - stopPos) / range; + + if (discrete && i > minStopIdx!) { + grd.addColorStop(pct, prevColor!); + } + + grd.addColorStop(pct, (prevColor = s[1])); + } + + return grd; +} + +export function addLines( + u: uPlot, + yScaleKey: string, + steps: AnnotationLineOption[], + self: Annotation, +) { + let ctx = u.ctx; + + // Thresholds below a transparent threshold is treated like "less than", and line drawn previous threshold + let transparentIndex = 0; + + for (let idx = 0; idx < steps.length; idx++) { + const step = steps[idx]; + if (step.style?.stroke === 'transparent') { + transparentIndex = idx; + break; + } + } + + ctx.lineWidth = 2; + + // Ignore the base -Infinity threshold by always starting on index 1 + for (let idx = 1; idx < steps.length; idx++) { + if (steps[idx]?.style?.line) { + return; + } + const step = steps[idx]; + let color: tinycolor.Instance; + + const cColor = self.ctrl.getTheme().colorVar.red; + + // if we are below a transparent index treat this a less then threshold, use previous thresholds color + if (transparentIndex >= idx && idx > 0) { + color = tinycolor(steps[idx - 1]?.style?.stroke || cColor); + } else { + color = tinycolor(step.style?.stroke || cColor); + } + + // Unless alpha specififed set to default value + if (color.getAlpha() === 1) { + color.setAlpha(0.7); + } + + let x0 = Math.round(u.bbox.left); + let y0 = Math.round(u.valToPos(+step.data, yScaleKey, true)); + let x1 = Math.round(u.bbox.left + u.bbox.width); + let y1 = Math.round(u.valToPos(+step.data, yScaleKey, true)); + if (step?.style?.lineDash) { + ctx.setLineDash(step?.style?.lineDash); + } + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + + ctx.strokeStyle = color.toString(); + ctx.stroke(); + } +} diff --git a/src/strategy/index.ts b/src/strategy/index.ts new file mode 100644 index 0000000..2ffe08f --- /dev/null +++ b/src/strategy/index.ts @@ -0,0 +1,3 @@ +export * from './internal-strategy.js'; +export * from './manage.js'; +export * from './uplot-strategy.js'; diff --git a/src/strategy/internal-strategy.ts b/src/strategy/internal-strategy.ts new file mode 100644 index 0000000..a845959 --- /dev/null +++ b/src/strategy/internal-strategy.ts @@ -0,0 +1,28 @@ +import { PolarShape } from '../components/shape/index.js'; + +import { ViewStrategy } from './abstract.js'; + +/** + * 渲染策略 + * internal 渲染图表 + */ +export class InternalViewStrategy extends ViewStrategy { + get name(): string { + return 'internal'; + } + + get component(): string[] { + return ['title', 'legend', 'pie', 'gauge']; + } + + init() { + // do nothing. + } + + render() { + this.component.forEach(c => { + const comp = this.ctrl.shapeComponents.get(c) as PolarShape; + comp?.render(); + }); + } +} diff --git a/src/strategy/manage.ts b/src/strategy/manage.ts new file mode 100644 index 0000000..9b9cc36 --- /dev/null +++ b/src/strategy/manage.ts @@ -0,0 +1,70 @@ +import { ViewStrategy } from './abstract.js'; + +/** + * view 策略管理 + * internal uPlot + * + * 关联:组件、option、 data、theme、render + */ +export class ViewStrategyManager { + strategy = new Map(); + + /** + * 添加策略 + * @param strategy 策略 + */ + add(strategy: ViewStrategy) { + this.strategy.set(strategy.name, strategy); + } + + /** + * 根据名称获取对应策略 + * @param name 策略名称 + * @returns 策略实例 + */ + getStrategy(name: string) { + return this.strategy.get(name); + } + + /** + * 获取所有策略 + * @returns 获取当前所有策略实例 + */ + getAllStrategy() { + return [...this.strategy.values()]; + } + + /** + * 获取所有策略下的组件 + * @returns 获取当前策略所有组件 + */ + getComponent() { + const all = this.getAllStrategy(); + return all.flatMap(ctrl => ctrl.components); + } +} + +// // 子 view +// class UPlotStrategy { +// name: 'uPlot' +// component = ['axis', 'tooltip', 'legend', 'line', 'area', 'point', 'bar']; +// handleData() {} +// } + +// class InternalStrategy { +// name: 'internal' +// component = ['title', 'legend', 'pie', 'gauge', 'tooltip']; +// handleData() {} + +// render (option:any) { +// this.dom +// } + +// getOption () {} +// } + +// const strategyManager = new StrategyManager(); +// strategyManager.add(new UPlotStrategy()) + +// const uPlotS = strategyManager.getStrategy('uPlot') +// uPlotS.getOption(); diff --git a/src/strategy/quadtree.ts b/src/strategy/quadtree.ts new file mode 100644 index 0000000..085b2fd --- /dev/null +++ b/src/strategy/quadtree.ts @@ -0,0 +1,132 @@ +export function pointWithin( + px: number, + py: number, + rlft: number, + rtop: number, + rrgt: number, + rbtm: number, +) { + return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm; +} + +const MAX_OBJECTS = 10; +const MAX_LEVELS = 4; + +export class Quadtree { + x: number; + y: number; + w: number; + h: number; + l: number; + o: Quadtree[] = []; + q: Quadtree[]; + + sidx: number; + didx: number; + + constructor( + x: number, + y: number, + width: number, + height: number, + left?: number, + ) { + this.x = x; + this.y = y; + this.w = width; + this.h = height; + this.l = left || 0; + } + + split = () => { + const { x, y, w: _w, h: _h, l: _l } = this; + const w = _w / 2; + const h = _h / 2; + const l = _l + 1; + + this.q = [ + // top right + new Quadtree(x + w, y, w, h, l), + // top left + new Quadtree(x, y, w, h, l), + // bottom left + new Quadtree(x, y + h, w, h, l), + // bottom right + new Quadtree(x + w, y + h, w, h, l), + ]; + }; + + // invokes callback with index of each overlapping quad + quads(x: number, y: number, w: number, h: number, cb: (d: Quadtree) => void) { + const q = this.q; + const hzMid = this.x + this.w / 2; + const vtMid = this.y + this.h / 2; + const startIsNorth = y < vtMid; + const startIsWest = x < hzMid; + const endIsEast = x + w > hzMid; + const endIsSouth = y + h > vtMid; + + // top-right quad + startIsNorth && endIsEast && cb(q[0]); + // top-left quad + startIsWest && startIsNorth && cb(q[1]); + // bottom-left quad + startIsWest && endIsSouth && cb(q[2]); + // bottom-right quad + endIsEast && endIsSouth && cb(q[3]); + } + + add(o: { + x: number; + y: number; + w: number; + h: number; + sidx: number; + didx: number; + }) { + if (this.q != null) { + this.quads(o.x, o.y, o.w, o.h, q => { + q.add(o); + }); + } else { + const os = this.o; + + os.push(o as Quadtree); + + if (os.length > MAX_OBJECTS && this.l < MAX_LEVELS) { + this.split(); + + for (const oi of os) { + this.quads(oi.x, oi.y, oi.w, oi.h, q => { + q.add(oi); + }); + } + + this.o.length = 0; + } + } + } + + getQ( + x: number, + y: number, + w: number, + h: number, + cb: (value: Quadtree) => void, + ) { + const os = this.o; + + for (const o of os) cb(o); + + if (this.q != null) { + this.quads(x, y, w, h, q => { + q.getQ(x, y, w, h, cb); + }); + } + } + + clear() { + this.o.length = 0; + this.q = null; + } +} diff --git a/src/strategy/uplot-strategy.ts b/src/strategy/uplot-strategy.ts new file mode 100644 index 0000000..d89c711 --- /dev/null +++ b/src/strategy/uplot-strategy.ts @@ -0,0 +1,645 @@ +import { cloneDeep, merge, mergeWith, omit, isFunction, get } from 'lodash-es'; +import UPlot from 'uplot'; + +import { Annotation } from '../components/annotation.js'; +import { Axis } from '../components/axis.js'; +import { Legend, Tooltip } from '../components/index.js'; +import { Scale } from '../components/scale.js'; +import { PolarShape, Shape } from '../components/shape/index.js'; +import { autoPadRight } from '../components/uplot-lib/axis.js'; +import { ChartEvent, Data, LegendItemActive, Size } from '../types/index.js'; +import { + generateName, + POLAR_SHAPE_TYPES, + SHAPE_TYPES, +} from '../utils/index.js'; + +import { ViewStrategy } from './abstract.js'; +import { UPLOT_DEFAULT_OPTIONS } from './config.js'; +import { Quadtree } from './quadtree.js'; + +const CURSOR_X = '.u-cursor-x'; +const SHAPES = SHAPE_TYPES; +/** + * 渲染策略 + * uPlot 渲染图表 + */ +export class UPlotViewStrategy extends ViewStrategy { + shapes = SHAPES; + + qt: Quadtree; + + private cursor: HTMLElement; + + get name(): string { + return 'uPlot'; + } + + get component(): string[] { + return ['axis', 'scale', 'tooltip', 'annotation', ...SHAPES]; + } + + private uPlot: uPlot; + + get isElementAction() { + return this.ctrl.isElementAction; + } + + private recordActive = false; + + activeId: number; + + init() { + this.getChartEvent(); + } + + private get transposed() { + return this.ctrl.getCoordinate().isTransposed; + } + + /** + * 监听 chart 事件 + */ + getChartEvent() { + // 监听 主题改变 + this.ctrl.on(ChartEvent.THEME_CHANGE, () => { + if (this.uPlot) { + this.uPlot.redraw(); + } + POLAR_SHAPE_TYPES.map(name => { + const comp = this.ctrl.shapeComponents.get(name) as PolarShape; + comp?.redraw(); + }); + }); + + // 监听 legend item click + this.ctrl.on(ChartEvent.LEGEND_ITEM_CLICK, (data: LegendItemActive) => { + if (this.uPlot) { + const index = this.uPlot.series + .filter(d => d.scale === 'y') + .findIndex(item => item.label === data.name); + this.uPlot.setSeries(index + 1, { show: data.activated }, true); + } + }); + + this.ctrl.on(ChartEvent.DATA_CHANGE, () => { + if (this.uPlot) { + this.uPlot.redraw(); + const data = this.getData(); + const ySeries = this.uPlot.series.filter(s => s.scale === 'y'); + const series = this.getSeries(); + const legend = this.ctrl.components.get('legend') as Legend; + + ySeries.forEach((s: uPlot.Series) => { + if (series.every(d => d.label !== s.label)) { + this.uPlot.delSeries( + this.uPlot.series.findIndex(res => res.label === s.label), + ); + } + }); + + this.getSeries().forEach((s: uPlot.Series, index) => { + const no = ySeries.every(d => d.label !== s.label); + if (legend.inactivatedSet.has(s.label)) { + s.show = false; + } + if (no && index) { + this.uPlot.addSeries(s, this.uPlot.series.length); + return; + } + this.uPlot.setSeries(this.uPlot.series.length + 1, { show: false }); + }); + this.uPlot.setData(data); + legend.update(); + } + }); + + this.ctrl.on(ChartEvent.HOOKS_REDRAW, () => { + this.uPlot?.redraw(); + }); + } + + render(size?: Size) { + const option = this.getOption(); + + if (!this.uPlot && option.series.length > 1) { + const data = option.data?.length ? option.data : this.getData(); + this.uPlot = new UPlot(option, data, this.ctrl.container); + } + this.changeSize(size || this.ctrl.size); + } + + /** + * 修改 uPlot 视图大小 + * @param size 宽 高 + */ + private changeSize(size: Size) { + // TODO: 设置 uPlot padding 留空间给header header 使用 position 定位 + if (this.uPlot) { + const position: string = get(this.options.legend, 'position', ''); + const legendEl = this.ctrl.chartContainer.querySelector( + `.${generateName('legend')}`, + ); + const legendH = position.includes('bottom') + ? legendEl?.clientHeight || 0 + ? (legendEl?.clientHeight || 0) + 8 + : 0 + : 0; + const headerH = + this.ctrl.chartContainer.querySelector(`.${generateName('header')}`) + ?.clientHeight || 0; + + this.uPlot.setSize({ + ...size, + height: size.height - (headerH + legendH), + }); + } + } + + /** + * 获取 uPlot 配置 + * 将原始 option 转换 uPlot 配置 + */ + // eslint-disable-next-line sonarjs/cognitive-complexity + private readonly getOption = (): uPlot.Options => { + const { width, height } = this.ctrl.size; + const ctrlOption = this.ctrl.getOption(); + const series = this.getSeries(); + const theme = this.getThemeOption(); + const plugins = this.getPlugins(); + const shapeOptions = this.getShapeChartOption(); + const coordinate = this.ctrl.coordinateInstance.getOptions(); + const axis = (this.ctrl.components.get('axis') as Axis).getOptions(); + const scale = (this.ctrl.components.get('scale') as Scale).getOptions(); + const annotation = ( + this.ctrl.components.get('annotation') as Annotation + ).getOptions(); + const interaction = this.getInteractionOption(); + const [dt, dr, db, dl] = + ctrlOption.padding || UPLOT_DEFAULT_OPTIONS.padding; + const paddingRight = isFunction(dr) ? dr : autoPadRight(dr); + const source = { + width, + height: height + 90, + ...cloneDeep(UPLOT_DEFAULT_OPTIONS), + padding: [dt, paddingRight, db, dl], + plugins, + // fmtDate: () => UPlot.fmtDate('{HH}:{mm}'), + hooks: { + drawClear: [ + (u: uPlot) => { + this.qt = + this.qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); + this.qt.clear(); + }, + ], + setSeries: [ + (_u: uPlot, id: number) => { + this.activeId = id; + }, + ], + ready: [ + (u: uPlot) => { + this.cursor = document.createElement('div'); + this.cursor.className = 'cursor'; + this.cursor.style.position = 'absolute'; + this.cursor.style.top = '0'; + this.cursor.style.left = '0'; + u.over.append(this.cursor); + this.ctrl.emit(ChartEvent.U_PLOT_READY); + requestAnimationFrame(() => { + this.render(); + }); + }, + ], + }, + series, + }; + return mergeWith( + source, + coordinate, + annotation, + axis, + scale, + theme, + shapeOptions, + interaction, + (objValue: unknown, srcValue: unknown, key: string) => { + if (Array.isArray(objValue) && Array.isArray(srcValue)) { + if (key === 'plugins' || key === 'drawClear') { + return objValue.concat(srcValue); + } + if (key === 'axes') { + return merge(objValue, srcValue); + } + const objLonger = objValue.length > srcValue.length; + const source = objLonger ? objValue : srcValue; + const minSource = objLonger ? srcValue : objValue; + return source.map((value: unknown, index) => + merge(value, minSource[index]), + ); + } + }, + ); + }; + + /** + * 获取数据 + * 原始数据转换为 uPlot 数据 + * @returns uPlot.AlignedData + */ + getData(): uPlot.AlignedData { + const data = this.ctrl.getData(); + return this.handleData(data); + } + + /** + * 将数据处理成 uPlot 数据格式 + * @param data 源数据 + * @returns uPlot 数据源哥是 + */ + handleData(data: Data): uPlot.AlignedData { + if (!data.length) { + return []; + } + const labels: string[] = this.ctrl.getShapeDataName(); + const values = data + .filter(v => labels.includes(v.name)) + .sort( + (a, b) => + labels.indexOf(a.id || a.name) - labels.indexOf(a.id || b.name), + ) + .map(value => value.values); + // type-coverage:ignore-next-line + const x = values[0].map(value => value.x); + // const yItem = values.map(data => data.map(d => d.y)); + + const yItem = values.map(data => + data.map(d => + isNaN(+d.y) || !isFinite(+d.y) || d.y === null ? null : +d.y, + ), + ); + return [x, ...yItem]; + } + + /** + * 获取 series + * @param data 源数据 + * @returns uPlot series + */ + getSeries() { + const series = this.ctrl + .getShapeList() + .map((shape: Shape) => { + return shape.getSeries(); + }) + .flat(); + + return [{}, ...series]; + } + + /** + * 获取 shape uPlot 配置 + * @returns uPlot option + */ + getShapeChartOption() { + return this.shapes.reduce((prev, name) => { + const comp = this.ctrl.shapeComponents.get(name) as Shape; + return comp ? merge(prev, comp.getOptions()) : prev; + }, {}); + } + + getInteractionOption() { + return { + cursor: { + bind: { + // 始终开启 drag x 根据 interaction brush x 是否开启触发 handle + mousedown: ( + _u: uPlot, + _t: HTMLElement, + handler: (e: MouseEvent) => null, + ) => { + return (e: MouseEvent) => { + if (this.ctrl.interactions.get('brush-x')) { + handler(e); + } + }; + }, + }, + drag: { + x: true, + }, + }, + }; + } + + /** + * 获取主题 配置 + */ + getThemeOption() { + if (!this.ctrl.getTheme()) { + return; + } + return { + axes: [ + { + stroke: () => this.ctrl.getTheme().xAxis.stroke, + ticks: { + stroke: () => this.ctrl.getTheme().xAxis.tickStroke, + }, + border: { + stroke: () => this.ctrl.getTheme().xAxis.tickStroke, + }, + }, + { + stroke: () => this.ctrl.getTheme().yAxis.stroke, + grid: { + stroke: () => this.ctrl.getTheme().yAxis.gridStroke, + }, + ticks: { + stroke: () => this.ctrl.getTheme().yAxis.tickStroke, + }, + border: { + stroke: () => this.ctrl.getTheme().xAxis.tickStroke, + }, + }, + ], + }; + } + + getUPlotChart() { + return this.uPlot; + } + + private getPlugins() { + return [this.getTooltipPlugin()]; + } + + private getTooltipPlugin() { + let over: HTMLDivElement; + let bound: HTMLDivElement; + // let bLeft: number; + // let bTop: number; + let cacheData: { title: string | number; values: Data }; + let overBbox: DOMRect; + return { + hooks: { + init: (u: UPlot) => { + over = u.over; + bound = over; + const cursorX = u.over.querySelector(CURSOR_X) as HTMLElement; + if (cursorX) { + cursorX.style.visibility = 'hidden'; + } + over.addEventListener('click', () => { + const { left, top } = u.cursor; + const data = this.ctrl.getData(); + const x = + u.data[0][ + u.valToIdx(u.posToVal(this.transposed ? top : left, 'x')) + ]; + const values = data.reduce((pre, cur) => { + const items = cur.values.find(c => c.x === x); + const values = { ...items, ...omit(cur, 'values') }; + return [...pre, values]; + }, []); + this.ctrl.emit(ChartEvent.PLOT_CLICK, { + title: x, + values, + }); + }); + over.addEventListener('mouseenter', () => { + if (u.series.some(d => d.scale === 'y' && d.show)) { + this.ctrl.emit(ChartEvent.PLOT_MOUSEMOVE); + const noData = !Array.from(u.data.slice(1)) + .flat() + .some(d => d !== null); + if (!noData && !this.ctrl.hideTooltip && !this.isElementAction) { + (this.ctrl.components.get('tooltip') as Tooltip).showTooltip(); + } + } + }); + over.addEventListener('mouseleave', () => { + this.ctrl.emit(ChartEvent.PLOT_MOUSELEAVE); + (this.ctrl.components.get('tooltip') as Tooltip).hideTooltip(); + }); + }, + syncRect: (_: uPlot, rect: DOMRect) => (overBbox = rect), + // eslint-disable-next-line sonarjs/cognitive-complexity + setCursor: (u: uPlot) => { + if (!overBbox) { + return; + } + const { left, top, idx, idxs } = u.cursor; + const x = u.data[0][idx]; + const data = this.ctrl.getData(); + + const ySeries = u.series.filter(d => d.scale === 'y'); + + const values = data.reduce((prev, curr, index) => { + const allow = this.isElementAction ? idxs[index + 1] : true; + return ySeries[index]?.show && allow + ? [ + ...prev, + { + name: curr.name, + color: curr.color, + value: curr.values[idx || 0]?.y, + activated: this.activeId === index + 1, + ...curr.values[idx || 0], + }, + ] + : prev; + }, []); + const cursorX = u.over.querySelector(CURSOR_X) as HTMLElement; + const cursorY = u.over.querySelector('.u-cursor-y') as HTMLElement; + const noData = !values.some(d => d.y !== null); + const visibility = noData ? 'hidden' : 'visible'; + const visibilityX = + noData || this.ctrl.hideTooltip ? 'hidden' : 'visible'; + + if (cursorX) { + cursorX.style.visibility = visibilityX; + const tooltip = this.ctrl.components.get('tooltip') as Tooltip; + if (visibilityX === 'hidden') { + tooltip.hideTooltip(); + } else { + tooltip.showTooltip(); + } + } + if (cursorY) { + cursorY.style.visibility = visibility; + } + + if (noData && !this.isElementAction) { + return; + } + + if (!this.isElementAction) { + cacheData = { + title: x, + values, + }; + } + + if (idxs.some(Boolean)) { + cacheData = { + title: x, + values, + }; + if (!this.recordActive) { + this.recordActive = true; + this.ctrl.emit(ChartEvent.ELEMENT_MOUSEMOVE); + if (!noData && !this.ctrl.hideTooltip) { + (this.ctrl.components.get('tooltip') as Tooltip).showTooltip(); + } + } + } else { + if (this.recordActive) { + this.ctrl.emit(ChartEvent.ELEMENT_MOUSELEAVE); + this.recordActive = false; + (this.ctrl.components.get('tooltip') as Tooltip).hideTooltip(); + } + } + + if (cacheData && this.cursor) { + this.cursor.style.transform = `translate(${left}px,${top}px)`; + this.ctrl.emit(ChartEvent.U_PLOT_SET_CURSOR, { + bound, + anchor: this.cursor || u.over.querySelector(CURSOR_X), + title: cacheData.title, + values: cacheData.values, + }); + // this.ctrl.emit(ChartEvent.U_PLOT_SET_CURSOR, { + // bound, + // anchor, + // title: cacheData.title, + // values: cacheData.values, + // }); + } + }, + setSelect: [ + (u: uPlot) => { + if (u.select.width) { + const start = + u.data[0][u.valToIdx(u.posToVal(u.select.left, 'x'))]; + const end = + u.data[0][ + u.valToIdx(u.posToVal(u.select.left + u.select.width, 'x')) + ]; + // manually hide selected region (since cursor.drag.setScale = false) + /* @ts-ignore */ + u.setSelect({ left: 0, width: 0 }, false); + this.ctrl.emit(ChartEvent.PLOT_MOUSEUP, { start, end }); + } + }, + ], + }, + }; + } + + override destroy(): void { + this.components = []; + this.uPlot?.destroy(); + } +} + +function isCursorOutsideCanvas({ left, top }: uPlot.Cursor, canvas: DOMRect) { + if (left === undefined || top === undefined) { + return false; + } + return left < 0 || left > canvas.width || top < 0 || top > canvas.height; +} + +/** + * Finds y axis midpoint for point at given idx (css pixels relative to uPlot canvas) + * @internal + **/ +// eslint-disable-next-line sonarjs/cognitive-complexity +export function findMidPointYPosition(u: uPlot, idx: number) { + let y; + let sMaxIdx = 1; + let sMinIdx = 1; + // assume min/max being values of 1st series + let max = u.data[1][idx]; + let min = u.data[1][idx]; + + // find min max values AND ids of the corresponding series to get the scales + for (let i = 1; i < u.data.length; i++) { + const sData = u.data[i]; + const sVal = sData[idx]; + if (sVal != null) { + if (max == null) { + max = sVal; + } else { + if (sVal > max) { + max = u.data[i][idx]; + sMaxIdx = i; + } + } + if (min == null) { + min = sVal; + } else { + if (sVal < min) { + min = u.data[i][idx]; + sMinIdx = i; + } + } + } + } + + if (min == null && max == null) { + // no tooltip to show + y = undefined; + } else if (min != null && max != null) { + // find median position + y = + (u.valToPos(min, u.series[sMinIdx].scale) + + u.valToPos(max, u.series[sMaxIdx].scale)) / + 2; + } else { + // snap tooltip to min OR max point, one of those is not null :) + y = u.valToPos((min || max)!, u.series[(sMaxIdx || sMinIdx)!].scale); + } + + // if y is out of canvas bounds, snap it to the bottom + if (y !== undefined && y < 0) { + y = u.bbox.height / devicePixelRatio; + } + + return y; +} + +/** + * Given uPlot cursor position, figure out position of the tooltip withing the canvas bbox + * Tooltip is positioned relatively to a viewport + * @internal + **/ +export function positionTooltip(u: uPlot, bbox: DOMRect) { + let x, y; + const cL = u.cursor.left || 0; + const cT = u.cursor.top || 0; + + if (isCursorOutsideCanvas(u.cursor, bbox)) { + const idx = u.posToIdx(cL); + // when cursor outside of uPlot's canvas + if (cT < 0 || cT > bbox.height) { + const pos = findMidPointYPosition(u, idx); + + if (pos) { + y = bbox.top + pos; + if (cL >= 0 && cL <= bbox.width) { + // find x-scale position for a current cursor left position + x = + bbox.left + + u.valToPos(u.data[0][u.posToIdx(cL)], u.series[0].scale); + } + } + } + } else { + x = bbox.left + cL; + y = bbox.top + cT; + } + + return { x, y }; +} diff --git a/src/strategy/utils.ts b/src/strategy/utils.ts new file mode 100644 index 0000000..6c8bcf5 --- /dev/null +++ b/src/strategy/utils.ts @@ -0,0 +1,187 @@ +import UPlot from 'uplot'; + +import { StepType } from '../components/shape/line.js'; +import { ShapeOption } from '../types/index.js'; +import { ShapeType } from '../utils/component.js'; +import { convertRgba } from '../utils/index.js'; +import { getOpacityGradientFn } from './color-untils.js'; +// eslint-disable-next-line sonarjs/cognitive-complexity +export function scaleGradient( + u: UPlot, + scaleKey: string, + ori: number, + scaleStops: Array<[number, string]>, + discrete = false, +) { + const can = document.createElement('canvas'); + const ctx = can.getContext('2d'); + const scale = u.scales[scaleKey]; + + // we want the stop below or at the scaleMax + // and the stop below or at the scaleMin, else the stop above scaleMin + let minStopIdx: number; + let maxStopIdx: number; + + for (const [i, scaleStop] of scaleStops.entries()) { + const stopVal = scaleStop[0]; + + if (stopVal <= scale.min || minStopIdx == null) minStopIdx = i; + + maxStopIdx = i; + + if (stopVal >= scale.max) break; + } + + if (minStopIdx === maxStopIdx) return scaleStops[minStopIdx][1]; + let minStopVal = scaleStops[minStopIdx][0]; + let maxStopVal = scaleStops[maxStopIdx][0]; + + if (minStopVal === -Infinity) minStopVal = scale.min; + + if (maxStopVal === Infinity) maxStopVal = scale.max; + + const minStopPos = u.valToPos(minStopVal, scaleKey, true); + const maxStopPos = u.valToPos(maxStopVal, scaleKey, true); + + const range = minStopPos - maxStopPos; + + let x0: number, y0: number, x1: number, y1: number; + + if (ori === 1) { + x0 = x1 = 0; + y0 = minStopPos; + y1 = maxStopPos; + } else { + y0 = y1 = 0; + x0 = minStopPos; + x1 = maxStopPos; + } + + const grd = ctx.createLinearGradient(x0, y0, x1, y1); + let prevColor: string; + + for (let i = minStopIdx; i <= maxStopIdx; i++) { + const s = scaleStops[i]; + + const stopPos = + i === minStopIdx + ? minStopPos + : i === maxStopIdx + ? maxStopPos + : u.valToPos(s[0], scaleKey, true); + const pct = (minStopPos - stopPos) / range; + + if (discrete && i > minStopIdx) grd.addColorStop(pct, prevColor); + + grd.addColorStop(pct, (prevColor = s[1])); + } + return grd; +} + +export function getSeriesPathType( + type: ShapeType, + color: string, + options: ShapeOption, + stepType?: StepType, +) { + const defaultType = UPlot.paths.spline(); + const defaultOptions = { + width: options.width ?? 1.5, + // alpha: options.alpha ?? 1, + }; + const stroke = convertRgba(color, 1); + return ( + { + [ShapeType.Line]: { + stroke, + ...defaultOptions, + paths: stepType + ? UPlot.paths.stepped({ + align: stepType === 'start' ? 1 : -1, + }) + : defaultType, + }, + [ShapeType.Area]: { + paths: defaultType, + ...defaultOptions, + stroke, + alpha: options.alpha, + fill: getOpacityGradientFn(stroke, options.alpha || 0.8), + }, + [ShapeType.Bar]: { + ...defaultOptions, + paths: UPlot.paths.bars(), + fill: color, + }, + [ShapeType.Point]: { + ...defaultOptions, + stroke, + paths: UPlot.paths.points(), + }, + }[type] || { + ...defaultOptions, + paths: defaultType, + } + ); +} + +/** -------------------------------------------------------- */ +let _context: CanvasRenderingContext2D; +const cache = new Map(); +const cacheLimit = 500; +let ctxFontStyle = ''; +export const UPLOT_AXIS_FONT_SIZE = 12; + +// 计算最小网格和刻度间距 +export function axesSpace(self: uPlot, axisIdx: number, scaleMin: number) { + const axis = self.axes[axisIdx]; + const scale = self.scales[axis.scale]; + + // for axis left & right + if (axis.side !== 2 || !scale) { + return 30; + } + const defaultSpacing = 40; + if (scale.time) { + return measureText(String(scaleMin), UPLOT_AXIS_FONT_SIZE).width + 18; + } + return defaultSpacing; +} + +/** + * @internal + */ +export function getCanvasContext() { + if (!_context) { + _context = document.createElement('canvas').getContext('2d')!; + } + return _context; +} +/** + * @beta + */ +export function measureText(text: string, fontSize = 12): TextMetrics { + const fontStyle = `${fontSize}px 'Roboto'`; + const cacheKey = text + fontStyle; + const fromCache = cache.get(cacheKey); + + if (fromCache) { + return fromCache; + } + + const context = getCanvasContext(); + + if (ctxFontStyle !== fontStyle) { + context.font = ctxFontStyle = fontStyle; + } + + const metrics = context.measureText(text); + + if (cache.size === cacheLimit) { + cache.clear(); + } + + cache.set(cacheKey, metrics); + + return metrics; +} diff --git a/src/theme/_base-var.scss b/src/theme/_base-var.scss deleted file mode 100644 index b885adc..0000000 --- a/src/theme/_base-var.scss +++ /dev/null @@ -1,30 +0,0 @@ -@use 'sass:string'; - -@function use-var($name) { - @return var(--ac-#{$name}); -} - -@function use-rgb($name: primary, $level: none) { - @if $level != none { - @return rgb(var(--ac-color-#{string.slice('#{$name}', 1, 1)}-#{$level})); - } - @return rgb(var(--ac-color-#{$name})); -} - -@function use-rgba($name, $opacity) { - @return rgba(var(--ac-color-#{$name}), $opacity); -} - -@function use-text-color($level: main) { - @if $level == main { - @return use-rgb(n-1); - } @else if $level == secondary { - @return use-rgb(n-2); - } @else if $level == help { - @return use-rgb(n-4); - } @else if $level == disabled or $level == placeholder { - @return use-rgb(n-6); - } @else { - @return use-rgb($level); - } -} diff --git a/src/theme/_theme-preset.scss b/src/theme/_theme-preset.scss deleted file mode 100644 index e5af33e..0000000 --- a/src/theme/_theme-preset.scss +++ /dev/null @@ -1,103 +0,0 @@ -@use 'sass:color'; - -@function rgb-string($color) { - @return color.red($color), color.green($color), color.blue($color); -} - -@mixin light-mode { - --ac-color-blue: #{rgb-string(#007af5)}; - --ac-color-b-0: #{rgb-string(#0067d0)}; - --ac-color-b-1: #{rgb-string(#268df6)}; - --ac-color-b-2: #{rgb-string(#4da2f8)}; - --ac-color-b-3: #{rgb-string(#66aff9)}; - --ac-color-b-4: #{rgb-string(#b3d7fc)}; - --ac-color-b-5: #{rgb-string(#cce4fd)}; - --ac-color-b-6: #{rgb-string(#e5f1fe)}; - --ac-color-b-7: #{rgb-string(#f2f8fe)}; - --ac-color-green: #{rgb-string(#00c261)}; - --ac-color-g-0: #{rgb-string(#00a552)}; - --ac-color-g-1: #{rgb-string(#26cb78)}; - --ac-color-g-2: #{rgb-string(#4cd490)}; - --ac-color-g-4: #{rgb-string(#b3eccf)}; - --ac-color-g-6: #{rgb-string(#e6f9ef)}; - --ac-color-g-7: #{rgb-string(#f2fbf6)}; - --ac-color-yellow: #{rgb-string(#f5a300)}; - --ac-color-y-0: #{rgb-string(#dc9200)}; - --ac-color-y-1: #{rgb-string(#f6b026)}; - --ac-color-y-2: #{rgb-string(#f8be4d)}; - --ac-color-y-4: #{rgb-string(#fce3b3)}; - --ac-color-y-6: #{rgb-string(#fef5e6)}; - --ac-color-y-7: #{rgb-string(#fefaf3)}; - --ac-color-red: #{rgb-string(#eb0027)}; - --ac-color-r-0: #{rgb-string(#c70021)}; - --ac-color-r-1: #{rgb-string(#ed2647)}; - --ac-color-r-2: #{rgb-string(#f14c67)}; - --ac-color-r-4: #{rgb-string(#f9b3be)}; - --ac-color-r-6: #{rgb-string(#fde6e9)}; - --ac-color-r-7: #{rgb-string(#fef3f4)}; - --ac-color-n-1: #{rgb-string(#323437)}; - --ac-color-n-2: #{rgb-string(#646669)}; - --ac-color-n-3: #{rgb-string(#7c7e81)}; - --ac-color-n-4: #{rgb-string(#96989b)}; - --ac-color-n-5: #{rgb-string(#aeb0b3)}; - --ac-color-n-6: #{rgb-string(#c8cacd)}; - --ac-color-n-7: #{rgb-string(#d4d6d9)}; - --ac-color-n-8: #{rgb-string(#edeff2)}; - --ac-color-n-9: #{rgb-string(#f7f9fc)}; - --ac-color-n-10: #{rgb-string(#fff)}; - --ac-color-origin-shadow: var(--ac-color-n-1); - --ac-color-popper-bg: var(--ac-color-n-10); - --ac-color-button-bg: var(--ac-color-n-9); - --ac-color-main-bg: var(--ac-color-n-9); - --ac-color-divider: var(--ac-color-n-8); - --ac-color-border: var(--ac-color-n-7); -} - -@mixin dark-mode { - --ac-color-blue: #{rgb-string(#3d8eff)}; - --ac-color-b-0: #{rgb-string(#3674cc)}; - --ac-color-b-1: #{rgb-string(#6daaff)}; - --ac-color-b-2: #{rgb-string(#356fc1)}; - --ac-color-b-3: #{rgb-string(#3265ad)}; - --ac-color-b-4: #{rgb-string(#2f558f)}; - --ac-color-b-5: #{rgb-string(#283651)}; - --ac-color-b-6: #{rgb-string(#2a4066)}; - --ac-color-b-7: #{rgb-string(#2c4a7a)}; - --ac-color-green: #{rgb-string(#11b671)}; - --ac-color-g-0: #{rgb-string(#159261)}; - --ac-color-g-1: #{rgb-string(#4cc894)}; - --ac-color-g-2: #{rgb-string(#168b5d)}; - --ac-color-g-4: #{rgb-string(#1b674e)}; - --ac-color-g-6: #{rgb-string(#1f4a42)}; - --ac-color-g-7: #{rgb-string(#1c5848)}; - --ac-color-yellow: #{rgb-string(#edac2c)}; - --ac-color-y-0: #{rgb-string(#ba8a2d)}; - --ac-color-y-1: #{rgb-string(#f1c060)}; - --ac-color-y-2: #{rgb-string(#b0842d)}; - --ac-color-y-4: #{rgb-string(#7e622f)}; - --ac-color-y-6: #{rgb-string(#564831)}; - --ac-color-y-7: #{rgb-string(#695530)}; - --ac-color-red: #{rgb-string(#e2324f)}; - --ac-color-r-0: #{rgb-string(#b22f48)}; - --ac-color-r-1: #{rgb-string(#e9657b)}; - --ac-color-r-2: #{rgb-string(#a82e46)}; - --ac-color-r-4: #{rgb-string(#792b3f)}; - --ac-color-r-6: #{rgb-string(#532939)}; - --ac-color-r-7: #{rgb-string(#652a3c)}; - --ac-color-n-1: #{rgb-string(#f3f4f8)}; - --ac-color-n-2: #{rgb-string(#c8c9cd)}; - --ac-color-n-3: #{rgb-string(#b8bac2)}; - --ac-color-n-4: #{rgb-string(#989aa2)}; - --ac-color-n-5: #{rgb-string(#90939f)}; - --ac-color-n-6: #{rgb-string(#787b87)}; - --ac-color-n-7: #{rgb-string(#5c5f6b)}; - --ac-color-n-8: #{rgb-string(#434652)}; - --ac-color-n-9: #{rgb-string(#181b27)}; - --ac-color-n-10: #{rgb-string(#242733)}; - --ac-color-origin-shadow: var(--ac-color-n-9); - --ac-color-popper-bg: #{rgb-string(#383b47)}; - --ac-color-button-bg: #{rgb-string(#383b47)}; - --ac-color-main-bg: var(--ac-color-n-9); - --ac-color-divider: var(--ac-color-n-8); - --ac-color-border: var(--ac-color-n-7); -} diff --git a/src/theme/dark.ts b/src/theme/dark.ts new file mode 100644 index 0000000..4c46d64 --- /dev/null +++ b/src/theme/dark.ts @@ -0,0 +1,89 @@ +import { Theme, ThemeOptions } from '../index.js'; + +const COLORS = { + blue: '#3d8eff', + 'b-0': '#3674cc', + 'b-1': '#6daaff', + 'b-2': '#356fc1', + 'b-3': '#3265ad', + 'b-4': '#2f558f', + 'b-5': '#283651', + 'b-6': '#2a4066', + 'b-7': '#2c4a7a', + green: '#11b671', + 'g-0': '#159261', + 'g-1': '#4cc894', + 'g-2': '#168b5d', + 'g-4': '#1b674e', + 'g-6': '#1f4a42', + 'g-7': '#1c5848', + yellow: '#edac2c', + 'y-0': '#ba8a2d', + 'y-1': '#f1c060', + 'y-2': '#b0842d', + 'y-4': '#7e622f', + 'y-6': '#564831', + 'y-7': '#695530', + red: '#e2324f', + 'r-0': '#b22f48', + 'r-1': '#e9657b', + 'r-2': '#a82e46', + 'r-4': '#792b3f', + 'r-6': '#532939', + 'r-7': '#652a3c', + 'n-1': '#f3f4f8', + 'n-2': '#c8c9cd', + 'n-3': '#b8bac2', + 'n-4': '#989aa2', + 'n-5': '#90939f', + 'n-6': '#787b87', + 'n-7': '#5c5f6b', + 'n-8': '#434652', + 'n-9': '#181b27', + 'n-10': '#242733', +} as const; + +const LEGEND = {}; + +const TITLE = {}; + +const AXIS = { + stroke: COLORS['n-2'], + gridStroke: COLORS['n-8'], + tickStroke: COLORS['n-8'], +}; + +const SHAPE = { + point: { + size: 5, + }, +}; + +const TOOLTIP = { + background: COLORS['n-10'], + color: COLORS['n-2'], + activeBg: COLORS['b-6'], +}; + +const GAUGE = { + textColor: COLORS['n-1'], + descriptionColor: COLORS['n-2'], +}; + +/** + * Dark theme. + */ +export const Dark = (options?: ThemeOptions): ThemeOptions => { + const defaultOptions: Theme = { + colorVar: COLORS, + type: 'dark', + legend: LEGEND, + title: TITLE, + xAxis: AXIS, + yAxis: AXIS, + shape: SHAPE, + tooltip: TOOLTIP, + gauge: GAUGE, + }; + return { ...defaultOptions, ...options }; +}; diff --git a/src/theme/default.scss b/src/theme/default.scss deleted file mode 100644 index fd6b611..0000000 --- a/src/theme/default.scss +++ /dev/null @@ -1,179 +0,0 @@ -@import 'base-var'; -@import 'theme-preset'; -$ac-prefix: 'ac'; - -@mixin theme-light { - $host: &; - - @at-root { - :root #{$host} { - @content; - } - - html[ac-theme-mode='light'] #{$host} { - @content; - } - } -} - -@mixin theme-dark { - $host: &; - - @at-root { - @media (prefers-color-scheme: dark) { - html[ac-theme-mode='system'] #{$host} { - @content; - } - } - - html[ac-theme-mode='dark'] #{$host} { - @content; - } - } -} - -@include theme-light { - .#{$ac-prefix}-chart-wrapper { - @include light-mode; - } -} - -@include theme-dark { - .#{$ac-prefix}-chart-wrapper { - @include dark-mode; - } -} - -.#{$ac-prefix}-chart-wrapper { - @include theme-light; -} - -.#{$ac-prefix} { - &-header { - display: flex; - align-items: center; - } - - &-title { - font-size: 18px; - flex: 1; - fill: use-rgb(n-1); - } - - &-legend-item { - text { - font-size: 12px; - fill: use-rgb(n-2); - } - - &-hidden { - text, - .#{$ac-prefix}-legend-icon { - fill: use-rgb(n-6); - } - } - } - - &-x-axis, - &-y-axis { - .tick-line, - .domain { - stroke: use-rgb(n-8); - } - - path, - .tick-aim, - line { - stroke: use-rgb(n-7); - } - - text { - color: use-rgb(n-2); - font-size: 12px; - } - } - - &-tooltip { - position: absolute; - visibility: visible; - z-index: 8; - box-shadow: 0 2px 8px 0 rgb(0 0 0 / 16%); - background-color: use-rgb(n-10); - padding: 12px 8px 10px 14px; - left: 0; - top: 0; - color: use-rgb(n-2); - font-size: 12px; - - &-title { - margin-bottom: 8px; - font-weight: 500; - } - - ul, - li { - margin: 0; - list-style: none; - } - - &-list { - padding: 0; - } - .#{$ac-prefix}-tooltip-icon { - margin-right: 4px; - width: 16px; - height: 2px; - background-color: var(--ac-color-n-10); - display: inline-block; - font-size: 6px; - } - .#{$ac-prefix}-tooltip-icon-circle { - width: 6px; - height: 6px; - border-radius: 50%; - } - .#{$ac-prefix}-tooltip-left { - margin-right: 16px; - display: flex; - align-items: center; - } - .#{$ac-prefix}-tooltip-name { - max-width: 197px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .#{$ac-prefix}-tooltip-right { - text-align: right; - white-space: nowrap; - flex: 1 1 auto; - } - - &-list-item { - display: flex; - align-items: center; - padding: 0 8px; - - &:not(:last-child) { - margin-bottom: 6px; - } - } - - &-list-item-activated { - background-color: use-rgb(p-6); - } - } - - &-zoom-brush { - fill-opacity: 0.2; - fill: use-rgb(n-6); - } - - &-y-plot-line-tip-text { - padding: 0 8px; - border: 1px solid use-rgb(n-7); - font-size: 12px; - color: #666; - white-space: nowrap; - } -} diff --git a/src/theme/index.ts b/src/theme/index.ts new file mode 100644 index 0000000..6f7687a --- /dev/null +++ b/src/theme/index.ts @@ -0,0 +1,30 @@ +import { get } from 'lodash-es'; + +import { ThemeOptions } from '../index.js'; + +import { Light } from './light.js'; + +export { Dark } from './dark.js'; +export { Light } from './light.js'; + +// 所有已经存在的主题 +const Themes: Record = { + default: Light(), +}; + +/** + * 获取主题配置信息。 + * @param theme 主题名 + */ +export function getTheme(theme: string, option?: ThemeOptions): ThemeOptions { + return Object.assign(get(Themes, theme, Themes.default), option); +} + +/** + * 注册新的主题配置信息。 + * @param name 主题名。 + * @param value 具体的主题配置。 + */ +export function registerTheme(name: string, value: ThemeOptions) { + Themes[name] = value; +} diff --git a/src/theme/light.ts b/src/theme/light.ts new file mode 100644 index 0000000..2387b9f --- /dev/null +++ b/src/theme/light.ts @@ -0,0 +1,88 @@ +import { Theme, ThemeOptions } from '../index.js'; + +const COLORS = { + blue: '#007af5', + 'b-0': '#0067d0', + 'b-1': '#268df6', + 'b-2': '#4da2f8', + 'b-3': '#66aff9', + 'b-4': '#b3d7fc', + 'b-5': '#cce4fd', + 'b-6': '#e5f1fe', + 'b-7': '#f2f8fe', + green: '#00c261', + 'g-0': '#00a552', + 'g-1': '#26cb78', + 'g-2': '#4cd490', + 'g-4': '#b3eccf', + 'g-6': '#e6f9ef', + 'g-7': '#f2fbf6', + yellow: '#f5a300', + 'y-0': '#dc9200', + 'y-1': '#f6b026', + 'y-2': '#f8be4d', + 'y-4': '#fce3b3', + 'y-6': '#fef5e6', + 'y-7': '#fefaf3', + red: '#eb0027', + 'r-0': '#c70021', + 'r-1': '#ed2647', + 'r-2': '#f14c67', + 'r-4': '#f9b3be', + 'r-6': '#fde6e9', + 'r-7': '#fef3f4', + 'n-1': '#323437', + 'n-2': '#646669', + 'n-3': '#7c7e81', + 'n-4': '#96989b', + 'n-5': '#aeb0b3', + 'n-6': '#c8cacd', + 'n-7': '#d4d6d9', + 'n-8': '#edeff2', + 'n-9': '#f7f9fc', + 'n-10': '#fff', +}; + +const LEGEND = {}; + +const TITLE = {}; + +const AXIS = { + stroke: COLORS['n-2'], + gridStroke: COLORS['n-8'], + tickStroke: COLORS['n-8'], +}; + +const SHAPE = { + point: { + size: 5, + }, +}; + +const TOOLTIP = { + background: COLORS['n-10'], + color: COLORS['n-2'], + activeBg: COLORS['b-6'], +}; + +const GAUGE = { + textColor: COLORS['n-1'], + descriptionColor: COLORS['n-2'], +}; + +/** + * Light theme. + */ +export const Light = (options?: ThemeOptions): ThemeOptions => { + const defaultOptions: Theme = { + colorVar: COLORS, + legend: LEGEND, + title: TITLE, + xAxis: AXIS, + yAxis: AXIS, + shape: SHAPE, + tooltip: TOOLTIP, + gauge: GAUGE, + }; + return { ...defaultOptions, ...options }; +}; diff --git a/src/types/chart.ts b/src/types/chart.ts new file mode 100644 index 0000000..66cc22f --- /dev/null +++ b/src/types/chart.ts @@ -0,0 +1,51 @@ +export enum ChartEvent { + // theme + THEME_CHANGE = 'theme:change', + + HOOKS_REDRAW = 'hooks:redraw', + + // uPlot hooks + U_PLOT_READY = 'uPlot:ready', + U_PLOT_SET_CURSOR = 'uPlot:setCursor', + + // plot + PLOT_MOUSEMOVE = 'plot:mousemove', + PLOT_MOUSELEAVE = 'plot:mouseleave', + PLOT_CLICK = 'plot:click', + PLOT_MOUSEDOWN = 'plot:mousedown', + PLOT_MOUSEUP = 'plot:mouseup', + + // element + ELEMENT_MOUSEMOVE = 'element:mousemove', + ELEMENT_MOUSELEAVE = 'element:mouseleave', + + // legend + LEGEND_ITEM_CLICK = 'legend-item:click', + LEGEND_ITEM_HOVER = 'legend-item:hover', + + // shape + SHAPE_CHANGE = 'shape:change', + + // data + DATA_CHANGE = 'data:change', +} + +/** + * 布局方位 + */ +export enum DIRECTION { + TOP = 'top', + TOP_LEFT = 'top-left', + TOP_RIGHT = 'top-right', + // RIGHT = 'right', + // RIGHT_TOP = 'right-top', + // RIGHT_BOTTOM = 'right-bottom', + // LEFT = 'left', + // LEFT_TOP = 'left-top', + // LEFT_BOTTOM = 'left-bottom', + BOTTOM = 'bottom', + BOTTOM_LEFT = 'bottom-left', + BOTTOM_RIGHT = 'bottom-right', + // no direction information + NONE = 'none', +} diff --git a/src/types/common.ts b/src/types/common.ts index daa278f..0490678 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -1,45 +1,9 @@ -import { NumberValue, Selection } from 'd3'; - -import { ChartData, Options } from '../types/index.js'; - -export type D3EelSelection = Selection; - -export type D3SvgSSelection = Selection< - SVGSVGElement, - unknown, - null, - undefined ->; - -export type D3SvgGSelection = Selection; - -export type D3Selection = Selection; - -export type D3ChartSelection = Selection; - -export type XScaleValue = string & (NumberValue & (Date | NumberValue)); - -export interface ChartEle { - chart: D3EelSelection; - header: D3EelSelection; - svg: D3SvgSSelection; - main: D3SvgGSelection; - title?: D3SvgGSelection; - legend?: D3Selection; - tooltip?: D3Selection; -} - -export interface ChartSize { +export interface Size { width: number; height: number; } -export interface ViewProps { - ele: D3EelSelection; - svg: D3SvgSSelection; - header: D3EelSelection; - size: ChartSize; - options: Options; +export interface BrushContext { + start: number; + end: number; } - -export type Theme = 'light' | 'dark' | 'system'; diff --git a/src/types/component.ts b/src/types/component.ts new file mode 100644 index 0000000..f747b93 --- /dev/null +++ b/src/types/component.ts @@ -0,0 +1,40 @@ +export interface LegendItemActive { + name: string; + activated: boolean; +} + +interface Coordinates { + top?: number; + bottom?: number; + left?: number; + right?: number; +} + +export type Placement = + | 'top' + | 'top-start' + | 'top-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'right' + | 'right-start' + | 'right-end' + | 'left' + | 'left-start' + | 'left-end'; + +export interface TooltipItemActive { + anchor: Element | Range | Coordinates; + bound: Element | Range | Coordinates; + title: string; + position?: Placement; + values: TooltipValue[]; +} + +export interface TooltipValue { + name: string; + color: string; + value: number; + activated?: boolean; +} diff --git a/src/types/helpers.ts b/src/types/helpers.ts index b873167..7001be8 100644 --- a/src/types/helpers.ts +++ b/src/types/helpers.ts @@ -1,2 +1,15 @@ -export type Nilable = T | null | undefined; export type Percentage = `${number}%`; + +export type ValueOf = T extends { + [key: string]: infer M; +} + ? M + : T extends { + [key: number]: infer N; + } + ? N + : never; + +export type Nil = null | undefined | void; + +export type Nilable = Nil | T; diff --git a/src/types/index.ts b/src/types/index.ts index 7fedfd6..23a6aa0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,7 @@ +export * from './chart.js'; export * from './common.js'; +export * from './component.js'; export * from './helpers.js'; +export * from './interaction.js'; export * from './options.js'; -export * from './tooltip.js'; +export * from './theme.js'; diff --git a/src/types/interaction.ts b/src/types/interaction.ts new file mode 100644 index 0000000..3d46956 --- /dev/null +++ b/src/types/interaction.ts @@ -0,0 +1,67 @@ +import { Action } from '../interaction/action/action.js'; + +import { ChartEvent as TriggerType } from './chart.js'; + +export interface InteractionSteps { + /** + * 交互开始 + */ + start?: InteractionStep[]; + + /** + * 交互结束 + */ + end?: InteractionStep[]; +} + +export interface ActionObject { + action: Action; + methodName: string; +} + +export interface InteractionStep { + /** + * 触发事件,支持 view,chart 的各种事件 + */ + trigger: string | TriggerType; + + /** + * @private + * 存储 action callback + */ + actionObject?: ActionObject; + + /** + * action 名称 (组件:动作) + */ + action: string | ActionType; + + /** + * 回调函数,action 执行后执行 + */ + callback?: (context: unknown) => void; + + // TODO: throttle debounce once +} +// TODO: 同时支持 string, string[], ()=>{} 三种方法是 +// export type StepAction = string | string[] | ActionType | ActionType[]; + +// 交互 触发类型 [区域:动作] + +// 交互 动作类型 [组件:动作] +export enum ActionType { + // tooltip + TOOLTIP_SHOW = 'tooltip:show', + TOOLTIP_HIDE = 'tooltip:hide', + + // element + ELEMENT_ACTIVE = 'element-active:active', + ELEMENT_RESET = 'element-active:reset', + + // legend + LEGEND_TOGGLE = 'legend:toggle', + + // brush + BRUSH_X_START = 'brush-x:start', + BRUSH_X_END = 'brush-x:end', +} diff --git a/src/types/options.ts b/src/types/options.ts index b058991..1456ad6 100644 --- a/src/types/options.ts +++ b/src/types/options.ts @@ -1,211 +1,245 @@ -import { CurveFactory } from 'd3'; +import uPlot, { Padding as UPadding } from 'uplot'; -import { View } from '../chart/index.js'; -import { LegendItem, AreaParams } from '../components/index.js'; +import { AdjustOption } from '../components/shape/bar.js'; +import { SizeCallback } from '../components/shape/point.js'; -import { Percentage } from './helpers.js'; -import { TooltipContext, TooltipContextItem } from './tooltip.js'; +import { TooltipValue } from './component.js'; -export enum ScaleType { - TIME = 'time', - LINEAR = 'linear', - ORDINAL = 'ordinal', -} - -export type ChartType = 'area' | 'bar' | 'line' | 'pie' | 'scatter'; +import { Theme } from './index.js'; -export type ChartSeriesOption = - | AreaSeriesOption - | BarSeriesOption - | LineSeriesOption - | PieSeriesOption - | ScatterOption; +// eslint-disable-next-line no-restricted-syntax +export type Padding = UPadding; -// 提供setOptions -export interface Options { +export interface ChartOption { + container: string | HTMLElement; + data?: Data; + autoFit?: boolean; // true + // 图表宽高度 不设置默认根据父容器高度自适应 width?: number; height?: number; - offset?: { x?: number; y?: number }; - grid?: { top?: number }; - customHeader?: boolean; - container: HTMLElement | string; - type?: ChartType; - data?: ChartData[]; // 数据源 + // 图表内边距 上 右 下 左 不包含 header + padding?: Padding; // [16,0,0,0] + // 默认交互 ['tooltip', 'legend-filter', 'legend-active'] + defaultInteractions?: string[]; + // 图表组件等相关的配置。同时支持配置式 和 声明式 + options?: Options; + /** 主题 */ + theme?: Theme; // default system +} + +export interface ViewOption { + readonly ele: HTMLElement; + readonly chartEle: HTMLElement; + readonly chartOption: ChartOption; + width?: number; + height?: number; + padding?: Padding; + data?: Data; + options?: Options; + defaultInteractions: string[]; + /** 主题 */ + theme?: Theme; // default system +} + +export interface Options { + readonly padding?: Padding; + readonly data?: Data; + title?: TitleOption; legend?: LegendOption; - xAxis?: AxisOption; - yAxis?: AxisOption; tooltip?: TooltipOption; - rotated?: boolean; // x y 轴旋转 - colors?: string[]; // 根据数组中循环展示颜色 - seriesOption?: ChartSeriesOption; - zoom?: ZoomOption; - contextCallbackFunction?: (view: View) => void; - yPlotLine?: YPlotLineOptions; - xPlotLine?: XPlotLineOptions; + annotation?: AnnotationOption; + scale?: { + x?: ScaleOption; + y?: ScaleOption; + }; + axis?: { + x?: AxisOption; + y?: AxisOption; + }; + coordinate?: CoordinateOption; + line?: LineShapeOption; + area?: AreaShapeOption; + bar?: BarShapeOption; + point?: PointShapeOption; + gauge?: GaugeShapeOption; } -export interface XPlotLineOptions { - hide?: boolean; - value?: number; - text?: string; +export type Data = DataItem[]; + +export interface DataItem { + name: string; + id?: string; color?: string; - dashType?: 'dash' | 'solid'; + value?: number; + // type-coverage:ignore-next-line + values?: Array<{ x: any; y: number; size?: number }>; } -export interface YPlotLineOptions { - hide?: boolean; - value?: TooltipContext; +export type TitleOption = TitleOpt | false; +export interface TitleOpt { + custom?: boolean; text?: string; - dashType?: 'dash' | 'solid'; - textFormatter?: string | ((text: string) => string); + formatter?: string | ((text: string) => string); } -export interface ZoomOption { - enabled: boolean; - onzoomStart?(d: AreaParams): void; +export type LegendOption = LegendOpt | boolean; - onzoom?(d: AreaParams): void; +export type LegendPosition = + | 'top' + | 'top-left' + | 'top-right' + | 'bottom' + | 'bottom-left' + | 'bottom-right'; - onzoomEnd?(d: AreaParams): void; -} - -export interface TitleOption { - text?: string; - offsetX?: number; - offsetY?: number; - hide?: boolean; - formatter?: string | ((text: string) => string); +export interface LegendOpt { + custom?: boolean; + position?: LegendPosition; } -export interface LegendOption { - hide?: boolean; - // template: string => 'legend {name}' fn => custom 插入 dom - formatter?: string | ((d: LegendItem[]) => string); - itemFormatter?: string | ((name: string) => string); - offsetX?: number; - offsetY?: number; - isMount?: boolean; - // textStyle?: {} // TODO - onClick?: () => void; // chart.on TODO -} - -// undefined -export interface AxisOption { - offsetX?: number; - offsetY?: number; - tickFormatter?: string | ((value?: any) => string | ((value: any) => string)); // 'xxx {value}' - type?: ScaleType; +export interface ScaleOption { + time?: boolean; // true min?: number; max?: number; - minStep?: number; // axis 最小步宽,例如整数可以设置为 1 - tickCount?: number; // 设置 坐标 tick 总数 - ticks?: unknown; // 完全控制的d3.ticks,如果要传递多个参数,可以使用数组形式,参见 https://observablehq.com/@d3/axis-ticks?collection=@d3/d3-axis -} - -export interface TooltipOption { - hideTitle?: boolean; - titleFormatter?: string | ((value: TooltipContext) => string); - itemFormatter?: (value: TooltipContextItem[]) => string; - nameFormatter?: string | ((value: TooltipContextItem) => string); - valueFormatter?: string | ((value: TooltipContextItem) => string); // 添加 行 formatter - sort?: (a: Data, b: Data) => number; // 支持 sort 函数 - disabled?: boolean; - trigger?: 'axis' | 'item' | 'none'; -} - -export interface LineSeriesOption { - // https://d3js.org.cn/document/d3-shape/#curves - curveType?: CurveFactory; // export d3 curve 业务使用 curve - lineWidth?: number; - activeLineWidth?: number; -} - -export interface AreaSeriesOption { - curveType?: CurveFactory; - lineWidth?: number; - activeLineWidth?: number; - startOpacity?: number; - endOpacity?: number; -} - -export interface BarSeriesOption { - stack?: boolean; - rotated?: boolean; // x y 轴旋转 - barWidth?: number; - padding?: number; // bar 相邻 padding - radius?: number; // 圆角 - bandwidth?: number; // bar 宽度 - closeRadiusLadder?: boolean; // 关闭叠加 bar 圆角是否阶阶梯优化 - isGroup?: boolean; // 是否分组 [{name: 'xxx1'}, {name: 'xxx2'}] - columnClick?: (data: BarColumnParams) => void; - minHeight?: number; // 单个柱状的高度,如果 isGroup 为 true 时不生效 -} - -export interface BarColumnParams { - name: string | number | Date; - value: number; - color?: string; } -export interface PieSeriesOption { - innerRadius?: number | Percentage; - outerRadius?: number | Percentage; - startAngle?: number; - endAngle?: number; +export type CoordinateOption = CoordinateOpt | boolean; + +export interface CoordinateOpt { + transposed?: boolean; +} + +export type AxisOption = AxisOpt | boolean; +export interface AxisOpt { + show?: boolean; + autoSize?: boolean; // 默认 true + formatter?: + | string + | ((value: string | number, uPlotParams?: unknown) => string); +} + +export type TooltipOption = TooltipOpt | boolean; +export interface TooltipOpt { + showTitle?: boolean; + popupContainer?: HTMLElement; // tooltip 渲染父节点 默认 body + titleFormatter?: string | ((title: string, values: TooltipValue[]) => string); + nameFormatter?: string | ((name: string, data?: TooltipValue) => string); + valueFormatter?: string | ((value: number, data?: TooltipValue) => string); + itemFormatter?: (value: TooltipValue[]) => string | TooltipValue[] | Element; + sort?: (a: TooltipValue, b: TooltipValue) => number; +} + +export interface ShapeOption extends uPlot.Series { + name?: string; // 指定 data name + connectNulls?: boolean; // 是否链接空值 默认 false + // points?: Omit | boolean; // 默认 false + width?: number; // 线宽 + alpha?: number; + map?: string; +} + +export interface LineShapeOption extends ShapeOption { + step?: 'start' | 'end'; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AreaShapeOption extends ShapeOption {} + +export interface BarShapeOption extends ShapeOption { + adjust?: AdjustOption; +} + +export interface PointShapeOption extends ShapeOption { + pointSize?: number; + sizeField?: string; + sizeCallback?: SizeCallback; +} + +export interface PieShapeOption { + innerRadius?: number; // 内半径 0 - 1 + outerRadius?: number; // 外半径 + startAngle?: number; // 开始角度 + endAngle?: number; // 结束角度 + padAngle?: number; label?: { - text?: string; + text?: string | ((value: number, total?: number) => string); + description?: string | ((data: Data) => string); position?: { - x?: string; - y?: string; + x?: number; + y?: number; }; }; - // 指定总量 - total?: number; + labelLine?: { + labels?: Array<'name' | 'value' | 'percent'>; + show?: boolean; + formatter?: + | string + | ((name: string, value: number, percent: number) => string); + }; + total?: number; // 指定总量 backgroundColor?: string; itemStyle?: { - borderRadius?: number; - borderWidth?: number; + borderRadius?: number; // item 圆角 + borderWidth?: number; // item间隔宽度 }; - innerDisc?: boolean; + innerDisc?: boolean; // 内阴影盘 } -export interface ScatterOption { - size?: number; // 圆大小 默认 5 - minSize?: number; // 默认 5 - maxSize?: number; // 默认 20 - type?: 'bubble'; // 设置 bubble 会默认使用 气泡样式渲染 - opacity?: number; // bubble 透明度 默认 0.2 -} - -export interface ChartData { - name: string; - color?: string; - value?: number; - values?: Array>; -} - -export type Data = T & - ( - | { - value?: number; - } - | { - x: Date | number | string; - y: number; - color?: string; - } - ); - -export interface XData { - x: Date | number | string; - y: number; - name?: string; - size?: number; - color?: string; +export interface GaugeShapeOption { + innerRadius?: number; // 内半径 0 - 1 + outerRadius?: number; // 外半径 + max?: number; // 100 + colors?: Array<[number, string]>; // 指定颜色 [数值, color] + label?: { + text?: string | ((data: Data, total?: number) => string); + description?: string | ((data: Data) => string); + position?: { + x?: number; + y?: number; + }; + textStyle?: { + color?: string; + }; + descriptionStyle?: { + color?: string; + }; + }; + text?: { + show?: boolean; // true, + size?: number; // 12 + color?: string | ((value: number) => string); // n-4 + }; } -export interface Point { - x: number; - y: number; +export type ShapeOptions = + | LineShapeOption + | AreaShapeOption + | BarShapeOption + | PointShapeOption; + +export interface AnnotationOption { + lineX?: AnnotationLineOption; + areaX?: AnnotationLineOption; + lineY?: AnnotationLineOption[]; + areaY?: AnnotationLineOption[]; +} + +export interface AnnotationLineOption { + data: string | number; + text?: { + position?: 'left' | 'right' | string; // + content: unknown; + style?: object; + border?: { + style?: string; + padding?: [number, number]; + }; + }; + style?: { + line?: boolean; + stroke?: string; + width?: number; + lineDash?: [number, number]; + }; } diff --git a/src/types/theme.ts b/src/types/theme.ts new file mode 100644 index 0000000..d5b256d --- /dev/null +++ b/src/types/theme.ts @@ -0,0 +1,86 @@ +export type Theme = LightTheme | DarkTheme | CustomTheme | SystemTheme; + +/************************************************ + * 待确定 * + ************************************************/ +interface Legend { + width?: number; + stroke?: string; + fill?: string; +} + +interface Title { + width?: number; + stroke?: string; + fill?: string; +} + +/***********************************************************/ +interface Axis { + stroke?: string; // 轴值颜色 + font?: string; // 轴值字体 + tickWidth?: number; // 刻度宽 + tickStroke?: string; // 刻度颜色 + gridWidth?: number; // 网格宽度 + gridStroke?: string; // 网格颜色 +} + +interface Shape { + stroke?: string; // 颜色 + width?: string; // 宽度 + fill?: string; // 填充色 + point?: Point; +} + +interface Point { + size?: number; // 直径 +} + +interface Tooltip { + background?: string; + color?: string; + activeBg?: string; +} + +export interface ThemeOptions { + colorVar: Record; + backgroundColor?: string; + + // 标题 + title?: Title; + // 图例 + legend?: Legend; + + // 坐标 + axis?: Axis; + xAxis?: Axis; + yAxis?: Axis; + + // 图形 + shape?: Shape; + + // tooltip + tooltip?: Tooltip; + + gauge?: Gauge; +} +interface Gauge { + textColor: string; + descriptionColor: string; +} + +export type LightTheme = { + type?: 'light'; +} & ThemeOptions; + +export type DarkTheme = { + type?: 'dark'; +} & ThemeOptions; + +export type CustomTheme = { + type?: string; +} & ThemeOptions; + +export type SystemTheme = { + type?: 'system'; +} & ThemeOptions; diff --git a/src/types/tooltip.ts b/src/types/tooltip.ts deleted file mode 100644 index e552580..0000000 --- a/src/types/tooltip.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { XData } from './options.js'; - -export interface TooltipContext { - title: Date | number | string; - values: TooltipContextItem[]; -} -export interface TooltipContextItem extends XData { - name: string; - color: string; - x: string; - y: number; - activated?: boolean; -} diff --git a/src/utils/color.ts b/src/utils/color.ts new file mode 100644 index 0000000..bf58109 --- /dev/null +++ b/src/utils/color.ts @@ -0,0 +1,44 @@ +/** + * 十六进制转换为 RGBA + * @param hex + * @param alpha + * @see https://stackoverflow.com/questions/21646738/convert-hex-to-rgba + * @see https://github.com/bgrins/TinyColor + * @returns string rgba + */ + +import { trim } from 'lodash-es'; + +// eslint-disable-next-line regexp/no-unused-capturing-group +const isValidHex = (hex: string) => /^#([\dA-Fa-f]{3,4}){1,2}$/.test(hex); + +const convertHexUnitTo256 = (hexStr: string) => + parseInt(hexStr.repeat(2 / hexStr.length), 16); + +const getAlphafloat = (a: number, alpha: number) => { + if (typeof a !== 'undefined') { + return a / 255; + } + if (typeof alpha !== 'number' || alpha < 0 || alpha > 1) { + return 1; + } + return alpha; +}; + +export function convertRgba(hex: string, alpha = 1) { + if (hex.includes('var')) { + const varColorStr = hex.replace(/^rgb\(var\(*/, '').replace(/\)\)/, ''); + const varColor = getComputedStyle(document.body).getPropertyValue( + varColorStr, + ); + const [r, g, b] = varColor.split(',').map(d => trim(d)); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + if (!isValidHex(hex)) { + return hex; + } + const chunkSize = Math.floor((hex.length - 1) / 3); + const hexArr = hex.slice(1).match(new RegExp(`.{${chunkSize}}`, 'g')); + const [r, g, b, a] = hexArr.map(convertHexUnitTo256); + return `rgba(${r}, ${g}, ${b}, ${getAlphafloat(a, alpha)})`; +} diff --git a/src/utils/component.ts b/src/utils/component.ts new file mode 100644 index 0000000..92baf4c --- /dev/null +++ b/src/utils/component.ts @@ -0,0 +1,20 @@ +import { ValueOf } from '../index.js'; + +export const ShapeType = { + Line: 'line', + Area: 'area', + Bar: 'bar', + Point: 'point', +} as const; + +export type ShapeType = ValueOf; + +export const SHAPE_TYPES = Object.values(ShapeType); + +export const PolarShapeType = { + Pie: 'pie', + Gauge: 'gauge', +}; +export type PolarShapeType = ValueOf; + +export const POLAR_SHAPE_TYPES = Object.values(PolarShapeType); diff --git a/src/utils/constant.ts b/src/utils/constant.ts new file mode 100644 index 0000000..d8d6767 --- /dev/null +++ b/src/utils/constant.ts @@ -0,0 +1,37 @@ +export const CHART_PREFIX = 'achart'; +export const HYPHEN = '-'; +export const NOT_AVAILABLE = HYPHEN; + +export const DEFAULT_COLORS = [ + '#006eff', + '#24b37a', + '#8b37c1', + '#ffbb00', + '#d42d3d', + '#1fc0cc', + '#a5d936', + '#d563c4', + '#c55a05', + '#6b8fbb', + '#1292d2', + '#36d940', + '#ea0abb', + '#ead925', + '#b0b55c', +]; + +export enum INTERACTION_TYPE { + TOOLTIP = 'tooltip', + ELEMENT_ACTIVE = 'element-active', + + // legend + LEGEND_FILTER = 'legend-filter', + LEGEND_ACTIVE = 'legend-active', +} + +export const DEFAULT_INTERACTIONS = [ + INTERACTION_TYPE.TOOLTIP, + // INTERACTION_TYPE.ELEMENT_ACTIVE, + // INTERACTION_TYPE.LEGEND_FILTER, + INTERACTION_TYPE.LEGEND_ACTIVE, +]; diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 6fcbe50..b4e7c30 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -1,9 +1,20 @@ -import { ChartSize } from '../types/index.js'; +import { select } from 'd3'; +import { debounce } from 'lodash-es'; + +import { Size } from '../index.js'; + +export function getElementSize(ele: Element | HTMLElement): Size { + const style = getComputedStyle(ele); -function getElementSize(ele: Element | HTMLElement) { return { - width: ele.clientWidth, - height: ele.clientHeight, + width: + (ele.clientWidth || parseInt(style.width, 10)) - + parseInt(style.paddingLeft, 10) - + parseInt(style.paddingRight, 10), + height: + (ele.clientHeight || parseInt(style.height, 10)) - + parseInt(style.paddingTop, 10) - + parseInt(style.paddingBottom, 10), }; } @@ -11,10 +22,10 @@ export function getChartSize( ele: Element | HTMLElement, width = 0, height = 0, -): ChartSize { +) { let w = width || 0; let h = height || 0; - if (!w && !h) { + if (!w && !h && ele) { const size = getElementSize(ele); w = size.width || w; @@ -26,8 +37,46 @@ export function getChartSize( }; } -export function getElement(container: HTMLElement | string) { +export function getElement(container: HTMLElement | string): HTMLElement { return typeof container === 'string' ? document.querySelector(container) : container; } + +export function transformD3El(dom: HTMLElement) { + return select(dom); +} + +export function getPixel(value: string | number) { + return typeof +value === 'number' && !isNaN(+value) ? `${value}px` : value; +} + +export function resizeObserver( + el: HTMLElement, + fn: (size: Size) => void, +): ResizeObserver { + const resizeObserver = new ResizeObserver( + debounce(([entry]: ResizeObserverEntry[]) => { + const { width, height } = entry.contentRect; + if (width !== 0 || height !== 0) { + const size = { width, height }; + fn(size); + } + }, 200), + ); + resizeObserver.observe(el); + return resizeObserver; +} + +export function createSvg( + el: d3.Selection, + width?: number, + height?: number, +) { + return el + .append('svg') + .style('width', width || '100%') + .style('height', height || '100%') + .style('overflow', 'hidden') + .style('display', 'block'); +} diff --git a/src/utils/helper.ts b/src/utils/helper.ts index bee0409..a0309e2 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -1,6 +1,5 @@ -import { isNumber } from 'lodash'; +import { isNumber } from 'lodash-es'; -import { View } from '../chart/index.js'; import { Percentage } from '../types/index.js'; export function getPos( @@ -15,22 +14,6 @@ export function getPos( : e.pageX - ((rectDom || svgE).getBoundingClientRect().left + scrollLeft); } -export function findClosestPointIndex( - xPos: number, - owner: View, - isRotated: boolean, -) { - // 数组可能出现长度不一致情况 - const max = owner.chartData.reduce( - (prev, curr) => (prev > curr.values.length ? prev : curr.values.length), - 0, - ); - const count = owner.isBar && owner.isGroup ? owner.chartData.length : max; - const w = isRotated ? owner.size.grid.height : owner.size.grid.width; - const idx = Math.floor((xPos * count) / w); - return Math.min(Math.max(0, idx), count); -} - export function isPercentage(num: number | string): num is Percentage { return !isNumber(num) && num.endsWith('%'); } diff --git a/src/utils/index.ts b/src/utils/index.ts index 3fa9484..c2de9c9 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,5 @@ +export * from './color.js'; +export * from './component.js'; export * from './dom.js'; export * from './helper.js'; export * from './style.js'; diff --git a/src/utils/unit.ts b/src/utils/unit.ts index 99be6e9..fa5d9cd 100644 --- a/src/utils/unit.ts +++ b/src/utils/unit.ts @@ -1,31 +1,14 @@ -import { template as _template } from 'lodash'; +import { template as _template } from 'lodash-es'; -import { DEFAULT_COLORS } from '../constant.js'; -import { Data, XData } from '../types/index.js'; +import { CHART_PREFIX, DEFAULT_COLORS } from './constant.js'; export function getChartColor(index: number) { const colorIndex = index % DEFAULT_COLORS.length; return DEFAULT_COLORS[colorIndex]; } -/** - * Generates a short id. - * http://stackoverflow.com/questions/6248666/how-to-generate-short-uid-like-ax4j9z-in-js - */ -const X = 36; -const Y = 10; -export function generateUID(): string { - const newId = ( - '0000' + Math.trunc(Math.random() * Math.pow(X, Y)).toString(X) - ).slice(-Y); - // append a 'a' because neo gets mad - return `a${newId}`; -} - -const NULL_TYPE: Set = new Set([null, undefined]); - -export function defined(d: Data) { - return !(NULL_TYPE.has(d.x) || NULL_TYPE.has(d.y)); +export function generateName(name: string) { + return `${CHART_PREFIX}-${name}`; } const TEMPLATE_OPTIONS = { @@ -36,41 +19,3 @@ const TEMPLATE_OPTIONS = { export function template(str: string, data: object) { return _template(str, TEMPLATE_OPTIONS)(data); } - -export const resizeOn = ( - target: T, - fn: (entry: ResizeObserverEntry) => void, - options?: ResizeObserverOptions, -) => { - const resizeObserver = new ResizeObserver(entries => { - for (const entry of entries) { - fn(entry); - } - }); - resizeObserver.observe(target, options); - return () => { - resizeObserver.unobserve(target); - resizeObserver.disconnect(); - }; -}; - -export function getTextWidth(text: string | number, font = '12px arial') { - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - context.font = font; - const metrics = context.measureText(text ? String(text) : ''); - canvas.remove(); - return Math.ceil(metrics.width); -} - -export function hexToRGB(hex: string, alpha: number) { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - - if (alpha) { - return `rgba(${r}, ${g}, ${b}, ${alpha})`; - } - - return `rgb(${r}, ${g}, ${b})`; -} diff --git a/stories/area.stories.ts b/stories/area.stories.ts index d9813bf..ef286ef 100644 --- a/stories/area.stories.ts +++ b/stories/area.stories.ts @@ -1,54 +1,309 @@ +import addons from '@storybook/addons'; import { Story, Meta } from '@storybook/html'; -import { timeFormat } from 'd3'; +import { DARK_MODE_EVENT_NAME } from 'storybook-dark-mode'; -import { data } from './data'; +import { generateTime, generateY } from './utilt'; -import { Chart, ScaleType } from '@alauda/chart'; - -import '../src/theme/default.scss'; +import { ActionType, Chart, ChartEvent, resizeObserver } from '@alauda/chart'; +import 'uplot/dist/uPlot.min.css'; export default { title: 'Area', } as Meta; +let chart: Chart; + const Template: Story = () => { + addons.getChannel().on(DARK_MODE_EVENT_NAME, (e: boolean) => { + chart?.theme(e ? 'dark' : 'light'); + }); + setTimeout(() => { - Chart({ - container: '#areaChart', - type: 'area', - title: { - text: '面积图', - // offsetX: 20, - // offsetY: 30, - // hide: true, - }, - legend: { - // hide: true, - // offsetX: 20, - // offsetY: 30, - // formatter: (data: ChartData[]) => `
    11${data[0].name}
    `, - // itemFormatter: `legend {name}`, - }, - data, - xAxis: { - type: ScaleType.TIME, - tickFormatter: () => timeFormat('%H:%M'), - }, - tooltip: { - // titleFormatter: (name: Date | number | string) => - // `
    ${new Date(name)}
    `, - // itemFormatter: (values: TooltipContextItem[]) => - // `
    ${JSON.stringify(values)}
    `, - sort: (a, b) => a.y - b.y, - }, + const auto = document.querySelector('#autoUpdate'); + const text = document.querySelector('.text'); + const total = 60; + const step = 720; + const start = '2023-01-31 09:00:00'; + const range1: [number, number] = [0, 100]; + const range2: [number, number] = [0, 100]; + const timeData = generateTime(start, total, step); + const yData1 = generateY(total, range1); + const yData2 = generateY(total, range2); + + const d1 = timeData.map((x, i) => ({ x, y: yData1[i] })); + const d2 = timeData.map((x, i) => ({ x, y: yData2[i] })); + function getOp(container: string): any { + return { + container, + // data: [], + data: [ + { + name: 'area1', + // color: 'rgb(var(--aui-color-green))', + values: d1.map(d => ({ ...d, y: 2 })), + }, + // { + // name: 'area2', + // values: d2, + // }, + ], + options: { + // title: { text: '1231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312' }, + title: { text: '11' }, + legend: { + position: 'bottom-left', + // position: 'bottom-right', + }, + axis: { + // x: { + // formatter: (value: string) => { + // console.log(value) + // return `${value}%1` + // }, + // }, + // y: { + // autoSize: true, + // formatter: `{value}%1`, + // }, + }, + + annotation: { + areaY: [ + { + data: -Infinity, + text: { + content: 'line', + position: 'right', + }, + }, + { + data: 1, + text: { + content: 'line', + position: 'right', + }, + style: { + stroke: '#EAB839', + }, + }, + ], + // lineX: [ + // { + // data: null, + // }, + // ], + // lineY: [ + // { + // data: 1, + // text: { + // content: 'lineY', + // }, + // }, + // { + // data: 10, + // text: { + // content: 'lineYxxxx', + // }, + // }, + // ], + }, + // tooltip: false, + }, + }; + } + chart = new Chart(getOp('.chart-area')); + // console.log(chart); + // chart.data(data); + // chart.title(false) + // chart.legend(false); + chart.line(); + // chart.line({ alpha: 1, width: 1 }).map('area1'); + // chart.area({ alpha: 1, width: 1 }).map('area2'); + // chart.annotation().lineY({ + // data: 1, + // text: { + // content: 'line', + // position: 'right', + // }, + // }); + + // chart.annotation().areaY(); + // chart.annotation().lineX({ + // data: d1[10].x, + // text: { + // // border: { + // // style: '2px solid red', + // // padding: [0, 5] + // // }, + // style: { + // fontSize: '20px', + // color: 'red', + // }, + // content: '1111', + // }, + // }); + // chart.axis('y', {autoSize: false}) + // chart.shape('bar', { name: 'line2' }); + // chart.interaction('brush-x', { + // end: [ + // { + // trigger: ChartEvent.PLOT_MOUSEUP, + // action: ActionType.BRUSH_X_END, + // callback: e => { + // console.log('brush-x', e); + // }, + // }, + // ], + // }); + chart.render(); + // let bb = true; + let ind = 1; + chart.on(ChartEvent.PLOT_CLICK, e => { + console.log('e', e); + // const timeData = generateTime(start, total, step); + // let yData1 = generateY(total, [0, +(Math.random() * 100).toFixed(2)]); + // let yData2 = generateY(total, [5, 10]); + + // const d1 = timeData.map((x, i) => ({ x, y: bb ? null : yData1[i] })); + // const d2 = timeData.map((x, i) => ({ x, y: bb ? null : yData2[i] })); + // chart.data([ + // { + // name: 'area1', + // // color: 'rgb(var(--aui-color-green))', + // values: d1, + // }, + // { + // name: 'area2', + // values: d2, + // }, + // ]); + // bb = !bb; + ind += 1; + // chart.annotation().lineY({ + // data: ind, + // text: { + // content: 'line', + // position: 'left', + // }, + // }); + // chart.setScale + // chart.setScale('y', { max: 200 }); + chart.annotation().lineX({ + data: e.title, + text: { + border: { + style: '2px solid red', + padding: [0, 5], + }, + style: { + fontSize: '12px', + color: '#999', + }, + content: '2023-02-07 09:45:00', + }, + }); + }); + + // const chart2 = new Chart(getOp('.chart-area2')); + // chart2.area(); + // chart2.render(); + + // console.log('111',chart) + // console.log('222',chart2) + + let interval: NodeJS.Timer; + let animationFrame: number; + let index = 0; + function update() { + index += 3; + // chart.annotation().lineY({ + // data: index, + // text: { + // content: 'line', + // position: 'left', + // }, + // }); + // const end = new Date(start).valueOf() / 1000 + total * step; + // interval = setInterval(() => { + // index += 1; + // const time = end + index * step; + // const current = dealWithTime(new Date(time * 1000)); + // const timeData = generateTime(current, total, step); + // yData1 = yData1.slice(1).concat(getRandom()); + // yData2 = yData2.slice(1).concat(getRandom([0, 10])); + // const data = [ + // { + // name: 'area1', + // values: timeData.map((x, i) => ({ x, y: yData1[i] })), + // }, + // { + // name: 'area2', + // values: timeData.map((x, i) => ({ x, y: yData2[i] })), + // }, + // ]; + // chart.data(data); + // }, 200); + + // index += 1; + // const time = end + index * step; + // const current = dealWithTime(new Date(time * 1000)); + // const timeData = generateTime(current, total, step); + // yData1 = yData1.slice(1).concat(getRandom(range1)); + // yData2 = yData2.slice(1).concat(getRandom(range2)); + // const data = [ + // { + // name: 'area1', + // values: timeData.map((x, i) => ({ x, y: yData1[i] })), + // }, + // { + // name: 'area2', + // values: timeData.map((x, i) => ({ x, y: yData2[i] })), + // }, + // ]; + // chart.data(data); + + // animationFrame = requestAnimationFrame(update); + } + + auto.addEventListener('click', () => { + const type = auto.getAttribute('type'); + const val = type === 'open' ? 'close' : 'open'; + if (val === 'open') { + update(); + } else { + cancelAnimationFrame(animationFrame); + clearInterval(interval); + } + auto.setAttribute('type', val); + text.innerHTML = val; + }); + + resizeObserver(document.querySelector('.chart-area'), () => { + console.log('change'); }); }); return ` -
    -
    + + close +
    +
    +
    +
    +
    + `; }; -export const area = Template.bind({}); +export const Area = Template.bind({}); + +// 图表类型 line area bar +// 大数据量 +// sliding 动态效果 diff --git a/stories/bar.stories.ts b/stories/bar.stories.ts index ed5b0f1..c08817b 100644 --- a/stories/bar.stories.ts +++ b/stories/bar.stories.ts @@ -1,51 +1,90 @@ import { Story, Meta } from '@storybook/html'; -import { groupBarData } from './data'; +import { dealWithTime, generateData } from './utilt'; -import { Chart, ScaleType } from '@alauda/chart'; - -import '../src/theme/default.scss'; +import { Chart } from '@alauda/chart'; +import 'uplot/dist/uPlot.min.css'; export default { title: 'Bar', } as Meta; -// More on component templates: https://storybook.js.org/docs/html/writing-stories/introduction#using-args const Template: Story = () => { setTimeout(() => { - Chart({ - container: '#barChart', - type: 'bar', - // offset: { - // x: 220, - // y: 40, - // }, - rotated: true, - title: { - text: '柱状图', - }, - legend: {}, - tooltip: { - trigger: 'axis', - }, - seriesOption: { - isGroup: true, - stack: true, - radius: 5, - closeRadiusLadder: true, - bandwidth: 10, + const chart = new Chart({ + container: '.chart-bar', + data: [ + // { + // name: 'bar1', + // values: [ + // { x: 'a', y: 2 }, + // { x: 'b', y: 4 }, + // { x: 'c', y: 1 }, + // ], + // }, + // { + // name: 'bar2', + // values: [ + // { x: 'a', y: 4 }, + // { x: 'b', y: 2 }, + // { x: 'c', y: 1 }, + // ], + // }, + // { + // name: 'bar3', + // values: [ + // { x: 'a', y: 1 }, + // { x: 'b', y: 1 }, + // { x: 'c', y: 1 }, + // ], + // }, + { name: 'bar2', values: generateData('2023-01-31 09:00:00', 2, 2) }, + { name: 'bar3', values: generateData('2023-01-31 09:00:00', 2, 2) }, + ], + options: { + title: { text: 'bar chart' }, + // legend: { + // position: 'bottom-right', + // } + scale: { + x: { + time: false, + }, + }, + tooltip: { + // showTitle: false + titleFormatter: title => + `${dealWithTime(new Date(Number(title) * 1000))}`, + }, + coordinate: { transposed: true }, + bar: { + adjust: { type: 'stack' }, + }, }, - data: groupBarData, - xAxis: { - type: ScaleType.ORDINAL, - }, - yAxis: { - minStep: 2, + }); + // console.log(chart); + // chart.data(data); + // chart.coordinate().transpose(); + chart.bar(); + // chart.bar().adjust({ type: 'stack' }); + // chart.shape('line', { name: 'bar2' }); + chart.annotation().lineY({ + data: 10, + text: { + content: 'line', }, }); - }, 0); + chart.render(); + }); return ` -
    `; +
    +
    +
    + `; }; -export const bar = Template.bind({}); +export const Bar = Template.bind({}); + +// 图表类型 line area bar +// 大数据量 +// sliding 动态效果 diff --git a/stories/base-bar.stories.ts b/stories/base-bar.stories.ts deleted file mode 100644 index 05aea20..0000000 --- a/stories/base-bar.stories.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Story, Meta } from '@storybook/html'; -import { timeFormat } from 'd3'; - -import { barData } from './data'; - -import { Chart } from '@alauda/chart'; - -import '../src/theme/default.scss'; - -export default { - title: 'BaseBar', -} as Meta; - -// More on component templates: https://storybook.js.org/docs/html/writing-stories/introduction#using-args -const Template: Story = () => { - setTimeout(() => { - Chart({ - container: '#baseBar', - type: 'bar', - // rotated: true, - title: { - text: '柱状图', - }, - tooltip: {}, - seriesOption: { - // stack: true, - // radius: 5, - // bandwidth: 10, - }, - data: barData, - xAxis: { - // type: ScaleType.TIME, - tickFormatter: () => timeFormat('%H:%M'), - }, - }); - }, 0); - return `
    `; -}; - -export const BaseBar = Template.bind({}); diff --git a/stories/data.ts b/stories/data.ts deleted file mode 100644 index 6e40a6b..0000000 --- a/stories/data.ts +++ /dev/null @@ -1,1687 +0,0 @@ -export const data = [ - { - name: 'deploy-a-67b6bfbf46-r6dbl', - values: [ - { x: 1_656_402_180_000, y: 4.886_106_293_796_049_5 }, - { x: 1_656_402_240_000, y: 4.611_278_000_458_449 }, - { x: 1_656_402_300_000, y: 4.580_173_171_028_2 }, - { x: 1_656_402_360_000, y: 4.475_392_150_592_65 }, - { x: 1_656_402_420_000, y: 4.512_954_807_898_25 }, - { x: 1_656_402_480_000, y: 4.605_846_033_809_15 }, - { x: 1_656_402_540_000, y: 4.568_795_552_000_7 }, - { x: 1_656_402_600_000, y: 4.505_600_601_088_15 }, - { x: 1_656_402_660_000, y: 4.621_248_619_932_049 }, - { x: 1_656_402_720_000, y: 4.841_830_777_650_949 }, - { x: 1_656_402_780_000, y: 4.644_490_140_896_949_4 }, - { x: 1_656_402_840_000, y: 4.485_451_698_423_399 }, - { x: 1_656_402_900_000, y: 4.547_863_830_671_6 }, - { x: 1_656_402_960_000, y: 4.619_941_783_151_249_5 }, - { x: 1_656_403_020_000, y: 4.595_141_653_783_4 }, - { x: 1_656_403_080_000, y: 4.414_480_463_545_999 }, - { x: 1_656_403_140_000, y: 4.510_077_582_101_1 }, - { x: 1_656_403_200_000, y: 4.715_503_736_728_2 }, - { x: 1_656_403_260_000, y: 4.457_457_346_512_85 }, - { x: 1_656_403_320_000, y: 4.943_002_866_81 }, - { x: 1_656_403_380_000, y: 5.142_426_911_407_099_5 }, - { x: 1_656_403_440_000, y: 4.690_855_183_154_85 }, - { x: 1_656_403_500_000, y: 4.664_914_622_379_75 }, - { x: 1_656_403_560_000, y: 4.641_054_577_899_45 }, - { x: 1_656_403_620_000, y: 4.552_596_495_833_1 }, - { x: 1_656_403_680_000, y: 4.490_925_517_272_6 }, - { x: 1_656_403_740_000, y: 4.540_932_126_773_2 }, - { x: 1_656_403_800_000, y: 4.485_414_964_670_75 }, - { x: 1_656_403_860_000, y: 4.694_334_221_292_65 }, - { x: 1_656_403_920_000, y: 4.744_454_834_353_1 }, - { x: 1_656_403_980_000, y: 4.610_508_938_349_3 }, - { x: 1_656_404_040_000, y: 4.714_607_089_670_149_5 }, - { x: 1_656_404_100_000, y: 4.557_340_738_578_8 }, - { x: 1_656_404_160_000, y: 4.358_969_977_314_7 }, - { x: 1_656_404_220_000, y: 4.295_183_225_861_45 }, - { x: 1_656_404_280_000, y: 4.399_047_154_106_25 }, - { x: 1_656_404_340_000, y: 4.943_577_520_903_55 }, - { x: 1_656_404_400_000, y: 5.644_588_735_606_45 }, - { x: 1_656_404_460_000, y: 6.182_428_882_157_649 }, - { x: 1_656_404_520_000, y: 5.570_285_598_539 }, - { x: 1_656_404_580_000, y: 4.578_652_139_937_95 }, - { x: 1_656_404_640_000, y: 4.507_089_474_343_901 }, - { x: 1_656_404_700_000, y: 4.780_431_628_415_849 }, - ], - unit: '%', - color: '#006eff', - }, - { - name: 'deploy-a-8445d8c9fc-x7fr4', - values: [ - { x: 1_656_402_180_000, y: null }, - { x: 1_656_402_240_000, y: null }, - { x: 1_656_402_300_000, y: null }, - { x: 1_656_402_360_000, y: null }, - { x: 1_656_402_420_000, y: null }, - { x: 1_656_402_480_000, y: null }, - { x: 1_656_402_540_000, y: null }, - { x: 1_656_402_600_000, y: null }, - { x: 1_656_402_660_000, y: null }, - { x: 1_656_402_720_000, y: null }, - { x: 1_656_402_780_000, y: null }, - { x: 1_656_402_840_000, y: null }, - { x: 1_656_402_900_000, y: null }, - { x: 1_656_402_960_000, y: null }, - { x: 1_656_403_020_000, y: null }, - { x: 1_656_403_080_000, y: null }, - { x: 1_656_403_140_000, y: null }, - { x: 1_656_403_200_000, y: null }, - { x: 1_656_403_260_000, y: null }, - { x: 1_656_403_320_000, y: null }, - { x: 1_656_403_380_000, y: null }, - { x: 1_656_403_440_000, y: null }, - { x: 1_656_403_500_000, y: null }, - { x: 1_656_403_560_000, y: null }, - { x: 1_656_403_620_000, y: null }, - { x: 1_656_403_680_000, y: null }, - { x: 1_656_403_740_000, y: null }, - { x: 1_656_403_800_000, y: null }, - { x: 1_656_403_860_000, y: null }, - { x: 1_656_403_920_000, y: null }, - { x: 1_656_403_980_000, y: null }, - { x: 1_656_404_040_000, y: null }, - { x: 1_656_404_100_000, y: null }, - { x: 1_656_404_160_000, y: null }, - { x: 1_656_404_220_000, y: null }, - { x: 1_656_404_280_000, y: null }, - { x: 1_656_404_340_000, y: null }, - { x: 1_656_404_400_000, y: null }, - { x: 1_656_404_460_000, y: null }, - { x: 1_656_404_520_000, y: null }, - { x: 1_656_404_580_000, y: null }, - { x: 1_656_404_640_000, y: null }, - { x: 1_656_404_700_000, y: 15.389_840_554_945 }, - { x: 1_656_404_760_000, y: 8.849_063_438_889_4 }, - { x: 1_656_404_820_000, y: 4.448_660_703_050_35 }, - { x: 1_656_404_880_000, y: 4.590_960_486_232_4 }, - { x: 1_656_404_940_000, y: 4.509_079_948_065_199_5 }, - { x: 1_656_405_000_000, y: 4.339_916_755_135_15 }, - { x: 1_656_405_060_000, y: 4.761_793_444_603_95 }, - { x: 1_656_405_120_000, y: 5.101_320_351_473_699 }, - { x: 1_656_405_180_000, y: 4.626_832_420_906_499 }, - { x: 1_656_405_240_000, y: 4.217_734_780_035_349 }, - { x: 1_656_405_300_000, y: 4.544_432_987_163_15 }, - { x: 1_656_405_360_000, y: 4.751_148_730_778_45 }, - { x: 1_656_405_420_000, y: 4.478_849_056_849_65 }, - { x: 1_656_405_480_000, y: 4.228_520_191_837_95 }, - { x: 1_656_405_540_000, y: 4.137_299_117_695_449_4 }, - { x: 1_656_405_600_000, y: 4.111_430_542_483_35 }, - { x: 1_656_405_660_000, y: 4.053_438_065_286_199 }, - { x: 1_656_405_720_000, y: 4.252_526_857_895_05 }, - { x: 1_656_405_780_000, y: 4.386_664_088_705_499_5 }, - ], - unit: '%', - color: '#24b37a', - }, -]; -export const data1 = [ - { - name: 'accesstokeninfoes.auth.alauda.io', - values: [ - { x: 1_637_366_580, y: 1 }, - { x: 1_637_366_640, y: 1 }, - { x: 1_637_366_700, y: 1 }, - { x: 1_637_366_760, y: 1 }, - { x: 1_637_366_820, y: 1 }, - { x: 1_637_366_880, y: 1 }, - { x: 1_637_366_940, y: 1 }, - { x: 1_637_367_000, y: 1 }, - { x: 1_637_367_060, y: 1 }, - { x: 1_637_367_120, y: 1 }, - { x: 1_637_367_180, y: 1 }, - { x: 1_637_367_240, y: 1 }, - { x: 1_637_367_300, y: 1 }, - { x: 1_637_367_360, y: 1 }, - { x: 1_637_367_420, y: 1 }, - { x: 1_637_367_480, y: 1 }, - { x: 1_637_367_540, y: 1 }, - { x: 1_637_367_600, y: 1 }, - { x: 1_637_367_660, y: 1 }, - { x: 1_637_367_720, y: 1 }, - { x: 1_637_367_780, y: 1 }, - { x: 1_637_367_840, y: 1 }, - { x: 1_637_367_900, y: 1 }, - { x: 1_637_367_960, y: 1 }, - { x: 1_637_368_020, y: 1 }, - { x: 1_637_368_080, y: 1 }, - { x: 1_637_368_140, y: 1 }, - { x: 1_637_368_200, y: 1 }, - { x: 1_637_368_260, y: 1 }, - { x: 1_637_368_320, y: 1 }, - { x: 1_637_368_380, y: 1 }, - { x: 1_637_368_440, y: 1 }, - { x: 1_637_368_500, y: 1 }, - { x: 1_637_368_560, y: 1 }, - { x: 1_637_368_620, y: 1 }, - { x: 1_637_368_680, y: 1 }, - { x: 1_637_368_740, y: 1 }, - { x: 1_637_368_800, y: 1 }, - { x: 1_637_368_860, y: 1 }, - { x: 1_637_368_920, y: 1 }, - { x: 1_637_368_980, y: 1 }, - { x: 1_637_369_040, y: 1 }, - { x: 1_637_369_100, y: 1 }, - { x: 1_637_369_160, y: 1 }, - { x: 1_637_369_220, y: 1 }, - { x: 1_637_369_280, y: 1 }, - { x: 1_637_369_340, y: 1 }, - { x: 1_637_369_400, y: 1 }, - { x: 1_637_369_460, y: 1 }, - { x: 1_637_369_520, y: 1 }, - { x: 1_637_369_580, y: 1 }, - { x: 1_637_369_640, y: 1 }, - { x: 1_637_369_700, y: 1 }, - { x: 1_637_369_760, y: 1 }, - { x: 1_637_369_820, y: 1 }, - { x: 1_637_369_880, y: 1 }, - { x: 1_637_369_940, y: 1 }, - { x: 1_637_370_000, y: 1 }, - { x: 1_637_370_060, y: 1 }, - { x: 1_637_370_120, y: 1 }, - { x: 1_637_370_180, y: 1 }, - ], - unit: '个', - }, - { - name: 'acp.product.alauda.io', - values: [ - { x: 1_637_366_580, y: 1 }, - { x: 1_637_366_640, y: 1 }, - { x: 1_637_366_700, y: 1 }, - { x: 1_637_366_760, y: 1 }, - { x: 1_637_366_820, y: 1 }, - { x: 1_637_366_880, y: 1 }, - { x: 1_637_366_940, y: 1 }, - { x: 1_637_367_000, y: 1 }, - { x: 1_637_367_060, y: 1 }, - { x: 1_637_367_120, y: 1 }, - { x: 1_637_367_180, y: 1 }, - { x: 1_637_367_240, y: 1 }, - { x: 1_637_367_300, y: 1 }, - { x: 1_637_367_360, y: 1 }, - { x: 1_637_367_420, y: 1 }, - { x: 1_637_367_480, y: 1 }, - { x: 1_637_367_540, y: 1 }, - { x: 1_637_367_600, y: 1 }, - { x: 1_637_367_660, y: 1 }, - { x: 1_637_367_720, y: 1 }, - { x: 1_637_367_780, y: 1 }, - { x: 1_637_367_840, y: 1 }, - { x: 1_637_367_900, y: 1 }, - { x: 1_637_367_960, y: 1 }, - { x: 1_637_368_020, y: 1 }, - { x: 1_637_368_080, y: 1 }, - { x: 1_637_368_140, y: 1 }, - { x: 1_637_368_200, y: 1 }, - { x: 1_637_368_260, y: 1 }, - { x: 1_637_368_320, y: 1 }, - { x: 1_637_368_380, y: 1 }, - { x: 1_637_368_440, y: 1 }, - { x: 1_637_368_500, y: 1 }, - { x: 1_637_368_560, y: 1 }, - { x: 1_637_368_620, y: 1 }, - { x: 1_637_368_680, y: 1 }, - { x: 1_637_368_740, y: 1 }, - { x: 1_637_368_800, y: 1 }, - { x: 1_637_368_860, y: 1 }, - { x: 1_637_368_920, y: 1 }, - { x: 1_637_368_980, y: 1 }, - { x: 1_637_369_040, y: 1 }, - { x: 1_637_369_100, y: 1 }, - { x: 1_637_369_160, y: 1 }, - { x: 1_637_369_220, y: 1 }, - { x: 1_637_369_280, y: 1 }, - { x: 1_637_369_340, y: 1 }, - { x: 1_637_369_400, y: 1 }, - { x: 1_637_369_460, y: 1 }, - { x: 1_637_369_520, y: 1 }, - { x: 1_637_369_580, y: 1 }, - { x: 1_637_369_640, y: 1 }, - { x: 1_637_369_700, y: 1 }, - { x: 1_637_369_760, y: 1 }, - { x: 1_637_369_820, y: 1 }, - { x: 1_637_369_880, y: 1 }, - { x: 1_637_369_940, y: 1 }, - { x: 1_637_370_000, y: 1 }, - { x: 1_637_370_060, y: 1 }, - { x: 1_637_370_120, y: 1 }, - { x: 1_637_370_180, y: 1 }, - ], - unit: '个', - }, - { - name: 'alaudafeaturegates.alauda.io', - values: [ - { x: 1_637_366_580, y: 190 }, - { x: 1_637_366_640, y: 190 }, - { x: 1_637_366_700, y: 190 }, - { x: 1_637_366_760, y: 190 }, - { x: 1_637_366_820, y: 190 }, - { x: 1_637_366_880, y: 190 }, - { x: 1_637_366_940, y: 190 }, - { x: 1_637_367_000, y: 190 }, - { x: 1_637_367_060, y: 190 }, - { x: 1_637_367_120, y: 190 }, - { x: 1_637_367_180, y: 190 }, - { x: 1_637_367_240, y: 190 }, - { x: 1_637_367_300, y: 190 }, - { x: 1_637_367_360, y: 190 }, - { x: 1_637_367_420, y: 190 }, - { x: 1_637_367_480, y: 190 }, - { x: 1_637_367_540, y: 190 }, - { x: 1_637_367_600, y: 190 }, - { x: 1_637_367_660, y: 190 }, - { x: 1_637_367_720, y: 190 }, - { x: 1_637_367_780, y: 190 }, - { x: 1_637_367_840, y: 190 }, - { x: 1_637_367_900, y: 190 }, - { x: 1_637_367_960, y: 190 }, - { x: 1_637_368_020, y: 190 }, - { x: 1_637_368_080, y: 190 }, - { x: 1_637_368_140, y: 190 }, - { x: 1_637_368_200, y: 190 }, - { x: 1_637_368_260, y: 190 }, - { x: 1_637_368_320, y: 190 }, - { x: 1_637_368_380, y: 190 }, - { x: 1_637_368_440, y: 190 }, - { x: 1_637_368_500, y: 190 }, - { x: 1_637_368_560, y: 190 }, - { x: 1_637_368_620, y: 190 }, - { x: 1_637_368_680, y: 190 }, - { x: 1_637_368_740, y: 190 }, - { x: 1_637_368_800, y: 190 }, - { x: 1_637_368_860, y: 190 }, - { x: 1_637_368_920, y: 190 }, - { x: 1_637_368_980, y: 190 }, - { x: 1_637_369_040, y: 190 }, - { x: 1_637_369_100, y: 190 }, - { x: 1_637_369_160, y: 190 }, - { x: 1_637_369_220, y: 190 }, - { x: 1_637_369_280, y: 190 }, - { x: 1_637_369_340, y: 190 }, - { x: 1_637_369_400, y: 190 }, - { x: 1_637_369_460, y: 190 }, - { x: 1_637_369_520, y: 190 }, - { x: 1_637_369_580, y: 190 }, - { x: 1_637_369_640, y: 190 }, - { x: 1_637_369_700, y: 190 }, - { x: 1_637_369_760, y: 190 }, - { x: 1_637_369_820, y: 190 }, - { x: 1_637_369_880, y: 190 }, - { x: 1_637_369_940, y: 190 }, - { x: 1_637_370_000, y: 190 }, - { x: 1_637_370_060, y: 190 }, - { x: 1_637_370_120, y: 190 }, - { x: 1_637_370_180, y: 190 }, - ], - unit: '个', - }, - { - name: 'alaudaloadbalancer2.crd.alauda.io', - values: [ - { x: 1_637_366_580, y: 1 }, - { x: 1_637_366_640, y: 1 }, - { x: 1_637_366_700, y: 1 }, - { x: 1_637_366_760, y: 1 }, - { x: 1_637_366_820, y: 1 }, - { x: 1_637_366_880, y: 1 }, - { x: 1_637_366_940, y: 1 }, - { x: 1_637_367_000, y: 1 }, - { x: 1_637_367_060, y: 1 }, - { x: 1_637_367_120, y: 1 }, - { x: 1_637_367_180, y: 1 }, - { x: 1_637_367_240, y: 1 }, - { x: 1_637_367_300, y: 1 }, - { x: 1_637_367_360, y: 1 }, - { x: 1_637_367_420, y: 1 }, - { x: 1_637_367_480, y: 1 }, - { x: 1_637_367_540, y: 1 }, - { x: 1_637_367_600, y: 1 }, - { x: 1_637_367_660, y: 1 }, - { x: 1_637_367_720, y: 1 }, - { x: 1_637_367_780, y: 1 }, - { x: 1_637_367_840, y: 1 }, - { x: 1_637_367_900, y: 1 }, - { x: 1_637_367_960, y: 1 }, - { x: 1_637_368_020, y: 1 }, - { x: 1_637_368_080, y: 1 }, - { x: 1_637_368_140, y: 1 }, - { x: 1_637_368_200, y: 1 }, - { x: 1_637_368_260, y: 1 }, - { x: 1_637_368_320, y: 1 }, - { x: 1_637_368_380, y: 1 }, - { x: 1_637_368_440, y: 1 }, - { x: 1_637_368_500, y: 1 }, - { x: 1_637_368_560, y: 1 }, - { x: 1_637_368_620, y: 1 }, - { x: 1_637_368_680, y: 1 }, - { x: 1_637_368_740, y: 1 }, - { x: 1_637_368_800, y: 1 }, - { x: 1_637_368_860, y: 1 }, - { x: 1_637_368_920, y: 1 }, - { x: 1_637_368_980, y: 1 }, - { x: 1_637_369_040, y: 1 }, - { x: 1_637_369_100, y: 1 }, - { x: 1_637_369_160, y: 1 }, - { x: 1_637_369_220, y: 1 }, - { x: 1_637_369_280, y: 1 }, - { x: 1_637_369_340, y: 1 }, - { x: 1_637_369_400, y: 1 }, - { x: 1_637_369_460, y: 1 }, - { x: 1_637_369_520, y: 1 }, - { x: 1_637_369_580, y: 1 }, - { x: 1_637_369_640, y: 1 }, - { x: 1_637_369_700, y: 1 }, - { x: 1_637_369_760, y: 1 }, - { x: 1_637_369_820, y: 1 }, - { x: 1_637_369_880, y: 1 }, - { x: 1_637_369_940, y: 1 }, - { x: 1_637_370_000, y: 1 }, - { x: 1_637_370_060, y: 1 }, - { x: 1_637_370_120, y: 1 }, - { x: 1_637_370_180, y: 1 }, - ], - unit: '个', - }, - { - name: 'alaudaproducts.portal.alauda.io', - values: [ - { x: 1_637_366_580, y: 4 }, - { x: 1_637_366_640, y: 4 }, - { x: 1_637_366_700, y: 4 }, - { x: 1_637_366_760, y: 4 }, - { x: 1_637_366_820, y: 4 }, - { x: 1_637_366_880, y: 4 }, - { x: 1_637_366_940, y: 4 }, - { x: 1_637_367_000, y: 4 }, - { x: 1_637_367_060, y: 4 }, - { x: 1_637_367_120, y: 4 }, - { x: 1_637_367_180, y: 4 }, - { x: 1_637_367_240, y: 4 }, - { x: 1_637_367_300, y: 4 }, - { x: 1_637_367_360, y: 4 }, - { x: 1_637_367_420, y: 4 }, - { x: 1_637_367_480, y: 4 }, - { x: 1_637_367_540, y: 4 }, - { x: 1_637_367_600, y: 4 }, - { x: 1_637_367_660, y: 4 }, - { x: 1_637_367_720, y: 4 }, - { x: 1_637_367_780, y: 4 }, - { x: 1_637_367_840, y: 4 }, - { x: 1_637_367_900, y: 4 }, - { x: 1_637_367_960, y: 4 }, - { x: 1_637_368_020, y: 4 }, - { x: 1_637_368_080, y: 4 }, - { x: 1_637_368_140, y: 4 }, - { x: 1_637_368_200, y: 4 }, - { x: 1_637_368_260, y: 4 }, - { x: 1_637_368_320, y: 4 }, - { x: 1_637_368_380, y: 4 }, - { x: 1_637_368_440, y: 4 }, - { x: 1_637_368_500, y: 4 }, - { x: 1_637_368_560, y: 4 }, - { x: 1_637_368_620, y: 4 }, - { x: 1_637_368_680, y: 4 }, - { x: 1_637_368_740, y: 4 }, - { x: 1_637_368_800, y: 4 }, - { x: 1_637_368_860, y: 4 }, - { x: 1_637_368_920, y: 4 }, - { x: 1_637_368_980, y: 4 }, - { x: 1_637_369_040, y: 4 }, - { x: 1_637_369_100, y: 4 }, - { x: 1_637_369_160, y: 4 }, - { x: 1_637_369_220, y: 4 }, - { x: 1_637_369_280, y: 4 }, - { x: 1_637_369_340, y: 4 }, - { x: 1_637_369_400, y: 4 }, - { x: 1_637_369_460, y: 4 }, - { x: 1_637_369_520, y: 4 }, - { x: 1_637_369_580, y: 4 }, - { x: 1_637_369_640, y: 4 }, - { x: 1_637_369_700, y: 4 }, - { x: 1_637_369_760, y: 4 }, - { x: 1_637_369_820, y: 4 }, - { x: 1_637_369_880, y: 4 }, - { x: 1_637_369_940, y: 4 }, - { x: 1_637_370_000, y: 4 }, - { x: 1_637_370_060, y: 4 }, - { x: 1_637_370_120, y: 4 }, - { x: 1_637_370_180, y: 4 }, - ], - unit: '个', - }, - { - name: 'alertmanagerconfigs.monitoring.coreos.com', - values: [ - { x: 1_637_366_580, y: 0 }, - { x: 1_637_366_640, y: 0 }, - { x: 1_637_366_700, y: 0 }, - { x: 1_637_366_760, y: 0 }, - { x: 1_637_366_820, y: 0 }, - { x: 1_637_366_880, y: 0 }, - { x: 1_637_366_940, y: 0 }, - { x: 1_637_367_000, y: 0 }, - { x: 1_637_367_060, y: 0 }, - { x: 1_637_367_120, y: 0 }, - { x: 1_637_367_180, y: 0 }, - { x: 1_637_367_240, y: 0 }, - { x: 1_637_367_300, y: 0 }, - { x: 1_637_367_360, y: 0 }, - { x: 1_637_367_420, y: 0 }, - { x: 1_637_367_480, y: 0 }, - { x: 1_637_367_540, y: 0 }, - { x: 1_637_367_600, y: 0 }, - { x: 1_637_367_660, y: 0 }, - { x: 1_637_367_720, y: 0 }, - { x: 1_637_367_780, y: 0 }, - { x: 1_637_367_840, y: 0 }, - { x: 1_637_367_900, y: 0 }, - { x: 1_637_367_960, y: 0 }, - { x: 1_637_368_020, y: 0 }, - { x: 1_637_368_080, y: 0 }, - { x: 1_637_368_140, y: 0 }, - { x: 1_637_368_200, y: 0 }, - { x: 1_637_368_260, y: 0 }, - { x: 1_637_368_320, y: 0 }, - { x: 1_637_368_380, y: 0 }, - { x: 1_637_368_440, y: 0 }, - { x: 1_637_368_500, y: 0 }, - { x: 1_637_368_560, y: 0 }, - { x: 1_637_368_620, y: 0 }, - { x: 1_637_368_680, y: 0 }, - { x: 1_637_368_740, y: 0 }, - { x: 1_637_368_800, y: 0 }, - { x: 1_637_368_860, y: 0 }, - { x: 1_637_368_920, y: 0 }, - { x: 1_637_368_980, y: 0 }, - { x: 1_637_369_040, y: 0 }, - { x: 1_637_369_100, y: 0 }, - { x: 1_637_369_160, y: 0 }, - { x: 1_637_369_220, y: 0 }, - { x: 1_637_369_280, y: 0 }, - { x: 1_637_369_340, y: 0 }, - { x: 1_637_369_400, y: 0 }, - { x: 1_637_369_460, y: 0 }, - { x: 1_637_369_520, y: 0 }, - { x: 1_637_369_580, y: 0 }, - { x: 1_637_369_640, y: 0 }, - { x: 1_637_369_700, y: 0 }, - { x: 1_637_369_760, y: 0 }, - { x: 1_637_369_820, y: 0 }, - { x: 1_637_369_880, y: 0 }, - { x: 1_637_369_940, y: 0 }, - { x: 1_637_370_000, y: 0 }, - { x: 1_637_370_060, y: 0 }, - { x: 1_637_370_120, y: 0 }, - { x: 1_637_370_180, y: 0 }, - ], - unit: '个', - }, - { - name: 'alertmanagers.monitoring.coreos.com', - values: [ - { x: 1_637_366_580, y: 1 }, - { x: 1_637_366_640, y: 1 }, - { x: 1_637_366_700, y: 1 }, - { x: 1_637_366_760, y: 1 }, - { x: 1_637_366_820, y: 1 }, - { x: 1_637_366_880, y: 1 }, - { x: 1_637_366_940, y: 1 }, - { x: 1_637_367_000, y: 1 }, - { x: 1_637_367_060, y: 1 }, - { x: 1_637_367_120, y: 1 }, - { x: 1_637_367_180, y: 1 }, - { x: 1_637_367_240, y: 1 }, - { x: 1_637_367_300, y: 1 }, - { x: 1_637_367_360, y: 1 }, - { x: 1_637_367_420, y: 1 }, - { x: 1_637_367_480, y: 1 }, - { x: 1_637_367_540, y: 1 }, - { x: 1_637_367_600, y: 1 }, - { x: 1_637_367_660, y: 1 }, - { x: 1_637_367_720, y: 1 }, - { x: 1_637_367_780, y: 1 }, - { x: 1_637_367_840, y: 1 }, - { x: 1_637_367_900, y: 1 }, - { x: 1_637_367_960, y: 1 }, - { x: 1_637_368_020, y: 1 }, - { x: 1_637_368_080, y: 1 }, - { x: 1_637_368_140, y: 1 }, - { x: 1_637_368_200, y: 1 }, - { x: 1_637_368_260, y: 1 }, - { x: 1_637_368_320, y: 1 }, - { x: 1_637_368_380, y: 1 }, - { x: 1_637_368_440, y: 1 }, - { x: 1_637_368_500, y: 1 }, - { x: 1_637_368_560, y: 1 }, - { x: 1_637_368_620, y: 1 }, - { x: 1_637_368_680, y: 1 }, - { x: 1_637_368_740, y: 1 }, - { x: 1_637_368_800, y: 1 }, - { x: 1_637_368_860, y: 1 }, - { x: 1_637_368_920, y: 1 }, - { x: 1_637_368_980, y: 1 }, - { x: 1_637_369_040, y: 1 }, - { x: 1_637_369_100, y: 1 }, - { x: 1_637_369_160, y: 1 }, - { x: 1_637_369_220, y: 1 }, - { x: 1_637_369_280, y: 1 }, - { x: 1_637_369_340, y: 1 }, - { x: 1_637_369_400, y: 1 }, - { x: 1_637_369_460, y: 1 }, - { x: 1_637_369_520, y: 1 }, - { x: 1_637_369_580, y: 1 }, - { x: 1_637_369_640, y: 1 }, - { x: 1_637_369_700, y: 1 }, - { x: 1_637_369_760, y: 1 }, - { x: 1_637_369_820, y: 1 }, - { x: 1_637_369_880, y: 1 }, - { x: 1_637_369_940, y: 1 }, - { x: 1_637_370_000, y: 1 }, - { x: 1_637_370_060, y: 1 }, - { x: 1_637_370_120, y: 1 }, - { x: 1_637_370_180, y: 1 }, - ], - unit: '个', - }, - { - name: 'alertproviders.flagger.app', - values: [ - { x: 1_637_366_580, y: 0 }, - { x: 1_637_366_640, y: 0 }, - { x: 1_637_366_700, y: 0 }, - { x: 1_637_366_760, y: 0 }, - { x: 1_637_366_820, y: 0 }, - { x: 1_637_366_880, y: 0 }, - { x: 1_637_366_940, y: 0 }, - { x: 1_637_367_000, y: 0 }, - { x: 1_637_367_060, y: 0 }, - { x: 1_637_367_120, y: 0 }, - { x: 1_637_367_180, y: 0 }, - { x: 1_637_367_240, y: 0 }, - { x: 1_637_367_300, y: 0 }, - { x: 1_637_367_360, y: 0 }, - { x: 1_637_367_420, y: 0 }, - { x: 1_637_367_480, y: 0 }, - { x: 1_637_367_540, y: 0 }, - { x: 1_637_367_600, y: 0 }, - { x: 1_637_367_660, y: 0 }, - { x: 1_637_367_720, y: 0 }, - { x: 1_637_367_780, y: 0 }, - { x: 1_637_367_840, y: 0 }, - { x: 1_637_367_900, y: 0 }, - { x: 1_637_367_960, y: 0 }, - { x: 1_637_368_020, y: 0 }, - { x: 1_637_368_080, y: 0 }, - { x: 1_637_368_140, y: 0 }, - { x: 1_637_368_200, y: 0 }, - { x: 1_637_368_260, y: 0 }, - { x: 1_637_368_320, y: 0 }, - { x: 1_637_368_380, y: 0 }, - { x: 1_637_368_440, y: 0 }, - { x: 1_637_368_500, y: 0 }, - { x: 1_637_368_560, y: 0 }, - { x: 1_637_368_620, y: 0 }, - { x: 1_637_368_680, y: 0 }, - { x: 1_637_368_740, y: 0 }, - { x: 1_637_368_800, y: 0 }, - { x: 1_637_368_860, y: 0 }, - { x: 1_637_368_920, y: 0 }, - { x: 1_637_368_980, y: 0 }, - { x: 1_637_369_040, y: 0 }, - { x: 1_637_369_100, y: 0 }, - { x: 1_637_369_160, y: 0 }, - { x: 1_637_369_220, y: 0 }, - { x: 1_637_369_280, y: 0 }, - { x: 1_637_369_340, y: 0 }, - { x: 1_637_369_400, y: 0 }, - { x: 1_637_369_460, y: 0 }, - { x: 1_637_369_520, y: 0 }, - { x: 1_637_369_580, y: 0 }, - { x: 1_637_369_640, y: 0 }, - { x: 1_637_369_700, y: 0 }, - { x: 1_637_369_760, y: 0 }, - { x: 1_637_369_820, y: 0 }, - { x: 1_637_369_880, y: 0 }, - { x: 1_637_369_940, y: 0 }, - { x: 1_637_370_000, y: 0 }, - { x: 1_637_370_060, y: 0 }, - { x: 1_637_370_120, y: 0 }, - { x: 1_637_370_180, y: 0 }, - ], - unit: '个', - }, - { - name: 'alerttemplates.aiops.alauda.io', - values: [ - { x: 1_637_366_580, y: 13 }, - { x: 1_637_366_640, y: 13 }, - { x: 1_637_366_700, y: 13 }, - { x: 1_637_366_760, y: 13 }, - { x: 1_637_366_820, y: 13 }, - { x: 1_637_366_880, y: 13 }, - { x: 1_637_366_940, y: 13 }, - { x: 1_637_367_000, y: 13 }, - { x: 1_637_367_060, y: 13 }, - { x: 1_637_367_120, y: 13 }, - { x: 1_637_367_180, y: 13 }, - { x: 1_637_367_240, y: 13 }, - { x: 1_637_367_300, y: 13 }, - { x: 1_637_367_360, y: 13 }, - { x: 1_637_367_420, y: 13 }, - { x: 1_637_367_480, y: 13 }, - { x: 1_637_367_540, y: 13 }, - { x: 1_637_367_600, y: 13 }, - { x: 1_637_367_660, y: 13 }, - { x: 1_637_367_720, y: 13 }, - { x: 1_637_367_780, y: 13 }, - { x: 1_637_367_840, y: 13 }, - { x: 1_637_367_900, y: 13 }, - { x: 1_637_367_960, y: 13 }, - { x: 1_637_368_020, y: 13 }, - { x: 1_637_368_080, y: 13 }, - { x: 1_637_368_140, y: 13 }, - { x: 1_637_368_200, y: 13 }, - { x: 1_637_368_260, y: 13 }, - { x: 1_637_368_320, y: 13 }, - { x: 1_637_368_380, y: 13 }, - { x: 1_637_368_440, y: 13 }, - { x: 1_637_368_500, y: 13 }, - { x: 1_637_368_560, y: 13 }, - { x: 1_637_368_620, y: 13 }, - { x: 1_637_368_680, y: 13 }, - { x: 1_637_368_740, y: 13 }, - { x: 1_637_368_800, y: 13 }, - { x: 1_637_368_860, y: 13 }, - { x: 1_637_368_920, y: 13 }, - { x: 1_637_368_980, y: 13 }, - { x: 1_637_369_040, y: 13 }, - { x: 1_637_369_100, y: 13 }, - { x: 1_637_369_160, y: 13 }, - { x: 1_637_369_220, y: 13 }, - { x: 1_637_369_280, y: 13 }, - { x: 1_637_369_340, y: 13 }, - { x: 1_637_369_400, y: 13 }, - { x: 1_637_369_460, y: 13 }, - { x: 1_637_369_520, y: 13 }, - { x: 1_637_369_580, y: 13 }, - { x: 1_637_369_640, y: 13 }, - { x: 1_637_369_700, y: 13 }, - { x: 1_637_369_760, y: 13 }, - { x: 1_637_369_820, y: 13 }, - { x: 1_637_369_880, y: 13 }, - { x: 1_637_369_940, y: 13 }, - { x: 1_637_370_000, y: 13 }, - { x: 1_637_370_060, y: 13 }, - { x: 1_637_370_120, y: 13 }, - { x: 1_637_370_180, y: 13 }, - ], - unit: '个', - }, - { - name: 'apiattributes.asm.alauda.io', - values: [ - { x: 1_637_366_580, y: 0 }, - { x: 1_637_366_640, y: 0 }, - { x: 1_637_366_700, y: 0 }, - { x: 1_637_366_760, y: 0 }, - { x: 1_637_366_820, y: 0 }, - { x: 1_637_366_880, y: 0 }, - { x: 1_637_366_940, y: 0 }, - { x: 1_637_367_000, y: 0 }, - { x: 1_637_367_060, y: 0 }, - { x: 1_637_367_120, y: 0 }, - { x: 1_637_367_180, y: 0 }, - { x: 1_637_367_240, y: 0 }, - { x: 1_637_367_300, y: 0 }, - { x: 1_637_367_360, y: 0 }, - { x: 1_637_367_420, y: 0 }, - { x: 1_637_367_480, y: 0 }, - { x: 1_637_367_540, y: 0 }, - { x: 1_637_367_600, y: 0 }, - { x: 1_637_367_660, y: 0 }, - { x: 1_637_367_720, y: 0 }, - { x: 1_637_367_780, y: 0 }, - { x: 1_637_367_840, y: 0 }, - { x: 1_637_367_900, y: 0 }, - { x: 1_637_367_960, y: 0 }, - { x: 1_637_368_020, y: 0 }, - { x: 1_637_368_080, y: 0 }, - { x: 1_637_368_140, y: 0 }, - { x: 1_637_368_200, y: 0 }, - { x: 1_637_368_260, y: 0 }, - { x: 1_637_368_320, y: 0 }, - { x: 1_637_368_380, y: 0 }, - { x: 1_637_368_440, y: 0 }, - { x: 1_637_368_500, y: 0 }, - { x: 1_637_368_560, y: 0 }, - { x: 1_637_368_620, y: 0 }, - { x: 1_637_368_680, y: 0 }, - { x: 1_637_368_740, y: 0 }, - { x: 1_637_368_800, y: 0 }, - { x: 1_637_368_860, y: 0 }, - { x: 1_637_368_920, y: 0 }, - { x: 1_637_368_980, y: 0 }, - { x: 1_637_369_040, y: 0 }, - { x: 1_637_369_100, y: 0 }, - { x: 1_637_369_160, y: 0 }, - { x: 1_637_369_220, y: 0 }, - { x: 1_637_369_280, y: 0 }, - { x: 1_637_369_340, y: 0 }, - { x: 1_637_369_400, y: 0 }, - { x: 1_637_369_460, y: 0 }, - { x: 1_637_369_520, y: 0 }, - { x: 1_637_369_580, y: 0 }, - { x: 1_637_369_640, y: 0 }, - { x: 1_637_369_700, y: 0 }, - { x: 1_637_369_760, y: 0 }, - { x: 1_637_369_820, y: 0 }, - { x: 1_637_369_880, y: 0 }, - { x: 1_637_369_940, y: 0 }, - { x: 1_637_370_000, y: 0 }, - { x: 1_637_370_060, y: 0 }, - { x: 1_637_370_120, y: 0 }, - { x: 1_637_370_180, y: 0 }, - ], - unit: '个', - }, - { - name: 'apiservices.apiregistration.k8s.io', - values: [ - { x: 1_637_366_580, y: 120 }, - { x: 1_637_366_640, y: 120 }, - { x: 1_637_366_700, y: 120 }, - { x: 1_637_366_760, y: 120 }, - { x: 1_637_366_820, y: 120 }, - { x: 1_637_366_880, y: 120 }, - { x: 1_637_366_940, y: 120 }, - { x: 1_637_367_000, y: 120 }, - { x: 1_637_367_060, y: 120 }, - { x: 1_637_367_120, y: 120 }, - { x: 1_637_367_180, y: 120 }, - { x: 1_637_367_240, y: 120 }, - { x: 1_637_367_300, y: 120 }, - { x: 1_637_367_360, y: 120 }, - { x: 1_637_367_420, y: 120 }, - { x: 1_637_367_480, y: 120 }, - { x: 1_637_367_540, y: 120 }, - { x: 1_637_367_600, y: 120 }, - { x: 1_637_367_660, y: 120 }, - { x: 1_637_367_720, y: 120 }, - { x: 1_637_367_780, y: 120 }, - { x: 1_637_367_840, y: 120 }, - { x: 1_637_367_900, y: 120 }, - { x: 1_637_367_960, y: 120 }, - { x: 1_637_368_020, y: 120 }, - { x: 1_637_368_080, y: 120 }, - { x: 1_637_368_140, y: 120 }, - { x: 1_637_368_200, y: 120 }, - { x: 1_637_368_260, y: 120 }, - { x: 1_637_368_320, y: 120 }, - { x: 1_637_368_380, y: 120 }, - { x: 1_637_368_440, y: 120 }, - { x: 1_637_368_500, y: 120 }, - { x: 1_637_368_560, y: 120 }, - { x: 1_637_368_620, y: 120 }, - { x: 1_637_368_680, y: 120 }, - { x: 1_637_368_740, y: 120 }, - { x: 1_637_368_800, y: 120 }, - { x: 1_637_368_860, y: 120 }, - { x: 1_637_368_920, y: 120 }, - { x: 1_637_368_980, y: 120 }, - { x: 1_637_369_040, y: 120 }, - { x: 1_637_369_100, y: 120 }, - { x: 1_637_369_160, y: 120 }, - { x: 1_637_369_220, y: 120 }, - { x: 1_637_369_280, y: 120 }, - { x: 1_637_369_340, y: 120 }, - { x: 1_637_369_400, y: 120 }, - { x: 1_637_369_460, y: 120 }, - { x: 1_637_369_520, y: 120 }, - { x: 1_637_369_580, y: 120 }, - { x: 1_637_369_640, y: 120 }, - { x: 1_637_369_700, y: 120 }, - { x: 1_637_369_760, y: 120 }, - { x: 1_637_369_820, y: 120 }, - { x: 1_637_369_880, y: 120 }, - { x: 1_637_369_940, y: 120 }, - { x: 1_637_370_000, y: 120 }, - { x: 1_637_370_060, y: 120 }, - { x: 1_637_370_120, y: 120 }, - { x: 1_637_370_180, y: 120 }, - ], - unit: '个', - }, - { - name: 'appconfigs.asm.alauda.io', - values: [ - { x: 1_637_366_580, y: 0 }, - { x: 1_637_366_640, y: 0 }, - { x: 1_637_366_700, y: 0 }, - { x: 1_637_366_760, y: 0 }, - { x: 1_637_366_820, y: 0 }, - { x: 1_637_366_880, y: 0 }, - { x: 1_637_366_940, y: 0 }, - { x: 1_637_367_000, y: 0 }, - { x: 1_637_367_060, y: 0 }, - { x: 1_637_367_120, y: 0 }, - { x: 1_637_367_180, y: 0 }, - { x: 1_637_367_240, y: 0 }, - { x: 1_637_367_300, y: 0 }, - { x: 1_637_367_360, y: 0 }, - { x: 1_637_367_420, y: 0 }, - { x: 1_637_367_480, y: 0 }, - { x: 1_637_367_540, y: 0 }, - { x: 1_637_367_600, y: 0 }, - { x: 1_637_367_660, y: 0 }, - { x: 1_637_367_720, y: 0 }, - { x: 1_637_367_780, y: 0 }, - { x: 1_637_367_840, y: 0 }, - { x: 1_637_367_900, y: 0 }, - { x: 1_637_367_960, y: 0 }, - { x: 1_637_368_020, y: 0 }, - { x: 1_637_368_080, y: 0 }, - { x: 1_637_368_140, y: 0 }, - { x: 1_637_368_200, y: 0 }, - { x: 1_637_368_260, y: 0 }, - { x: 1_637_368_320, y: 0 }, - { x: 1_637_368_380, y: 0 }, - { x: 1_637_368_440, y: 0 }, - { x: 1_637_368_500, y: 0 }, - { x: 1_637_368_560, y: 0 }, - { x: 1_637_368_620, y: 0 }, - { x: 1_637_368_680, y: 0 }, - { x: 1_637_368_740, y: 0 }, - { x: 1_637_368_800, y: 0 }, - { x: 1_637_368_860, y: 0 }, - { x: 1_637_368_920, y: 0 }, - { x: 1_637_368_980, y: 0 }, - { x: 1_637_369_040, y: 0 }, - { x: 1_637_369_100, y: 0 }, - { x: 1_637_369_160, y: 0 }, - { x: 1_637_369_220, y: 0 }, - { x: 1_637_369_280, y: 0 }, - { x: 1_637_369_340, y: 0 }, - { x: 1_637_369_400, y: 0 }, - { x: 1_637_369_460, y: 0 }, - { x: 1_637_369_520, y: 0 }, - { x: 1_637_369_580, y: 0 }, - { x: 1_637_369_640, y: 0 }, - { x: 1_637_369_700, y: 0 }, - { x: 1_637_369_760, y: 0 }, - { x: 1_637_369_820, y: 0 }, - { x: 1_637_369_880, y: 0 }, - { x: 1_637_369_940, y: 0 }, - { x: 1_637_370_000, y: 0 }, - { x: 1_637_370_060, y: 0 }, - { x: 1_637_370_120, y: 0 }, - { x: 1_637_370_180, y: 0 }, - ], - unit: '个', - }, - { - name: 'appdeployments.core.oam.dev', - values: [ - { x: 1_637_366_580, y: 0 }, - { x: 1_637_366_640, y: 0 }, - { x: 1_637_366_700, y: 0 }, - { x: 1_637_366_760, y: 0 }, - { x: 1_637_366_820, y: 0 }, - { x: 1_637_366_880, y: 0 }, - { x: 1_637_366_940, y: 0 }, - { x: 1_637_367_000, y: 0 }, - { x: 1_637_367_060, y: 0 }, - { x: 1_637_367_120, y: 0 }, - { x: 1_637_367_180, y: 0 }, - { x: 1_637_367_240, y: 0 }, - { x: 1_637_367_300, y: 0 }, - { x: 1_637_367_360, y: 0 }, - { x: 1_637_367_420, y: 0 }, - { x: 1_637_367_480, y: 0 }, - { x: 1_637_367_540, y: 0 }, - { x: 1_637_367_600, y: 0 }, - { x: 1_637_367_660, y: 0 }, - { x: 1_637_367_720, y: 0 }, - { x: 1_637_367_780, y: 0 }, - { x: 1_637_367_840, y: 0 }, - { x: 1_637_367_900, y: 0 }, - { x: 1_637_367_960, y: 0 }, - { x: 1_637_368_020, y: 0 }, - { x: 1_637_368_080, y: 0 }, - { x: 1_637_368_140, y: 0 }, - { x: 1_637_368_200, y: 0 }, - { x: 1_637_368_260, y: 0 }, - { x: 1_637_368_320, y: 0 }, - { x: 1_637_368_380, y: 0 }, - { x: 1_637_368_440, y: 0 }, - { x: 1_637_368_500, y: 0 }, - { x: 1_637_368_560, y: 0 }, - { x: 1_637_368_620, y: 0 }, - { x: 1_637_368_680, y: 0 }, - { x: 1_637_368_740, y: 0 }, - { x: 1_637_368_800, y: 0 }, - { x: 1_637_368_860, y: 0 }, - { x: 1_637_368_920, y: 0 }, - { x: 1_637_368_980, y: 0 }, - { x: 1_637_369_040, y: 0 }, - { x: 1_637_369_100, y: 0 }, - { x: 1_637_369_160, y: 0 }, - { x: 1_637_369_220, y: 0 }, - { x: 1_637_369_280, y: 0 }, - { x: 1_637_369_340, y: 0 }, - { x: 1_637_369_400, y: 0 }, - { x: 1_637_369_460, y: 0 }, - { x: 1_637_369_520, y: 0 }, - { x: 1_637_369_580, y: 0 }, - { x: 1_637_369_640, y: 0 }, - { x: 1_637_369_700, y: 0 }, - { x: 1_637_369_760, y: 0 }, - { x: 1_637_369_820, y: 0 }, - { x: 1_637_369_880, y: 0 }, - { x: 1_637_369_940, y: 0 }, - { x: 1_637_370_000, y: 0 }, - { x: 1_637_370_060, y: 0 }, - { x: 1_637_370_120, y: 0 }, - { x: 1_637_370_180, y: 0 }, - ], - unit: '个', - }, - { - name: 'applicationconfigurations.core.oam.dev', - values: [ - { x: 1_637_366_580, y: 0 }, - { x: 1_637_366_640, y: 0 }, - { x: 1_637_366_700, y: 0 }, - { x: 1_637_366_760, y: 0 }, - { x: 1_637_366_820, y: 0 }, - { x: 1_637_366_880, y: 0 }, - { x: 1_637_366_940, y: 0 }, - { x: 1_637_367_000, y: 0 }, - { x: 1_637_367_060, y: 0 }, - { x: 1_637_367_120, y: 0 }, - { x: 1_637_367_180, y: 0 }, - { x: 1_637_367_240, y: 0 }, - { x: 1_637_367_300, y: 0 }, - { x: 1_637_367_360, y: 0 }, - { x: 1_637_367_420, y: 0 }, - { x: 1_637_367_480, y: 0 }, - { x: 1_637_367_540, y: 0 }, - { x: 1_637_367_600, y: 0 }, - { x: 1_637_367_660, y: 0 }, - { x: 1_637_367_720, y: 0 }, - { x: 1_637_367_780, y: 0 }, - { x: 1_637_367_840, y: 0 }, - { x: 1_637_367_900, y: 0 }, - { x: 1_637_367_960, y: 0 }, - { x: 1_637_368_020, y: 0 }, - { x: 1_637_368_080, y: 0 }, - { x: 1_637_368_140, y: 0 }, - { x: 1_637_368_200, y: 0 }, - { x: 1_637_368_260, y: 0 }, - { x: 1_637_368_320, y: 0 }, - { x: 1_637_368_380, y: 0 }, - { x: 1_637_368_440, y: 0 }, - { x: 1_637_368_500, y: 0 }, - { x: 1_637_368_560, y: 0 }, - { x: 1_637_368_620, y: 0 }, - { x: 1_637_368_680, y: 0 }, - { x: 1_637_368_740, y: 0 }, - { x: 1_637_368_800, y: 0 }, - { x: 1_637_368_860, y: 0 }, - { x: 1_637_368_920, y: 0 }, - { x: 1_637_368_980, y: 0 }, - { x: 1_637_369_040, y: 0 }, - { x: 1_637_369_100, y: 0 }, - { x: 1_637_369_160, y: 0 }, - { x: 1_637_369_220, y: 0 }, - { x: 1_637_369_280, y: 0 }, - { x: 1_637_369_340, y: 0 }, - { x: 1_637_369_400, y: 0 }, - { x: 1_637_369_460, y: 0 }, - { x: 1_637_369_520, y: 0 }, - { x: 1_637_369_580, y: 0 }, - { x: 1_637_369_640, y: 0 }, - { x: 1_637_369_700, y: 0 }, - { x: 1_637_369_760, y: 0 }, - { x: 1_637_369_820, y: 0 }, - { x: 1_637_369_880, y: 0 }, - { x: 1_637_369_940, y: 0 }, - { x: 1_637_370_000, y: 0 }, - { x: 1_637_370_060, y: 0 }, - { x: 1_637_370_120, y: 0 }, - { x: 1_637_370_180, y: 0 }, - ], - unit: '个', - }, - { - name: 'applicationcontexts.core.oam.dev', - values: [ - { x: 1_637_366_580, y: 0 }, - { x: 1_637_366_640, y: 0 }, - { x: 1_637_366_700, y: 0 }, - { x: 1_637_366_760, y: 0 }, - { x: 1_637_366_820, y: 0 }, - { x: 1_637_366_880, y: 0 }, - { x: 1_637_366_940, y: 0 }, - { x: 1_637_367_000, y: 0 }, - { x: 1_637_367_060, y: 0 }, - { x: 1_637_367_120, y: 0 }, - { x: 1_637_367_180, y: 0 }, - { x: 1_637_367_240, y: 0 }, - { x: 1_637_367_300, y: 0 }, - { x: 1_637_367_360, y: 0 }, - { x: 1_637_367_420, y: 0 }, - { x: 1_637_367_480, y: 0 }, - { x: 1_637_367_540, y: 0 }, - { x: 1_637_367_600, y: 0 }, - { x: 1_637_367_660, y: 0 }, - { x: 1_637_367_720, y: 0 }, - { x: 1_637_367_780, y: 0 }, - { x: 1_637_367_840, y: 0 }, - { x: 1_637_367_900, y: 0 }, - { x: 1_637_367_960, y: 0 }, - { x: 1_637_368_020, y: 0 }, - { x: 1_637_368_080, y: 0 }, - { x: 1_637_368_140, y: 0 }, - { x: 1_637_368_200, y: 0 }, - { x: 1_637_368_260, y: 0 }, - { x: 1_637_368_320, y: 0 }, - { x: 1_637_368_380, y: 0 }, - { x: 1_637_368_440, y: 0 }, - { x: 1_637_368_500, y: 0 }, - { x: 1_637_368_560, y: 0 }, - { x: 1_637_368_620, y: 0 }, - { x: 1_637_368_680, y: 0 }, - { x: 1_637_368_740, y: 0 }, - { x: 1_637_368_800, y: 0 }, - { x: 1_637_368_860, y: 0 }, - { x: 1_637_368_920, y: 0 }, - { x: 1_637_368_980, y: 0 }, - { x: 1_637_369_040, y: 0 }, - { x: 1_637_369_100, y: 0 }, - { x: 1_637_369_160, y: 0 }, - { x: 1_637_369_220, y: 0 }, - { x: 1_637_369_280, y: 0 }, - { x: 1_637_369_340, y: 0 }, - { x: 1_637_369_400, y: 0 }, - { x: 1_637_369_460, y: 0 }, - { x: 1_637_369_520, y: 0 }, - { x: 1_637_369_580, y: 0 }, - { x: 1_637_369_640, y: 0 }, - { x: 1_637_369_700, y: 0 }, - { x: 1_637_369_760, y: 0 }, - { x: 1_637_369_820, y: 0 }, - { x: 1_637_369_880, y: 0 }, - { x: 1_637_369_940, y: 0 }, - { x: 1_637_370_000, y: 0 }, - { x: 1_637_370_060, y: 0 }, - { x: 1_637_370_120, y: 0 }, - { x: 1_637_370_180, y: 0 }, - ], - unit: '个', - }, - { - name: 'applicationhistories.app.k8s.io', - values: [ - { x: 1_637_366_580, y: 2 }, - { x: 1_637_366_640, y: 2 }, - { x: 1_637_366_700, y: 2 }, - { x: 1_637_366_760, y: 2 }, - { x: 1_637_366_820, y: 2 }, - { x: 1_637_366_880, y: 2 }, - { x: 1_637_366_940, y: 2 }, - { x: 1_637_367_000, y: 2 }, - { x: 1_637_367_060, y: 2 }, - { x: 1_637_367_120, y: 2 }, - { x: 1_637_367_180, y: 2 }, - { x: 1_637_367_240, y: 2 }, - { x: 1_637_367_300, y: 2 }, - { x: 1_637_367_360, y: 2 }, - { x: 1_637_367_420, y: 2 }, - { x: 1_637_367_480, y: 2 }, - { x: 1_637_367_540, y: 2 }, - { x: 1_637_367_600, y: 2 }, - { x: 1_637_367_660, y: 2 }, - { x: 1_637_367_720, y: 2 }, - { x: 1_637_367_780, y: 2 }, - { x: 1_637_367_840, y: 2 }, - { x: 1_637_367_900, y: 2 }, - { x: 1_637_367_960, y: 2 }, - { x: 1_637_368_020, y: 2 }, - { x: 1_637_368_080, y: 2 }, - { x: 1_637_368_140, y: 2 }, - { x: 1_637_368_200, y: 2 }, - { x: 1_637_368_260, y: 2 }, - { x: 1_637_368_320, y: 2 }, - { x: 1_637_368_380, y: 2 }, - { x: 1_637_368_440, y: 2 }, - { x: 1_637_368_500, y: 2 }, - { x: 1_637_368_560, y: 2 }, - { x: 1_637_368_620, y: 2 }, - { x: 1_637_368_680, y: 2 }, - { x: 1_637_368_740, y: 2 }, - { x: 1_637_368_800, y: 2 }, - { x: 1_637_368_860, y: 2 }, - { x: 1_637_368_920, y: 2 }, - { x: 1_637_368_980, y: 2 }, - { x: 1_637_369_040, y: 2 }, - { x: 1_637_369_100, y: 2 }, - { x: 1_637_369_160, y: 2 }, - { x: 1_637_369_220, y: 2 }, - { x: 1_637_369_280, y: 2 }, - { x: 1_637_369_340, y: 2 }, - { x: 1_637_369_400, y: 2 }, - { x: 1_637_369_460, y: 2 }, - { x: 1_637_369_520, y: 2 }, - { x: 1_637_369_580, y: 2 }, - { x: 1_637_369_640, y: 2 }, - { x: 1_637_369_700, y: 2 }, - { x: 1_637_369_760, y: 2 }, - { x: 1_637_369_820, y: 2 }, - { x: 1_637_369_880, y: 2 }, - { x: 1_637_369_940, y: 2 }, - { x: 1_637_370_000, y: 2 }, - { x: 1_637_370_060, y: 2 }, - { x: 1_637_370_120, y: 2 }, - { x: 1_637_370_180, y: 2 }, - ], - unit: '个', - }, - { - name: 'applicationrevisions.core.oam.dev', - values: [ - { x: 1_637_366_580, y: 0 }, - { x: 1_637_366_640, y: 0 }, - { x: 1_637_366_700, y: 0 }, - { x: 1_637_366_760, y: 0 }, - { x: 1_637_366_820, y: 0 }, - { x: 1_637_366_880, y: 0 }, - { x: 1_637_366_940, y: 0 }, - { x: 1_637_367_000, y: 0 }, - { x: 1_637_367_060, y: 0 }, - { x: 1_637_367_120, y: 0 }, - { x: 1_637_367_180, y: 0 }, - { x: 1_637_367_240, y: 0 }, - { x: 1_637_367_300, y: 0 }, - { x: 1_637_367_360, y: 0 }, - { x: 1_637_367_420, y: 0 }, - { x: 1_637_367_480, y: 0 }, - { x: 1_637_367_540, y: 0 }, - { x: 1_637_367_600, y: 0 }, - { x: 1_637_367_660, y: 0 }, - { x: 1_637_367_720, y: 0 }, - { x: 1_637_367_780, y: 0 }, - { x: 1_637_367_840, y: 0 }, - { x: 1_637_367_900, y: 0 }, - { x: 1_637_367_960, y: 0 }, - { x: 1_637_368_020, y: 0 }, - { x: 1_637_368_080, y: 0 }, - { x: 1_637_368_140, y: 0 }, - { x: 1_637_368_200, y: 0 }, - { x: 1_637_368_260, y: 0 }, - { x: 1_637_368_320, y: 0 }, - { x: 1_637_368_380, y: 0 }, - { x: 1_637_368_440, y: 0 }, - { x: 1_637_368_500, y: 0 }, - { x: 1_637_368_560, y: 0 }, - { x: 1_637_368_620, y: 0 }, - { x: 1_637_368_680, y: 0 }, - { x: 1_637_368_740, y: 0 }, - { x: 1_637_368_800, y: 0 }, - { x: 1_637_368_860, y: 0 }, - { x: 1_637_368_920, y: 0 }, - { x: 1_637_368_980, y: 0 }, - { x: 1_637_369_040, y: 0 }, - { x: 1_637_369_100, y: 0 }, - { x: 1_637_369_160, y: 0 }, - { x: 1_637_369_220, y: 0 }, - { x: 1_637_369_280, y: 0 }, - { x: 1_637_369_340, y: 0 }, - { x: 1_637_369_400, y: 0 }, - { x: 1_637_369_460, y: 0 }, - { x: 1_637_369_520, y: 0 }, - { x: 1_637_369_580, y: 0 }, - { x: 1_637_369_640, y: 0 }, - { x: 1_637_369_700, y: 0 }, - { x: 1_637_369_760, y: 0 }, - { x: 1_637_369_820, y: 0 }, - { x: 1_637_369_880, y: 0 }, - { x: 1_637_369_940, y: 0 }, - { x: 1_637_370_000, y: 0 }, - { x: 1_637_370_060, y: 0 }, - { x: 1_637_370_120, y: 0 }, - { x: 1_637_370_180, y: 0 }, - ], - unit: '个', - }, - { - name: 'applications.app.k8s.io', - values: [ - { x: 1_637_366_580, y: 2 }, - { x: 1_637_366_640, y: 2 }, - { x: 1_637_366_700, y: 2 }, - { x: 1_637_366_760, y: 2 }, - { x: 1_637_366_820, y: 2 }, - { x: 1_637_366_880, y: 2 }, - { x: 1_637_366_940, y: 2 }, - { x: 1_637_367_000, y: 2 }, - { x: 1_637_367_060, y: 2 }, - { x: 1_637_367_120, y: 2 }, - { x: 1_637_367_180, y: 2 }, - { x: 1_637_367_240, y: 2 }, - { x: 1_637_367_300, y: 2 }, - { x: 1_637_367_360, y: 2 }, - { x: 1_637_367_420, y: 2 }, - { x: 1_637_367_480, y: 2 }, - { x: 1_637_367_540, y: 2 }, - { x: 1_637_367_600, y: 2 }, - { x: 1_637_367_660, y: 2 }, - { x: 1_637_367_720, y: 2 }, - { x: 1_637_367_780, y: 2 }, - { x: 1_637_367_840, y: 2 }, - { x: 1_637_367_900, y: 2 }, - { x: 1_637_367_960, y: 2 }, - { x: 1_637_368_020, y: 2 }, - { x: 1_637_368_080, y: 2 }, - { x: 1_637_368_140, y: 2 }, - { x: 1_637_368_200, y: 2 }, - { x: 1_637_368_260, y: 2 }, - { x: 1_637_368_320, y: 2 }, - { x: 1_637_368_380, y: 2 }, - { x: 1_637_368_440, y: 2 }, - { x: 1_637_368_500, y: 2 }, - { x: 1_637_368_560, y: 2 }, - { x: 1_637_368_620, y: 2 }, - { x: 1_637_368_680, y: 2 }, - { x: 1_637_368_740, y: 2 }, - { x: 1_637_368_800, y: 2 }, - { x: 1_637_368_860, y: 2 }, - { x: 1_637_368_920, y: 2 }, - { x: 1_637_368_980, y: 2 }, - { x: 1_637_369_040, y: 2 }, - { x: 1_637_369_100, y: 2 }, - { x: 1_637_369_160, y: 2 }, - { x: 1_637_369_220, y: 2 }, - { x: 1_637_369_280, y: 2 }, - { x: 1_637_369_340, y: 2 }, - { x: 1_637_369_400, y: 2 }, - { x: 1_637_369_460, y: 2 }, - { x: 1_637_369_520, y: 2 }, - { x: 1_637_369_580, y: 2 }, - { x: 1_637_369_640, y: 2 }, - { x: 1_637_369_700, y: 2 }, - { x: 1_637_369_760, y: 2 }, - { x: 1_637_369_820, y: 2 }, - { x: 1_637_369_880, y: 2 }, - { x: 1_637_369_940, y: 2 }, - { x: 1_637_370_000, y: 2 }, - { x: 1_637_370_060, y: 2 }, - { x: 1_637_370_120, y: 2 }, - { x: 1_637_370_180, y: 2 }, - ], - unit: '个', - }, - { - name: 'applications.core.oam.dev', - values: [ - { x: 1_637_366_580, y: 0 }, - { x: 1_637_366_640, y: 0 }, - { x: 1_637_366_700, y: 0 }, - { x: 1_637_366_760, y: 0 }, - { x: 1_637_366_820, y: 0 }, - { x: 1_637_366_880, y: 0 }, - { x: 1_637_366_940, y: 0 }, - { x: 1_637_367_000, y: 0 }, - { x: 1_637_367_060, y: 0 }, - { x: 1_637_367_120, y: 0 }, - { x: 1_637_367_180, y: 0 }, - { x: 1_637_367_240, y: 0 }, - { x: 1_637_367_300, y: 0 }, - { x: 1_637_367_360, y: 0 }, - { x: 1_637_367_420, y: 0 }, - { x: 1_637_367_480, y: 0 }, - { x: 1_637_367_540, y: 0 }, - { x: 1_637_367_600, y: 0 }, - { x: 1_637_367_660, y: 0 }, - { x: 1_637_367_720, y: 0 }, - { x: 1_637_367_780, y: 0 }, - { x: 1_637_367_840, y: 0 }, - { x: 1_637_367_900, y: 0 }, - { x: 1_637_367_960, y: 0 }, - { x: 1_637_368_020, y: 0 }, - { x: 1_637_368_080, y: 0 }, - { x: 1_637_368_140, y: 0 }, - { x: 1_637_368_200, y: 0 }, - { x: 1_637_368_260, y: 0 }, - { x: 1_637_368_320, y: 0 }, - { x: 1_637_368_380, y: 0 }, - { x: 1_637_368_440, y: 0 }, - { x: 1_637_368_500, y: 0 }, - { x: 1_637_368_560, y: 0 }, - { x: 1_637_368_620, y: 0 }, - { x: 1_637_368_680, y: 0 }, - { x: 1_637_368_740, y: 0 }, - { x: 1_637_368_800, y: 0 }, - { x: 1_637_368_860, y: 0 }, - { x: 1_637_368_920, y: 0 }, - { x: 1_637_368_980, y: 0 }, - { x: 1_637_369_040, y: 0 }, - { x: 1_637_369_100, y: 0 }, - { x: 1_637_369_160, y: 0 }, - { x: 1_637_369_220, y: 0 }, - { x: 1_637_369_280, y: 0 }, - { x: 1_637_369_340, y: 0 }, - { x: 1_637_369_400, y: 0 }, - { x: 1_637_369_460, y: 0 }, - { x: 1_637_369_520, y: 0 }, - { x: 1_637_369_580, y: 0 }, - { x: 1_637_369_640, y: 0 }, - { x: 1_637_369_700, y: 0 }, - { x: 1_637_369_760, y: 0 }, - { x: 1_637_369_820, y: 0 }, - { x: 1_637_369_880, y: 0 }, - { x: 1_637_369_940, y: 0 }, - { x: 1_637_370_000, y: 0 }, - { x: 1_637_370_060, y: 0 }, - { x: 1_637_370_120, y: 0 }, - { x: 1_637_370_180, y: 0 }, - ], - unit: '个', - }, - { - name: 'appreleases.operator.alauda.io', - values: [ - { x: 1_637_366_580, y: 19 }, - { x: 1_637_366_640, y: 19 }, - { x: 1_637_366_700, y: 19 }, - { x: 1_637_366_760, y: 19 }, - { x: 1_637_366_820, y: 19 }, - { x: 1_637_366_880, y: 19 }, - { x: 1_637_366_940, y: 19 }, - { x: 1_637_367_000, y: 19 }, - { x: 1_637_367_060, y: 19 }, - { x: 1_637_367_120, y: 19 }, - { x: 1_637_367_180, y: 19 }, - { x: 1_637_367_240, y: 19 }, - { x: 1_637_367_300, y: 19 }, - { x: 1_637_367_360, y: 19 }, - { x: 1_637_367_420, y: 19 }, - { x: 1_637_367_480, y: 19 }, - { x: 1_637_367_540, y: 19 }, - { x: 1_637_367_600, y: 19 }, - { x: 1_637_367_660, y: 19 }, - { x: 1_637_367_720, y: 19 }, - { x: 1_637_367_780, y: 19 }, - { x: 1_637_367_840, y: 19 }, - { x: 1_637_367_900, y: 19 }, - { x: 1_637_367_960, y: 19 }, - { x: 1_637_368_020, y: 19 }, - { x: 1_637_368_080, y: 19 }, - { x: 1_637_368_140, y: 19 }, - { x: 1_637_368_200, y: 19 }, - { x: 1_637_368_260, y: 19 }, - { x: 1_637_368_320, y: 19 }, - { x: 1_637_368_380, y: 19 }, - { x: 1_637_368_440, y: 19 }, - { x: 1_637_368_500, y: 19 }, - { x: 1_637_368_560, y: 19 }, - { x: 1_637_368_620, y: 19 }, - { x: 1_637_368_680, y: 19 }, - { x: 1_637_368_740, y: 19 }, - { x: 1_637_368_800, y: 19 }, - { x: 1_637_368_860, y: 19 }, - { x: 1_637_368_920, y: 19 }, - { x: 1_637_368_980, y: 19 }, - { x: 1_637_369_040, y: 19 }, - { x: 1_637_369_100, y: 19 }, - { x: 1_637_369_160, y: 19 }, - { x: 1_637_369_220, y: 19 }, - { x: 1_637_369_280, y: 19 }, - { x: 1_637_369_340, y: 19 }, - { x: 1_637_369_400, y: 19 }, - { x: 1_637_369_460, y: 19 }, - { x: 1_637_369_520, y: 19 }, - { x: 1_637_369_580, y: 19 }, - { x: 1_637_369_640, y: 19 }, - { x: 1_637_369_700, y: 19 }, - { x: 1_637_369_760, y: 19 }, - { x: 1_637_369_820, y: 19 }, - { x: 1_637_369_880, y: 19 }, - { x: 1_637_369_940, y: 19 }, - { x: 1_637_370_000, y: 19 }, - { x: 1_637_370_060, y: 19 }, - { x: 1_637_370_120, y: 19 }, - { x: 1_637_370_180, y: 19 }, - ], - unit: '个', - }, -]; - -export const groupPieData = [ - { - name: '部署', - value: 20, - color: '#999', - }, - { - name: '有状态', - value: 20, - // color: '#0abf5b', - }, - { - name: '守护', - value: 50, - // color: '#006eff', - }, -]; - -export const groupBarData = [ - { - name: 'deployment', - values: [ - { x: 'running', y: 1, index: 0, color: '#0abf5b' }, - { x: 'pending', y: 2, index: 1, color: '#006eff' }, - { x: 'stopped', y: 3, index: 2, color: '#999' }, - ], - }, - { - name: 'statefulset', - values: [ - { x: 'running', y: 4, index: 0, color: '#0abf5b' }, - { x: 'pending', y: 2, index: 1, color: '#006eff' }, - { x: 'stopped', y: 1, index: 2, color: '#999' }, - ], - }, - { - name: 'daemonset', - values: [ - { x: 'running', y: 3, index: 0, color: '#0abf5b' }, - { x: 'pending', y: 1, index: 1, color: '#006eff' }, - { x: 'stopped', y: 2, index: 2, color: '#999' }, - ], - }, -]; - -export const barData = [ - { - name: '柱状体', - values: [ - { x: 1_637_396_220_000, y: 5757 }, - { x: 1_637_396_250_000, y: 8759 }, - { x: 1_637_396_280_000, y: 8941 }, - { x: 1_637_396_310_000, y: 8805 }, - { x: 1_637_396_340_000, y: 8702 }, - { x: 1_637_396_370_000, y: 8712 }, - { x: 1_637_396_400_000, y: 8826 }, - { x: 1_637_396_430_000, y: 8757 }, - { x: 1_637_396_460_000, y: 9162 }, - { x: 1_637_396_490_000, y: 8822 }, - { x: 1_637_396_520_000, y: 8793 }, - // { x: 1637396580000, y: 8761 }, - // { x: 1637396610000, y: 8728 }, - // { x: 1637396640000, y: 8583 }, - // { x: 1637396670000, y: 8780 }, - // { x: 1637396700000, y: 8717 }, - // { x: 1637396730000, y: 8768 }, - // { x: 1637396760000, y: 8781 }, - // { x: 1637396790000, y: 8638 }, - // { x: 1637396820000, y: 8974 }, - // { x: 1637396850000, y: 8516 }, - // { x: 1637396880000, y: 8518 }, - // { x: 1637396910000, y: 8936 }, - // { x: 1637396940000, y: 8684 }, - // { x: 1637396970000, y: 8668 }, - // { x: 1637397000000, y: 8971 }, - // { x: 1637397030000, y: 9703 }, - // { x: 1637397060000, y: 9943 }, - // { x: 1637397090000, y: 10012 }, - // { x: 1637397120000, y: 458 }, - ], - }, -]; - -export const ScatterData = [ - { - name: 'd1', - values: [ - { - name: 'bd89adc30926c3341e853db82a4e2b69', - x: 1_646_983_049_633_887, - y: 15_897, - size: 2, - }, - - { - name: '63e6dd73c8258882d416630751af1bbc', - x: 1_646_983_047_621_645, - y: 17_442, - size: 7, - }, - { - name: 'a064c92afac5bf2c841d2ffc90d2d557', - x: 1_646_983_044_430_811, - y: 13_381, - size: 2, - }, - { - name: 'f8f4518fbe7c849d49f6fe17545be957', - x: 1_646_983_042_325_872, - y: 17_858, - size: 8, - }, - { - name: 'b19039ab5ec7f35113ee4d3ce5637139', - x: 1_646_983_039_139_002, - y: 12_875, - size: 3, - }, - { - name: '6b9491359263efc45685dc6ae00422d6', - x: 1_646_983_037_027_884, - y: 14_174, - size: 2, - }, - - { - name: '2afc8ca663853ff6a05d5ebb4b39bced', - x: 1_646_983_033_830_560, - y: 27_524, - size: 4, - }, - - { - name: 'a85104881d34b8bd58436c9b06c29956', - x: 1_646_983_031_735_682, - y: 43_427, - size: 2, - }, - - { - name: '6fc8ac16613479be0fe1b295d3687ec8', - x: 1_646_983_028_630_635, - y: 17_352, - size: 2, - }, - - { - name: '99d06ec023ce3be1745a4808af7337bc', - x: 1_646_983_026_422_212, - y: 15_486, - size: 2, - }, - ], - }, - { - name: 'd2', - values: [ - { - name: 'bd89adc30926c3341e853db82a4e2b69', - x: 1_646_983_042_633_887, - y: 12_897, - size: 24, - }, - { - name: '63e6dd73c8258882d416630751af1bbc', - x: 1_646_983_045_621_645, - y: 27_442, - size: 17, - }, - { - name: 'a064c92afac5bf2c841d2ffc90d2d557', - x: 1_646_983_044_430_811, - y: 13_381, - size: 12, - }, - { - name: 'f8f4518fbe7c849d49f6fe17545be957', - x: 1_646_983_042_325_872, - y: 17_858, - size: 8, - }, - { - name: 'b19039ab5ec7f35113ee4d3ce5637139', - x: 1_646_983_039_139_002, - y: 12_875, - size: 13, - }, - { - name: '6b9491359263efc45685dc6ae00422d6', - x: 1_646_983_037_027_884, - y: 14_174, - size: 7, - }, - - { - name: '2afc8ca663853ff6a05d5ebb4b39bced', - x: 1_646_983_033_830_560, - y: 27_524, - size: 19, - }, - - { - name: 'a85104881d34b8bd58436c9b06c29956', - x: 1_646_983_031_735_682, - y: 23_427, - size: 12, - }, - - { - name: '6fc8ac16613479be0fe1b295d3687ec8', - x: 1_646_983_028_600_635, - y: 1752, - size: 2, - }, - - { - name: '99d06ec023ce3be1745a4808af7337bc', - x: 1_646_983_026_422_212, - y: 15_486, - size: 12, - }, - ], - }, -]; diff --git a/stories/demo.stories.ts b/stories/demo.stories.ts new file mode 100644 index 0000000..bda9e61 --- /dev/null +++ b/stories/demo.stories.ts @@ -0,0 +1,157 @@ +import addons from '@storybook/addons'; +import { Story, Meta } from '@storybook/html'; +import { DARK_MODE_EVENT_NAME } from 'storybook-dark-mode'; + +import { dealWithTime, generateTime, generateY } from './utilt'; + +import { ActionType, Chart, ChartEvent } from '@alauda/chart'; +import 'uplot/dist/uPlot.min.css'; + +export default { + title: 'Demo', +} as Meta; + +let chart: Chart; + +const Template: Story = () => { + addons.getChannel().on(DARK_MODE_EVENT_NAME, (e: boolean) => { + chart?.theme(e ? 'dark' : 'light'); + }); + + setTimeout(() => { + const destroy = document.querySelector('#destroy'); + const init = document.querySelector('#init'); + const change = document.querySelector('#change'); + const update = document.querySelector('#update'); + const total = 60; + const step = 720; + const start = '2023-01-31 09:00:00'; + const range1: [number, number] = [0, 100]; + // const range2: [number, number] = [0, 100]; + const timeData = generateTime(start, total, step); + const yData1 = generateY(total, range1); + // let yData2 = generateY(total, range2); + + const d1 = timeData.map((x, i) => ({ x, y: yData1[i] })); + // const d2 = timeData.map((x, i) => ({ x, y: yData2[i] })); + console.log(JSON.stringify(d1)); + const data = [ + { + name: 'area1', + // color: 'rgb(var(--aui-color-green))', + values: d1, + }, + // { + // name: 'area2', + // values: d2, + // }, + ]; + function getOp(container: string, data: any): any { + return { + container, + // data: [], + data, + options: { + // title: { text: '1231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312123123123123121231231231231212312312312312' }, + // title: { text: '11', custom: true }, + + // position: 'bottom-right', + // } + axis: { + x: {}, + // y: { + // autoSize: true, + // formatter: `{value}%1`, + // }, + }, + scale: { + x: {}, + y: {}, + }, + annotation: { + // lineX: { + // data: null, + // }, + // lineY: { + // data: 1, + // text: { + // content: 'lineY', + // }, + // }, + }, + tooltip: { + // showTitle: false + titleFormatter: (title: string) => + `${dealWithTime(new Date(Number(title) * 1000))}`, + }, + // tooltip: false, + }, + }; + } + initChart(); + function initChart() { + chart = new Chart(getOp('.chart-area', data)); + chart.point(); + chart.render(); + } + + destroy.addEventListener('click', () => { + if (chart) { + chart.destroy(); + } + }); + init.addEventListener('click', () => { + initChart(); + }); + let l = true; + change.addEventListener('click', () => { + const timeData = generateTime('2023-01-01 09:00:00', 200, 120); + // let yData1 = generateY(200, range1); + // let yData2 = generateY(200, range2); + // const d1 = timeData.map((x, i) => ({ x, y: yData1[i] })); + l = !l; + const d2 = timeData.map((x, i) => ({ x, y: l ? null : yData1[i] })); + const data = [ + { + name: 'area11', + // color: 'rgb(var(--aui-color-green))', + values: d2, + }, + { + name: 'area2', + values: d2, + }, + ]; + chart.data(data); + }); + + update.addEventListener('click', () => { + chart.interaction('brush-x', { + end: [ + { + trigger: ChartEvent.PLOT_MOUSEUP, + action: ActionType.BRUSH_X_END, + callback: e => { + console.log('brush-x', e); + }, + }, + ], + }); + }); + }); + + return ` + + + + + close +
    +
    +
    +
    +
    + `; +}; + +export const Demo = Template.bind({}); diff --git a/stories/gauge.stories.ts b/stories/gauge.stories.ts new file mode 100644 index 0000000..a903962 --- /dev/null +++ b/stories/gauge.stories.ts @@ -0,0 +1,116 @@ +import addons from '@storybook/addons'; +import { Story, Meta } from '@storybook/html'; +import { DARK_MODE_EVENT_NAME } from 'storybook-dark-mode'; + +import { Chart } from '@alauda/chart'; +import 'uplot/dist/uPlot.min.css'; + +export default { + title: 'Gauge', +} as Meta; + +let chart: Chart; + +const Template: Story = () => { + addons.getChannel().on(DARK_MODE_EVENT_NAME, (e: boolean) => { + chart?.theme(e ? 'dark' : 'light'); + }); + + setTimeout(() => { + const groupPieData = [ + { + name: '部署', + value: 1, + color: '#006eff', + util: '%', + }, + ]; + function getOp(container: string, data: any): any { + return { + container, + // data: [], + data, + // height: 80, + options: { + title: { + text: '123123', + }, + legend: false, + // tooltip: false, + gauge: { + max: 100, + label: { + text: '
    {value}{data[0].util}
    ', + description: 'asd12312312312', + // position: { + // y: 10, + // }, + }, + colors: [ + [0, '#73BF69'], + [20, '#EAB839'], + [70, 'red'], + ], + // text: { + // show: true, + // size: 12, + // // color: 'red' || () => 'red' + // }, + }, + }, + }; + } + initChart(); + function initChart() { + + chart = new Chart(getOp('.chart', groupPieData)); + chart.gauge({ + // outerRadius: 60, + // innerRadius: 0.2, + // colors: [ + // [0.2, '#73BF69'], + // [0.5, '#EAB839'], + // [1, 'red'], + // ], + // label: { + // text: '
    50
    ', + // position: { + // // y: 5, + // }, + // }, + // text: { + // show: false, + // size: 12, + // // color: 'red' || () => 'red' + // }, + }); + chart.interaction('element-active'); + chart.render(); + // const pie = document.getElementsByClassName( + // 'pie-chart', + // )[0] as HTMLElement; + // pie.style.width = `${window.innerWidth - 100}px`; + // // pie.style.height = `${window.innerHeight - 100}px`; + // pie.style.height = `188px`; + } + + window.addEventListener('resize', () => { + const dom = document.querySelector('.chart') as unknown as HTMLElement + // console.log(dom) + dom.innerHTML = '' + setTimeout(() => { + initChart(); + }) + }); + }); + + return ` +
    +
    +
    +
    +
    + `; +}; + +export const Gauge = Template.bind({}); diff --git a/stories/line.stories.ts b/stories/line.stories.ts index f221685..1c7e3c3 100644 --- a/stories/line.stories.ts +++ b/stories/line.stories.ts @@ -1,11 +1,9 @@ import { Story, Meta } from '@storybook/html'; -import { timeFormat } from 'd3'; -import { data } from './data'; +import { dealWithTime, generateData } from './utilt'; -import { Chart, ScaleType } from '@alauda/chart'; - -import '../src/theme/default.scss'; +import { Chart } from '@alauda/chart'; +import 'uplot/dist/uPlot.min.css'; export default { title: 'Line', @@ -13,128 +11,132 @@ export default { const Template: Story = () => { setTimeout(() => { - Chart({ - container: '#chart', - type: 'line', - title: { - text: '折线图', - // hide: true, - formatter: () => '折线图', - // offsetX: 20, - // offsetY: 30, - // hide: true, - }, - legend: { - // hide: true, - // isMount: true, - // formatter: () => { - // return document.getElementById('title').outerHTML; - // }, - // offsetX: 20, - // offsetY: 30, - // formatter: data => `
    11
    `, - // itemFormatter: `legend {name}`, - }, - data: data.map(d => ({ - ...d, - values: d.values.map(a => ({ - ...a, - x: a.x * 1000, - y: a.y * 1_000_000_000_000, - })), - })), - yAxis: { - // tickFormatter: (text) => { - // console.log(text) - // return text - // }, - }, - xAxis: { - type: ScaleType.TIME, - tickFormatter: () => timeFormat('%m-%d %H:%M'), - }, - tooltip: { - // titleFormatter: (name: Date | number | string) => - // `
    ${new Date(name)}
    `, - // itemFormatter: (values: TooltipContextItem[]) => - // `
    ${JSON.stringify(values)}
    `, - // sort: (a, b) => a.y - b.y, - }, - zoom: { - enabled: false, - // onzoomStart: d => { - // console.log('zoom start', d); - // }, - // onzoom: d => { - // console.log('zoom', d); - // }, - // onzoomEnd: d => { - // console.log('zoom end', d); + console.time('render'); + const d1 = generateData('2023-01-31 09:00:00', 60, 60); + const opts = { + container: '.chart1', + data: [ + { + name: 'line', + values: d1, + }, + // { + // name: 'line2', + // values: generateData('2023-01-31 09:00:00', 60, 60), // }, + ], + options: { + title: { text: 'chart' }, + // legend: { + // position: 'bottom-right', + // } + annotation: { + // lineX: { + // data: d1[i].x, + // text: { + // content: i, + // } + // }, + lineY: [ + { + data: '3', + text: { + content: '1111', + }, + }, + ], + }, + scale: { + // y: { max: 100, min: 10 }, + // y: {} + }, + line: { + step: 'start', + }, + tooltip: { + // showTitle: false + titleFormatter: (title: string) => + `${dealWithTime(new Date(Number(title) * 1000))}`, + }, }, - // contextCallbackFunction: (view: View) => { - // console.log(view); - // }, - xPlotLine: { - color: 'red', - value: 120, - }, - // contextCallbackFunction: view => { - // console.log('cb', view); - // setTimeout(() => { - // const legend = view.getController('legend'); + }; + const chart = new Chart(opts as any); + chart.line(); + // console.log(chart); + // chart.data(data); + // chart.shape('line'); + // chart.shape('bar', { name: 'line2' }); + chart.render(); + const reactive: any = chart.reactive(); - // legend.legendUnselectAll(); - // setTimeout(() => { - // legend.legendSelectAll(); - // }, 1000); - // }, 2000); - // }, + const btn = document.querySelector('#change'); + // let bb = true; + let i = 0; + btn.addEventListener('click', () => { + // reactive.options.title.text = String(Math.random() * 1000); + // reactive.options.legend = { + // position: 'bottom-right', + // }; + // bb = !bb + // reactive.options.tooltip = { + // showTitle: bb, + // titleFormatter: '{title}111' + // } + reactive.options.annotation = { + // lineX: { + // data: d1[i].x, + // text: { + // content: i, + // } + // }, + lineY: { + data: d1[i].y, + text: { + content: String(i), + }, + }, + }; + // reactive.options.scale = { + // y: { max: 100, min: 10 }, + // }; + // reactive.options.tooltip = false; + // reactive.data = [ + // { + // name: 'line1', + // values: generateData('2023-01-31 09:00:00', 60, 60), + // }, + // { + // name: 'line2', + // values: generateData('2023-01-31 09:00:00', 60, 60), + // }, + // ]; + i = i + 1; }); - // chart.on(RECT_EVENTS.CLICK, (value: TooltipContext) => { - // chart.updateYPlotLine(value); - // console.log(JSON.stringify(value)); - // }); - - setTimeout(() => { - // chart.updateTitle({ - // text: '123123', - // formatter: () => '123123####', - // }); - // chart.updateYPlotLine({ - // title: 1637114400000, - // values: [ - // { - // x: 1637114400000, - // y: 573, - // name: 'running', - // color: '#24b37a', - // activated: false, - // }, - // { - // x: 1637114400000, - // y: 599, - // name: 'total_num', - // color: '#006eff', - // activated: false, - // }, - // ], - // }); - // chart.updateXPlotLine({value: 10}); - }, 1000); - - // setTimeout(() => { - // chart.data(data1); - // // chart.setOptions({ - // // zoom: { - // // enabled: true, - // // }, - // // }); - // }, 1000); }); - return `
    -
    -
    `; + + return ` + +
    +
    +
    + `; }; export const line = Template.bind({}); +// line.args = { +// primary: true, +// label: 'Button', +// }; +// line.parameters = { +// backgrounds: { +// values: [ +// { name: 'red', value: '#f00' }, +// { name: 'green', value: '#0f0' }, +// { name: 'blue', value: '#00f' }, +// ], +// }, +// }; + +// 图表类型 line area bar +// 大数据量 +// sliding 动态效果 diff --git a/stories/pie.stories.ts b/stories/pie.stories.ts index 20918f1..74f5ada 100644 --- a/stories/pie.stories.ts +++ b/stories/pie.stories.ts @@ -1,67 +1,123 @@ +import addons from '@storybook/addons'; import { Story, Meta } from '@storybook/html'; - -import { groupPieData } from './data'; +import { DARK_MODE_EVENT_NAME } from 'storybook-dark-mode'; import { Chart } from '@alauda/chart'; - -import '../src/theme/default.scss'; +import 'uplot/dist/uPlot.min.css'; export default { title: 'Pie', } as Meta; +let chart: Chart; + const Template: Story = () => { + addons.getChannel().on(DARK_MODE_EVENT_NAME, (e: boolean) => { + chart?.theme(e ? 'dark' : 'light'); + }); + setTimeout(() => { - const chart = Chart({ - container: '#pieChart', - type: 'pie', - // title: { - // text: '环形图', - // }, - legend: { - // hide: true, + const groupPieData = [ + { + name: '部署', + value: 7038600, + color: '#999', }, - seriesOption: { - outerRadius: 50, - // backgroundColor: '#ededed', - total: 100, - label: { - text: '
    1000
    ', - position: { - x: '50%', - y: '50%', - }, - }, - itemStyle: { - borderRadius: 2, - borderWidth: 2, - }, - innerDisc: true, + + { + name: '有状态', + value: 7038360, + color: '#0abf5b', }, - tooltip: { - trigger: 'item', - hideTitle: true, + { + name: '守护', + value: 7039320, + color: '#006eff', }, - data: groupPieData, - }); - // chart.on(PIE_EVENTS.ITEM_HOVERED, function (e) { - // console.log(e); - // }); - // chart.on(PIE_EVENTS.ITEM_MOUSEOUT, function (e) { - // console.log(e); - // }); - setTimeout(() => { - // chart.data(groupPieData); - chart.updatePie({ - label: { - text: '
    2222
    ', + { + name: 'sss', + value: 52186903, + color: '#999', + }, + ]; + + const data =[ + { name: '123', value: 7038600 }, + { name: 7038360, value: 7038360 }, + { name: 7039320, value: 7039320 }, + { name: 52186903, value: 52186903 }, + { name: 397374320, value: 397374320 }, + { name: 485002955, value: 485002955 }, + { name: 19920191, value: 19920191 }, + { name: 731859161, value: 731859161 }, + { name: '11231231231112312312311123123123111231231231112311231231231232312311123123123111231231231', value: 736941297 }, + ]; + + function getOp(container: string, data: any): any { + return { + container, + // data: [], + data, + options: { + legend: { + position: 'bottom-left' + }, + tooltip: true, + pie: { + // startAngle: -(Math.PI / 1.4), + // endAngle: Math.PI / 1.4, + // padAngle: 0.05, + // total: 100, + labelLine: { + labels: ['name','percent'], + show: true, + }, + // label: { + // text: '
    1000
    ', + // }, + // backgroundColor: '#ededed', + // itemStyle: { + // borderWidth: 0, + // borderRadius: 0, + // }, + // innerDisc: true + }, }, + }; + } + initChart(); + function initChart() { + chart = new Chart(getOp('.chart', data)); + chart.pie(); + chart.interaction('element-active'); + chart.render(); + const pie = document.getElementsByClassName( + 'pie-chart', + )[0] as HTMLElement; + pie.style.width = `${window.innerWidth - 100}px`; + // pie.style.height = `${window.innerHeight - 100}px`; + pie.style.height = `188px`; + + window.addEventListener('resize', e => { + const pie = document.getElementsByClassName( + 'pie-chart', + )[0] as HTMLElement; + pie.style.width = `${window.innerWidth - 100}px`; + // pie.style.height = `240px`; + pie.style.height = `${window.innerHeight - 100}px`; }); - }, 2000); - }, 0); - return `
    -
    -
    `; + } + }); + + return ` +
    +
    +
    +
    +
    +
    +
    + `; }; -export const pie = Template.bind({}); +export const Pie = Template.bind({}); diff --git a/stories/point.stories.ts b/stories/point.stories.ts new file mode 100644 index 0000000..b53940a --- /dev/null +++ b/stories/point.stories.ts @@ -0,0 +1,57 @@ +import { Story, Meta } from '@storybook/html'; + +import { dealWithTime, generateData } from './utilt'; + +import { Chart } from '@alauda/chart'; +import 'uplot/dist/uPlot.min.css'; + +export default { + title: 'Point', +} as Meta; + +const Template: Story = () => { + setTimeout(() => { + console.time('render'); + const chart = new Chart({ + container: '.chart1', + height: 200, + data: [ + { + name: 'point1', + values: generateData('2023-01-31 09:00:00', 60, 60), + }, + { + name: 'point2', + values: generateData('2023-01-31 09:00:00', 60, 60, [2, 5]), + }, + ], + options: { + title: { text: 'chart' }, + // legend: { + // position: 'bottom-right', + // } + tooltip: { + // showTitle: false + titleFormatter: title => + `${dealWithTime(new Date(Number(title) * 1000))}`, + }, + }, + }); + // console.log(chart); + // chart.data(data); + chart.point(); + // chart.shape('bar', { name: 'line2' }); + chart.render(); + }); + return ` +
    +
    +
    + `; +}; + +export const Point = Template.bind({}); + +// 图表类型 line area bar +// 大数据量 +// sliding 动态效果 diff --git a/stories/scatter.stories.ts b/stories/scatter.stories.ts deleted file mode 100644 index ff9270c..0000000 --- a/stories/scatter.stories.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Story, Meta } from '@storybook/html'; -import { timeFormat } from 'd3'; -import { round } from 'lodash'; - -import { ScatterData } from './data'; - -import { Chart, ScaleType } from '@alauda/chart'; - -import '../src/theme/default.scss'; - -export default { - title: 'Scatter', -} as Meta; - -const Template: Story = () => { - setTimeout(() => { - Chart({ - container: '#chart', - type: 'scatter', - title: { - text: '气泡-散点图', - }, - legend: {}, - seriesOption: { - type: 'bubble', - size: 3, - minSize: 10, - maxSize: 30, - }, - data: ScatterData, - yAxis: { - tickFormatter: duration => { - let d = duration / 1000; - let units = 'ms'; - if (d >= 1000) { - units = 's'; - d /= 1000; - } - return `${round(d, 2)}${units}`; - }, - }, - xAxis: { - type: ScaleType.TIME, - tickFormatter: () => timeFormat('%m-%d %H:%M'), - }, - tooltip: { - trigger: 'item', - // titleFormatter: (name: Date | number | string) => - // `
    ${new Date(name)}
    `, - // itemFormatter: (values: TooltipContextItem[]) => - // `
    ${JSON.stringify(values)}
    `, - // sort: (a, b) => a.y - b.y, - }, - }); - }); - return `
    -
    -
    `; -}; - -export const scatter = Template.bind({}); diff --git a/stories/utilt.ts b/stories/utilt.ts new file mode 100644 index 0000000..c57c0bc --- /dev/null +++ b/stories/utilt.ts @@ -0,0 +1,58 @@ +export function dealWithTime(date: Date) { + const Y = date.getFullYear(); + const M = + date.getMonth() + 1 - 0 >= 10 + ? Number(date.getMonth()) + 1 + : '0' + (Number(date.getMonth()) + 1); + const D = date.getDate(); + const h = date.getHours() >= 10 ? date.getHours() : '0' + date.getHours(); + const m = + date.getMinutes() >= 10 ? date.getMinutes() : '0' + date.getMinutes(); + const s = + date.getSeconds() >= 10 ? date.getSeconds() : '0' + date.getSeconds(); + return Y + '-' + M + '-' + D + ' ' + h + ':' + m + ':' + s; +} + +export function generateData( + start: string, + num: number, + step: number, + range: [number, number] = [10, 20], +) { + const s = new Date(start).valueOf() / 1000; + const [max, min] = range; + + return Array.from({ length: num + 1 }) + .fill(0) + .map((_, i) => { + const x: number = i ? s + i * step : s; + const v = Math.random() * (max + 1 - min) + min; + return { + x, + y: v, + xx: dealWithTime(new Date(x * 1000)), + size: Math.random() * (max + 1 - min) + min, + }; + }); +} + +export function generateTime(start: string, num: number, step: number) { + const s = new Date(start).valueOf() / 1000; + return Array.from({ length: num + 1 }) + .fill(0) + .map((_, i) => { + const x: number = i ? s + i * step : s; + return x; + }); +} + +export function generateY(num: number, range: [number, number] = [10, 20]) { + return Array.from({ length: num + 1 }) + .fill(0) + .map(() => getRandom(range)); +} + +export function getRandom(range: [number, number] = [10, 20]) { + const [max, min] = range; + return +(Math.random() * (max + 1 - min) + min).toFixed(2); +} diff --git a/yarn.lock b/yarn.lock index 9db18d8..ae5fa98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -364,12 +364,12 @@ dependencies: eslint-rule-composer "^0.3.0" -"@babel/generator@^7.0.0-beta.44", "@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.9.tgz#68337e9ea8044d6ddc690fb29acae39359cca0a5" - integrity sha512-wt5Naw6lJrL1/SGkipMiFxJjtyczUWTP38deiP1PO60HsBjDeKk08CGC3S8iVuvf0FmTdgKwU1KIXzSKL1G0Ug== +"@babel/generator@^7.0.0-beta.44", "@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.18.9", "@babel/generator@^7.20.7": + version "7.20.7" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.7.tgz#f8ef57c8242665c5929fe2e8d82ba75460187b4a" + integrity sha512-7wqMOJq8doJMZmP4ApXTzLxSr7+oO2jroJURrVEp6XShrQUObV8Tq/D0NCcoYg2uHqUrjzO0zwBjoYzelxK+sw== dependencies: - "@babel/types" "^7.18.9" + "@babel/types" "^7.20.7" "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" @@ -459,13 +459,13 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-function-name@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.18.9.tgz#940e6084a55dee867d33b4e487da2676365e86b0" - integrity sha512-fJgWlZt7nxGksJS9a0XdSaI4XvpExnNIgRP+rVefWh5U7BL8pPuir6SJUmFKRfjWQ51OtWSzwOxhaH/EBWWc0A== +"@babel/helper-function-name@^7.18.9", "@babel/helper-function-name@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c" + integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w== dependencies: - "@babel/template" "^7.18.6" - "@babel/types" "^7.18.9" + "@babel/template" "^7.18.10" + "@babel/types" "^7.19.0" "@babel/helper-hoist-variables@^7.18.6": version "7.18.6" @@ -561,10 +561,15 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-validator-identifier@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076" - integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g== +"@babel/helper-string-parser@^7.19.4": + version "7.19.4" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz#38d3acb654b4701a9b77fb0615a96f775c3a9e63" + integrity sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw== + +"@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": + version "7.19.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" + integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== "@babel/helper-validator-option@^7.18.6": version "7.18.6" @@ -599,10 +604,10 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.13.16", "@babel/parser@^7.14.7", "@babel/parser@^7.18.6", "@babel/parser@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.9.tgz#f2dde0c682ccc264a9a8595efd030a5cc8fd2539" - integrity sha512-9uJveS9eY9DJ0t64YbIBZICtJy8a5QrDEVdiLCG97fVLpDTpGX7t8mMSb6OWw6Lrnjqj4O8zwjELX3dhoMgiBg== +"@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.13.16", "@babel/parser@^7.14.7", "@babel/parser@^7.18.9", "@babel/parser@^7.20.7": + version "7.20.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.7.tgz#66fe23b3c8569220817d5feb8b9dcdc95bb4f71b" + integrity sha512-T3Z9oHybU+0vZlY9CiDSJQTD5ZapcW18ZctFMi0MOAl/4BjFF4ul7NVSARLdbGO5vDqy9eQiGTV0LtKfvCYvcg== "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" @@ -1484,43 +1489,44 @@ source-map-support "^0.5.16" "@babel/runtime@^7.0.0", "@babel/runtime@^7.10.4", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a" - integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw== + version "7.20.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.7.tgz#fcb41a5a70550e04a7b708037c7c32f7f356d8fd" + integrity sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ== dependencies: - regenerator-runtime "^0.13.4" + regenerator-runtime "^0.13.11" -"@babel/template@^7.0.0", "@babel/template@^7.12.7", "@babel/template@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.6.tgz#1283f4993e00b929d6e2d3c72fdc9168a2977a31" - integrity sha512-JoDWzPe+wgBsTTgdnIma3iHNFC7YVJoPssVBDjiHfNlyt4YcunDtcDOUmfVDfCK5MfdsaIoX9PkijPhjH3nYUw== +"@babel/template@^7.0.0", "@babel/template@^7.12.7", "@babel/template@^7.18.10", "@babel/template@^7.18.6": + version "7.20.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8" + integrity sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw== dependencies: "@babel/code-frame" "^7.18.6" - "@babel/parser" "^7.18.6" - "@babel/types" "^7.18.6" + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" "@babel/traverse@^7.0.0", "@babel/traverse@^7.12.11", "@babel/traverse@^7.12.9", "@babel/traverse@^7.13.0", "@babel/traverse@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.9.tgz#deeff3e8f1bad9786874cb2feda7a2d77a904f98" - integrity sha512-LcPAnujXGwBgv3/WHv01pHtb2tihcyW1XuL9wd7jqh1Z8AQkTd+QVjMrMijrln0T7ED3UXLIy36P9Ao7W75rYg== + version "7.20.10" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.10.tgz#2bf98239597fcec12f842756f186a9dde6d09230" + integrity sha512-oSf1juCgymrSez8NI4A2sr4+uB/mFd9MXplYGPEBnfAuWmmyeVcHa6xLPiaRBcXkcb/28bgxmQLTVwFKE1yfsg== dependencies: "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.18.9" + "@babel/generator" "^7.20.7" "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" "@babel/helper-hoist-variables" "^7.18.6" "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.18.9" - "@babel/types" "^7.18.9" + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.12.11", "@babel/types@^7.12.7", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.4.4": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.9.tgz#7148d64ba133d8d73a41b3172ac4b83a1452205f" - integrity sha512-WwMLAg2MvJmt/rKEVQBBhIVffMmnilX4oe0sRe7iPOHIGsqpruFHHdrfj4O1CMMtgMtCU4oPafZjDPCRgO57Wg== +"@babel/types@^7.0.0", "@babel/types@^7.12.11", "@babel/types@^7.12.7", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.20.7", "@babel/types@^7.4.4": + version "7.20.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.7.tgz#54ec75e252318423fc07fb644dc6a58a64c09b7f" + integrity sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg== dependencies: - "@babel/helper-validator-identifier" "^7.18.6" + "@babel/helper-string-parser" "^7.19.4" + "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" "@bloomberg/record-tuple-polyfill@^0.0.3": @@ -2862,6 +2868,46 @@ global "^4.4.0" regenerator-runtime "^0.13.7" +"@storybook/addons@^6.5.14": + version "6.5.15" + resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-6.5.15.tgz#3c3fafbf3c9ce2182d652cb6682f6581ba6580e1" + integrity sha512-xT31SuSX+kYGyxCNK2nqL7WTxucs3rSmhiCLovJcUjYk+QquV3c2c53Ki7lwwdDbzfXFcNAe0HJ4hoTN4jhn0Q== + dependencies: + "@storybook/api" "6.5.15" + "@storybook/channels" "6.5.15" + "@storybook/client-logger" "6.5.15" + "@storybook/core-events" "6.5.15" + "@storybook/csf" "0.0.2--canary.4566f4d.1" + "@storybook/router" "6.5.15" + "@storybook/theming" "6.5.15" + "@types/webpack-env" "^1.16.0" + core-js "^3.8.2" + global "^4.4.0" + regenerator-runtime "^0.13.7" + +"@storybook/api@6.5.15", "@storybook/api@^6.5.14": + version "6.5.15" + resolved "https://registry.yarnpkg.com/@storybook/api/-/api-6.5.15.tgz#a189dac82a57ae9cfac43c887207b1075a2a2e96" + integrity sha512-BBE0KXKvj1/3jTghbIoWfrcDM0t+xO7EYtWWAXD6XlhGsZVD2Dy82Z52ONyLulMDRpMWl0OYy3h6A1YnFUH25w== + dependencies: + "@storybook/channels" "6.5.15" + "@storybook/client-logger" "6.5.15" + "@storybook/core-events" "6.5.15" + "@storybook/csf" "0.0.2--canary.4566f4d.1" + "@storybook/router" "6.5.15" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.5.15" + core-js "^3.8.2" + fast-deep-equal "^3.1.3" + global "^4.4.0" + lodash "^4.17.21" + memoizerific "^1.11.3" + regenerator-runtime "^0.13.7" + store2 "^2.12.0" + telejson "^6.0.8" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/api@6.5.9": version "6.5.9" resolved "https://registry.yarnpkg.com/@storybook/api/-/api-6.5.9.tgz#303733214c9de0422d162f7c54ae05d088b89bf9" @@ -3006,6 +3052,15 @@ global "^4.4.0" telejson "^6.0.8" +"@storybook/channels@6.5.15": + version "6.5.15" + resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-6.5.15.tgz#586681b6ec458124da084c39bc8c518d9e96b10b" + integrity sha512-gPpsBgirv2NCXbH4WbYqdkI0JLE96aiVuu7UEWfn9yu071pQ9CLHbhXGD9fSFNrfOkyBBY10ppSE7uCXw3Wexg== + dependencies: + core-js "^3.8.2" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/channels@6.5.9": version "6.5.9" resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-6.5.9.tgz#abfab89a6587a2688e9926d4aafeb11c9d8b2e79" @@ -3076,6 +3131,14 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/client-logger@6.5.15": + version "6.5.15" + resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-6.5.15.tgz#0d9878af893a3493b6ee108cc097ae1436d7da4d" + integrity sha512-0uyxKvodq+FycGv6aUwC1wUR6suXf2+7ywMFAOlYolI4UvNj8NyU/5AfgKT5XnxYAgPmoCiAjOE700TrfHrosw== + dependencies: + core-js "^3.8.2" + global "^4.4.0" + "@storybook/client-logger@6.5.9": version "6.5.9" resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-6.5.9.tgz#dc1669abe8c45af1cc38f74c6f4b15ff33e63014" @@ -3119,6 +3182,20 @@ regenerator-runtime "^0.13.7" util-deprecate "^1.0.2" +"@storybook/components@^6.5.14": + version "6.5.15" + resolved "https://registry.yarnpkg.com/@storybook/components/-/components-6.5.15.tgz#8145be807bf48c1d010f29114411f390a9e3228f" + integrity sha512-bHTT0Oa3s4g+MBMaLBbX9ofMtb1AW59AzIUNGrfqW1XqJMGuUHMiJ7TSo+i5dRSFpbFygnwMEG9LfHxpR2Z0Dw== + dependencies: + "@storybook/client-logger" "6.5.15" + "@storybook/csf" "0.0.2--canary.4566f4d.1" + "@storybook/theming" "6.5.15" + core-js "^3.8.2" + memoizerific "^1.11.3" + qs "^6.10.0" + regenerator-runtime "^0.13.7" + util-deprecate "^1.0.2" + "@storybook/core-client@6.5.9": version "6.5.9" resolved "https://registry.yarnpkg.com/@storybook/core-client/-/core-client-6.5.9.tgz#ea6035d1c90d2c68e860e3cf629979491856cd88" @@ -3201,6 +3278,13 @@ util-deprecate "^1.0.2" webpack "4" +"@storybook/core-events@6.5.15", "@storybook/core-events@^6.5.14": + version "6.5.15" + resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-6.5.15.tgz#c12f645b50231c50eb9b26038aa67ab92b1ba24e" + integrity sha512-B1Ba6l5W7MeNclclqMMTMHgYgfdpB5SIhNCQFnzIz8blynzRhNFMdxvbAl6Je5G0S4xydYYd7Lno2kXQebs7HA== + dependencies: + core-js "^3.8.2" + "@storybook/core-events@6.5.9": version "6.5.9" resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-6.5.9.tgz#5b0783c7d22a586c0f5e927a61fe1b1223e19637" @@ -3472,6 +3556,17 @@ unfetch "^4.2.0" util-deprecate "^1.0.2" +"@storybook/router@6.5.15": + version "6.5.15" + resolved "https://registry.yarnpkg.com/@storybook/router/-/router-6.5.15.tgz#bf01d35bdd4603bf188629a6578489e313a312fd" + integrity sha512-9t8rI8t7/Krolau29gsdjdbRQ66orONIyP0efp0EukVgv6reNFzb/U14ARrl0uHys6Tl5Xyece9FoakQUdn8Kg== + dependencies: + "@storybook/client-logger" "6.5.15" + core-js "^3.8.2" + memoizerific "^1.11.3" + qs "^6.10.0" + regenerator-runtime "^0.13.7" + "@storybook/router@6.5.9": version "6.5.9" resolved "https://registry.yarnpkg.com/@storybook/router/-/router-6.5.9.tgz#4740248f8517425b2056273fb366ace8a17c65e8" @@ -3546,6 +3641,16 @@ read-pkg-up "^7.0.1" regenerator-runtime "^0.13.7" +"@storybook/theming@6.5.15", "@storybook/theming@^6.5.14": + version "6.5.15" + resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-6.5.15.tgz#048461b37ad0c29dc8d91a065a6bf1c90067524c" + integrity sha512-pgdW0lVZKKXQ4VhIfLHycMmwFSVOY7vLTKnytag4Y8Yz+aXm0bwDN/QxPntFzDH47F1Rcy2ywNnvty8ooDTvuA== + dependencies: + "@storybook/client-logger" "6.5.15" + core-js "^3.8.2" + memoizerific "^1.11.3" + regenerator-runtime "^0.13.7" + "@storybook/theming@6.5.9": version "6.5.9" resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-6.5.9.tgz#13f60a3a3cd73ceb5caf9f188e1627e79f1891aa" @@ -3989,10 +4094,17 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== -"@types/lodash@^4.14.167", "@types/lodash@^4.14.182": - version "4.14.182" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2" - integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q== +"@types/lodash-es@^4.17.12": + version "4.17.12" + resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b" + integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*", "@types/lodash@^4.14.167", "@types/lodash@^4.14.182": + version "4.17.7" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.7.tgz#2f776bcb53adc9e13b2c0dfd493dfcbd7de43612" + integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA== "@types/mdast@^3.0.0": version "3.0.10" @@ -4122,6 +4234,11 @@ resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.8.tgz#b94a4391c85666c7b73299fd3ad79d4faa435310" integrity sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ== +"@types/tinycolor2@^1.4.6": + version "1.4.6" + resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.6.tgz#670cbc0caf4e58dd61d1e3a6f26386e473087f06" + integrity sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw== + "@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" @@ -4752,6 +4869,15 @@ anymatch@^3.0.3, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +aphrodite@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/aphrodite/-/aphrodite-2.4.0.tgz#ec1a2afa41ba7310a47a4f1fba27919d99572c91" + integrity sha512-1rVRlLco+j1YAT5aKEE8Wuw5zWV+tI41/quEheJAG0vNaGHE64iJ/a2SiVMz8Uc80VdP2/Hjlfd2bPJOWsqJuQ== + dependencies: + asap "^2.0.3" + inline-style-prefixer "^5.1.0" + string-hash "^1.1.3" + app-root-dir@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/app-root-dir/-/app-root-dir-1.0.2.tgz#38187ec2dea7577fff033ffcb12172692ff6e118" @@ -4929,6 +5055,11 @@ arrify@^2.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== +asap@^2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + assign-symbols@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" @@ -6305,6 +6436,14 @@ css-has-pseudo@^3.0.4: dependencies: postcss-selector-parser "^6.0.9" +css-in-js-utils@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz#3b472b398787291b47cfe3e44fecfdd9e914ba99" + integrity sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA== + dependencies: + hyphenate-style-name "^1.0.2" + isobject "^3.0.1" + css-loader@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.6.0.tgz#2e4b2c7e6e2d27f8c8f28f61bffcd2e6c91ef645" @@ -9727,6 +9866,11 @@ human-signals@^3.0.1: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-3.0.1.tgz#c740920859dafa50e5a3222da9d3bf4bb0e5eef5" integrity sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ== +hyphenate-style-name@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz#691879af8e220aea5750e8827db4ef62a54e361d" + integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ== + iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -9967,6 +10111,13 @@ inline-style-parser@0.1.1: resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1" integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q== +inline-style-prefixer@^5.1.0: + version "5.1.2" + resolved "https://registry.yarnpkg.com/inline-style-prefixer/-/inline-style-prefixer-5.1.2.tgz#e5a5a3515e25600e016b71e39138971228486c33" + integrity sha512-PYUF+94gDfhy+LsQxM0g3d6Hge4l1pAqOSOiZuHWzMvQEGsbRQ/ck2WioLqrY2ZkHyPgVUXxn+hrkF7D6QUGbA== + dependencies: + css-in-js-utils "^2.0.0" + internal-slot@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c" @@ -12822,6 +12973,11 @@ object.values@^1.1.0, object.values@^1.1.5: define-properties "^1.1.3" es-abstract "^1.19.1" +on-change@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/on-change/-/on-change-4.0.2.tgz#838129790f09dc2ed04284944bda6e82b92c10b8" + integrity sha512-cMtCyuJmTx/bg2HCpHo3ZLeF7FZnBOapLqZHr2AlLeJ5Ul0Zu2mUJJz051Fdwu/Et2YW04ZD+TtU+gVy0ACNCA== + on-finished@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" @@ -13426,6 +13582,11 @@ pkg-dir@^5.0.0: dependencies: find-up "^5.0.0" +placement.js@^1.0.0-beta.5: + version "1.0.0-beta.5" + resolved "https://registry.yarnpkg.com/placement.js/-/placement.js-1.0.0-beta.5.tgz#2aac6bd8e670729bbf26ad47f2f9656b19e037d5" + integrity sha512-QD5hLPVKnT6Q1U34xxuRG9BhlBVaD0uF91JOzjvDnHAQfO/qjO4jmSTyjpR+K4se6Dn3Oo23IWeFX+QFFa9xNg== + pluralize@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" @@ -14748,10 +14909,10 @@ regenerate@^1.4.2: resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== -regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7: - version "0.13.9" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" - integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== +regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.7: + version "0.13.11" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" + integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== regenerator-transform@^0.15.0: version "0.15.0" @@ -16688,6 +16849,20 @@ store2@^2.12.0: resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.2.tgz#56138d200f9fe5f582ad63bc2704dbc0e4a45068" integrity sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w== +storybook-dark-mode@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/storybook-dark-mode/-/storybook-dark-mode-2.0.5.tgz#a15e8a43bf4b60745f73fd362133ba565a3bc61e" + integrity sha512-egOMu2tgGttGAMtFZcDLZobs1xc7LzFOh+pRVqaW59AVp05ABdQ3Hj6IX2Pz7tYGmF9AmaK+nBv0hDFxPe7Hfg== + dependencies: + "@storybook/addons" "^6.5.14" + "@storybook/api" "^6.5.14" + "@storybook/components" "^6.5.14" + "@storybook/core-events" "^6.5.14" + "@storybook/theming" "^6.5.14" + fast-deep-equal "^3.1.3" + global "^4.4.0" + memoizerific "^1.11.3" + stream-transform@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/stream-transform/-/stream-transform-2.1.3.tgz#a1c3ecd72ddbf500aa8d342b0b9df38f5aa598e3" @@ -16712,7 +16887,7 @@ string-argv@^0.3.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== -string-hash@^1.1.1: +string-hash@^1.1.1, string-hash@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b" integrity sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A== @@ -17382,6 +17557,11 @@ tiny-glob@^0.2.9: globalyzer "0.1.0" globrex "^0.1.2" +tinycolor2@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" + integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -18117,6 +18297,11 @@ update-notifier@^5.0.1: semver-diff "^3.1.1" xdg-basedir "^4.0.0" +uplot@^1.6.30: + version "1.6.30" + resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.6.30.tgz#1622a96b7cb2e50622c74330823c321847cbc147" + integrity sha512-48oVVRALM/128ttW19F2a2xobc2WfGdJ0VJFX00099CfqbCTuML7L2OrTKxNzeFP34eo1+yJbqFSoFAp2u28/Q== + uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"