diff --git a/__tests__/unit/ui/legend/continuous-spec.ts b/__tests__/unit/ui/legend/continuous-spec.ts
new file mode 100644
index 000000000..22637e242
--- /dev/null
+++ b/__tests__/unit/ui/legend/continuous-spec.ts
@@ -0,0 +1,244 @@
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Continuous } from '../../../../src';
+import { createDiv } from '../../../utils';
+
+// const webglRenderer = new WebGLRenderer();
+
+const renderer = new CanvasRenderer({
+ enableDirtyRectangleRenderingDebug: false,
+ enableAutoRendering: true,
+ enableDirtyRectangleRendering: true,
+});
+
+const div = createDiv();
+
+// @ts-ignore
+const canvas = new Canvas({
+ container: div,
+ width: 600,
+ height: 600,
+ renderer,
+});
+
+const continuous = new Continuous({
+ attrs: {
+ title: {
+ content: '连续图例',
+ },
+ label: {
+ align: 'outside',
+ },
+ rail: {
+ type: 'size',
+ width: 300,
+ height: 30,
+ },
+ min: 0,
+ max: 100,
+ color: '#f1a545',
+ },
+});
+
+canvas.appendChild(continuous);
+canvas.render();
+
+describe('continuous', () => {
+ test('basic', async () => {
+ // const continuous = new Continuous({
+ // attrs: {
+ // // x: 50,
+ // // y: 50,
+ // title: {
+ // content: "I'm title",
+ // spacing: 20,
+ // align: 'left',
+ // style: {
+ // fill: 'gray',
+ // fontSize: 16,
+ // fontWeight: 'bold',
+ // },
+ // },
+ // padding: 10,
+ // // label: false,
+ // label: {
+ // // align: 'rail',
+ // // align: 'outside',
+ // align: 'outside',
+ // spacing: 10,
+ // // formatter: (val, idx) => {
+ // // if (val === 100 || val === 200) {
+ // // return String(val);
+ // // }
+ // // return '';
+ // // },
+ // },
+ // backgroundStyle: {
+ // default: {
+ // fill: '#f3f3f3',
+ // },
+ // },
+ // // handle: false,
+ // // handle: {
+ // // text: {
+ // // align: 'inside',
+ // // },
+ // // },
+ // rail: {
+ // width: 280,
+ // height: 30,
+ // // type: 'size',
+ // chunked: true,
+ // ticks: [110, 120, 130, 140, 150, 160, 170, 180, 190],
+ // backgroundColor: '#eee8d5',
+ // },
+ // // slidable: false,
+ // min: 100,
+ // max: 200,
+ // // start: 110,
+ // // end: 190,
+ // step: 10,
+ // color: ['#d0e3fa', '#acc7f6', '#8daaf2', '#6d8eea', '#4d73cd', '#325bb1', '#5a3e75', '#8c3c79', '#e23455', '#e7655b'],
+ // value: [100, 200],
+ // },
+
+ // });
+
+ // title
+ expect(continuous.firstChild.attr('text')).toBe('连续图例');
+ expect(continuous.getElementById('startHandle').attr('x')).toBe(0);
+ expect(continuous.getElementById('endHandle').attr('x')).toBe(300);
+
+ continuous.update({
+ start: 10,
+ end: 50,
+ });
+
+ expect(continuous.getElementById('startHandle').attr('x')).toBe(30);
+ expect(continuous.getElementById('endHandle').attr('x')).toBe(150);
+
+ console.log(continuous);
+ });
+
+ test('size', async () => {
+ expect(continuous.getElementById('railPathGroup').children.length).toBe(1);
+ expect(continuous.getElementById('railPathGroup').firstChild.attr('path')[0][2]).toBe(30);
+ expect(continuous.getElementById('railPathGroup').firstChild.attr('path')[2][2]).toBe(0);
+ });
+
+ test('size chunked', async () => {
+ continuous.update({
+ rail: {
+ chunked: true,
+ ticks: [20, 30, 60, 80],
+ },
+ color: [
+ '#d0e3fa',
+ '#acc7f6',
+ '#8daaf2',
+ '#6d8eea',
+ '#4d73cd',
+ '#325bb1',
+ '#5a3e75',
+ '#8c3c79',
+ '#e23455',
+ '#e7655b',
+ ],
+ });
+ expect(continuous.getElementById('railPathGroup').children.length).toBe(5);
+ expect(continuous.getElementById('railPathGroup').children[0].attr('fill')).toBe('#d0e3fa');
+ expect(continuous.getElementById('railPathGroup').children[1].attr('fill')).toBe('#acc7f6');
+ expect(continuous.getElementById('railPathGroup').children[2].attr('fill')).toBe('#8daaf2');
+ expect(continuous.getElementById('railPathGroup').children[3].attr('fill')).toBe('#6d8eea');
+ expect(continuous.getElementById('railPathGroup').children[4].attr('fill')).toBe('#4d73cd');
+ expect(continuous.getElementById('railPathGroup').firstChild.attr('path')[2][2]).toBe(30 - 30 * 0.2);
+ expect(continuous.getElementById('railPathGroup').firstChild.attr('path')[3][2]).toBe(30);
+ });
+
+ test('color', async () => {
+ continuous.update({
+ rail: {
+ type: 'color',
+ chunked: false,
+ },
+ });
+ expect(continuous.getElementById('railPathGroup').children.length).toBe(1);
+ expect(continuous.getElementById('railPathGroup').children[0].attr('fill')).toBe('#d0e3fa');
+ });
+
+ test('color chunked', async () => {
+ continuous.update({
+ rail: {
+ type: 'color',
+ chunked: true,
+ },
+ color: [
+ '#d0e3fa',
+ '#acc7f6',
+ '#8daaf2',
+ '#6d8eea',
+ '#4d73cd',
+ '#325bb1',
+ '#5a3e75',
+ '#8c3c79',
+ '#e23455',
+ '#e7655b',
+ ],
+ });
+ expect(continuous.getElementById('railPathGroup').children.length).toBe(5);
+ expect(continuous.getElementById('railPathGroup').children[0].attr('fill')).toBe('#d0e3fa');
+ expect(continuous.getElementById('railPathGroup').children[1].attr('fill')).toBe('#acc7f6');
+ expect(continuous.getElementById('railPathGroup').children[2].attr('fill')).toBe('#8daaf2');
+ expect(continuous.getElementById('railPathGroup').children[3].attr('fill')).toBe('#6d8eea');
+ expect(continuous.getElementById('railPathGroup').children[4].attr('fill')).toBe('#4d73cd');
+ expect(continuous.getElementById('railPathGroup').firstChild.attr('path')[2][2]).toBe(0);
+ });
+
+ // test('vertical', async () => {
+ // const div = createDiv();
+
+ // // @ts-ignore
+ // const canvas = new Canvas({
+ // container: div,
+ // width: 600,
+ // height: 600,
+ // renderer,
+ // });
+
+ // const continuous = new Continuous({
+ // attrs: {
+ // x: 50,
+ // y: 0,
+ // title: {
+ // content: "I'm title",
+ // },
+ // label: {
+ // align: 'rail',
+ // spacing: 0
+ // },
+ // orient: 'vertical',
+ // width: 100,
+ // height: 300,
+ // backgroundStyle: {
+ // default: {
+ // fill: 'gray',
+ // },
+ // },
+ // handle: false,
+ // rail: {
+ // width: 50,
+ // height: 200,
+ // // type: 'size',
+ // chunked: true,
+ // ticks: [120, 140, 160, 180],
+ // },
+ // min: 100,
+ // max: 200,
+ // color: ['#d0e3fa', '#acc7f6', '#8daaf2', '#6d8eea', '#4d73cd', '#325bb1'],
+ // value: [100, 200],
+ // },
+ // });
+ // canvas.appendChild(continuous);
+ // canvas.render();
+ // });
+});
diff --git a/docs/api/ui/legend.en.md b/docs/api/ui/legend.en.md
new file mode 100644
index 000000000..431b7458a
--- /dev/null
+++ b/docs/api/ui/legend.en.md
@@ -0,0 +1 @@
+`markdown:docs/api/ui/legend.zh.md`
diff --git a/docs/api/ui/legend.zh.md b/docs/api/ui/legend.zh.md
new file mode 100644
index 000000000..4b566e1b6
--- /dev/null
+++ b/docs/api/ui/legend.zh.md
@@ -0,0 +1,90 @@
+---
+title: Legend
+order: 5
+---
+
+# 图例
+
+> 图例(legend)是图表的辅助元素,使用颜色、大小、形状区分不同的数据类型,用于图表中数据的筛选
+
+## 引入
+
+```ts
+import { Category, Continuous } from '@antv/gui';
+```
+
+### 基本配置
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| --------------- | --------------------------------------- | -------------- | -------------- |
+| padding | number \| number []
| 内边距 | `10` |
+| orient | 'horizontal' \| 'vertical'
| 横向、纵向模式 | `'horizontal'` |
+| backgroundStyle | MixAttrs
| 图例背景样式 | `[]` |
+| title | TitleCfg
| 图例标题配置 | `[]` |
+| type | 'category' \| 'continuous'
| 高度 | `[]` |
+
+### 连续图例配置
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| ---------- | ---------------------------------- | ---------- | -------------- |
+| min | number
| 最小值 | `[]` |
+| max | number
| 最大值 | `[]` |
+| start | number
| 开始区间 | `min` |
+| end | number
| 结束区间 | `max` |
+| color | string \| string[]
| 颜色 | `[]` |
+| label | false \| LabelCfg
| 标签 | `[]` |
+| rail | RailCfg
| 色板 | `[]` |
+| slidable | boolean
| 是否可滑动 | `true` |
+| step | number
| 步长 | `(max-min)*1%` |
+| handle | false \| HandleCfg
| 手柄配置 | `[]` |
+| indicator | false \| indicatorCfg
| 指示器配置 | `[]` |
+
+### TitleCfg
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| ---------- | ------------------------------------------ | ------------------ | ---------- |
+| content | string
| 标题 | |
+| spacing | number
| 标题与图例元素间距 | |
+| align | 'left' \| 'center' \| 'right'
| 标题对齐方式 | |
+| style | ShapeAttrs
| 标题样式 | |
+| formatter | (text:string)=>string
| 标题格式化 | |
+
+### LabelCfg
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| ---------- | ------------------------------------------------- | -------------- | ---------- |
+| style | ShapeAttrs
| 标签样式 | `[]` |
+| spacing | number
| 标签与图例间距 | `10` |
+| formatter | (value: number, idx: number)=>string
| 标签文本格式化 | `[]` |
+| align | 'rail' \| 'inside' \| 'outside'
| 标签对齐方式 | `rail` |
+
+### RailCfg
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| --------------- | ------------------------------ | ---------------------------- | ---------- |
+| width | number
| 色板宽度 | `[]` |
+| height | number
| 色板高度 | `[]` |
+| type | 'color' \| 'size'
| 色板类型 | `color` |
+| chunked | boolean
| 是否分块 | `false` |
+| ticks | number[]
| 分块分割点(label 显示的值) | `[]` |
+| isGradient | boolean \| 'auto'
| 是否使用渐变色 | `auto` |
+| backgroundColor | string
| 色板背景色 | `[]` |
+
+### HandleCfg
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| ---------- | ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------- |
+| size | number
| 手柄大小 | `4` |
+| text | Object
| 手柄文本 | `{formatter: (value:number)=>string, style: ShapeAttrs, align: 'rail' \| 'inside' \| 'outside' }` |
+| icon | Object
| 手柄图标 | `{marker: MarkerCfg}` |
+| spacing | number
| 手柄文本到手柄图标的间距 | `10` |
+
+### IndicatorCfg
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| --------------- | ------------------------------- | -------------------- | -------------------------------------------------------- |
+| size | number
| 指示器大小 | `8` |
+| spacing | number
| 指示器文本到色板间距 | `5` |
+| padding | number \| number[]
| 指示器文本内边距 | `5` |
+| backgroundStyle | ShapeAttrs
| 指示器背景样式 | `[]` |
+| text | Object
| 指示器文本样式 | `{style: ShapeAttrs, formatter:(value: number)=>string}` |
diff --git a/examples/legend/API.en.md b/examples/legend/API.en.md
new file mode 100644
index 000000000..8a9d2b4d5
--- /dev/null
+++ b/examples/legend/API.en.md
@@ -0,0 +1 @@
+`markdown:docs/api/ui/continuous.en.md`
diff --git a/examples/legend/API.zh.md b/examples/legend/API.zh.md
new file mode 100644
index 000000000..83dc99388
--- /dev/null
+++ b/examples/legend/API.zh.md
@@ -0,0 +1 @@
+`markdown:docs/api/ui/continuous.zh.md`
diff --git a/examples/legend/demo/basic.ts b/examples/legend/demo/basic.ts
new file mode 100644
index 000000000..245fb0007
--- /dev/null
+++ b/examples/legend/demo/basic.ts
@@ -0,0 +1,39 @@
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Continuous } from '@antv/gui';
+
+const renderer = new CanvasRenderer({
+ enableDirtyRectangleRenderingDebug: false,
+ enableAutoRendering: true,
+ enableDirtyRectangleRendering: true,
+});
+
+// @ts-ignore
+const canvas = new Canvas({
+ container: 'container',
+ width: 600,
+ height: 300,
+ renderer,
+});
+
+const continuous = new Continuous({
+ attrs: {
+ title: {
+ content: '连续图例',
+ },
+ label: {
+ align: 'outside',
+ },
+ rail: {
+ width: 300,
+ height: 30,
+ ticks: [20, 40, 60, 80],
+ },
+ handle: false,
+ min: 0,
+ max: 100,
+ color: '#ef923c',
+ },
+});
+
+canvas.appendChild(continuous);
diff --git a/examples/legend/demo/chunked.ts b/examples/legend/demo/chunked.ts
new file mode 100644
index 000000000..540e22985
--- /dev/null
+++ b/examples/legend/demo/chunked.ts
@@ -0,0 +1,52 @@
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Continuous } from '@antv/gui';
+
+const renderer = new CanvasRenderer({
+ enableDirtyRectangleRenderingDebug: false,
+ enableAutoRendering: true,
+ enableDirtyRectangleRendering: true,
+});
+
+// @ts-ignore
+const canvas = new Canvas({
+ container: 'container',
+ width: 600,
+ height: 300,
+ renderer,
+});
+
+const continuous = new Continuous({
+ attrs: {
+ x: 50,
+ y: 50,
+ title: {
+ content: '分块图例',
+ },
+ padding: 10,
+ rail: {
+ width: 280,
+ height: 30,
+ type: 'size',
+ chunked: true,
+ ticks: [110, 120, 130, 140, 150, 160, 170, 180, 190],
+ },
+ min: 100,
+ max: 200,
+ step: 10,
+ color: [
+ '#d0e3fa',
+ '#acc7f6',
+ '#8daaf2',
+ '#6d8eea',
+ '#4d73cd',
+ '#325bb1',
+ '#5a3e75',
+ '#8c3c79',
+ '#e23455',
+ '#e7655b',
+ ],
+ },
+});
+
+canvas.appendChild(continuous);
diff --git a/examples/legend/demo/color-type.ts b/examples/legend/demo/color-type.ts
new file mode 100644
index 000000000..2d8184ded
--- /dev/null
+++ b/examples/legend/demo/color-type.ts
@@ -0,0 +1,40 @@
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Continuous } from '@antv/gui';
+
+const renderer = new CanvasRenderer({
+ enableDirtyRectangleRenderingDebug: false,
+ enableAutoRendering: true,
+ enableDirtyRectangleRendering: true,
+});
+
+// @ts-ignore
+const canvas = new Canvas({
+ container: 'container',
+ width: 600,
+ height: 300,
+ renderer,
+});
+
+const continuous = new Continuous({
+ attrs: {
+ title: {
+ content: '颜色类型',
+ },
+ label: {
+ align: 'outside',
+ },
+ rail: {
+ width: 300,
+ height: 30,
+ },
+ min: 0,
+ max: 100,
+ start: 10,
+ end: 80,
+ step: 5,
+ color: '#6720f5',
+ },
+});
+
+canvas.appendChild(continuous);
diff --git a/examples/legend/demo/meta.json b/examples/legend/demo/meta.json
new file mode 100644
index 000000000..e97982eb1
--- /dev/null
+++ b/examples/legend/demo/meta.json
@@ -0,0 +1,40 @@
+{
+ "title": {
+ "zh": "中文分类",
+ "en": "Category"
+ },
+ "demos": [
+ {
+ "filename": "basic.ts",
+ "title": {
+ "zh": "基本连续图例",
+ "en": "Basic continuous legend"
+ },
+ "screenshot": ""
+ },
+ {
+ "filename": "size-type.ts",
+ "title": {
+ "zh": "尺寸类型",
+ "en": "Size type"
+ },
+ "screenshot": ""
+ },
+ {
+ "filename": "color-type.ts",
+ "title": {
+ "zh": "颜色类型",
+ "en": "Color type"
+ },
+ "screenshot": ""
+ },
+ {
+ "filename": "chunked.ts",
+ "title": {
+ "zh": "分块",
+ "en": "Chunked"
+ },
+ "screenshot": ""
+ }
+ ]
+}
diff --git a/examples/legend/demo/size-type.ts b/examples/legend/demo/size-type.ts
new file mode 100644
index 000000000..a093b8e2b
--- /dev/null
+++ b/examples/legend/demo/size-type.ts
@@ -0,0 +1,39 @@
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Continuous } from '@antv/gui';
+
+const renderer = new CanvasRenderer({
+ enableDirtyRectangleRenderingDebug: false,
+ enableAutoRendering: true,
+ enableDirtyRectangleRendering: true,
+});
+
+// @ts-ignore
+const canvas = new Canvas({
+ container: 'container',
+ width: 600,
+ height: 300,
+ renderer,
+});
+
+const continuous = new Continuous({
+ attrs: {
+ title: {
+ content: '尺寸类型',
+ },
+ label: {
+ align: 'outside',
+ },
+ rail: {
+ type: 'size',
+ width: 300,
+ height: 30,
+ },
+ handle: false,
+ min: 0,
+ max: 100,
+ color: '#f1a545',
+ },
+});
+
+canvas.appendChild(continuous);
diff --git a/examples/legend/index.en.md b/examples/legend/index.en.md
new file mode 100644
index 000000000..29a40e584
--- /dev/null
+++ b/examples/legend/index.en.md
@@ -0,0 +1,4 @@
+---
+title: Legend
+order: 5
+---
diff --git a/examples/legend/index.zh.md b/examples/legend/index.zh.md
new file mode 100644
index 000000000..29a40e584
--- /dev/null
+++ b/examples/legend/index.zh.md
@@ -0,0 +1,4 @@
+---
+title: Legend
+order: 5
+---
diff --git a/package.json b/package.json
index 87939f085..f53c51b5d 100644
--- a/package.json
+++ b/package.json
@@ -36,12 +36,13 @@
"GUI"
],
"dependencies": {
- "@antv/g": "^1.0.0-alpha.0",
+ "@antv/g": "^1.0.0-alpha.3",
"@antv/g-base": "^0.5.9",
- "@antv/g-canvas": "^1.0.0-alpha.0",
- "@antv/util": "^2.0.13",
+ "@antv/g-canvas": "^1.0.0-alpha.3",
+ "@antv/g-svg": "^1.0.0-alpha.3",
"@antv/path-util": "^2.0.9",
"@antv/scale": "^0.4.3",
+ "@antv/util": "^2.0.13",
"csstype": "^3.0.8",
"svg-path-parser": "^1.1.0"
},
diff --git a/src/ui/index.ts b/src/ui/index.ts
index 6a5ac4715..57d449f9f 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -10,8 +10,6 @@ export { Axis } from './axis';
export type { AxisOptions } from './axis';
export { Button } from './button';
export type { ButtonOptions } from './button';
-export { Legend } from './legend';
-export type { LegendOptions } from './legend';
export { Scrollbar } from './scrollbar';
export type { ScrollbarOptions, ScrollbarAttrs } from './scrollbar';
export { Sheet } from './sheet';
diff --git a/src/ui/legend/base.ts b/src/ui/legend/base.ts
new file mode 100644
index 000000000..247cc818f
--- /dev/null
+++ b/src/ui/legend/base.ts
@@ -0,0 +1,134 @@
+import { deepMix, get } from '@antv/util';
+import { Rect, Text } from '@antv/g';
+import { GUI } from '../core/gui';
+import { getStateStyle, normalPadding } from '../../util';
+import { LEGEND_BASE_DEFAULT_OPTIONS } from './constant';
+import { getShapeSpace } from './utils';
+import type { Pair } from '../slider/types';
+import type { StyleState } from '../../types';
+import type { LegendBaseCfg, LegendBaseOptions } from './types';
+
+export abstract class LegendBase extends GUI {
+ public static tag = 'legendBase';
+
+ // background
+ protected backgroundShape: Rect;
+
+ protected titleShape: Text;
+
+ protected static defaultOptions = {
+ type: LegendBase.tag,
+ ...LEGEND_BASE_DEFAULT_OPTIONS,
+ };
+
+ constructor(options: LegendBaseOptions) {
+ super(deepMix({}, LegendBase.defaultOptions, options));
+ }
+
+ attributeChangedCallback(name: string, value: any) {}
+
+ public init() {
+ this.createTitle();
+ }
+
+ /**
+ * 获取颜色
+ */
+ protected abstract getColor(): string;
+
+ /**
+ * 背景属性
+ */
+ protected abstract getBackgroundAttrs();
+
+ // 获取对应状态的样式
+ protected getStyle(name: string | string[], state?: StyleState) {
+ const style = get(this.attributes, name);
+ return getStateStyle(style, state);
+ }
+
+ /**
+ * 根据方向取值
+ */
+ protected getOrientVal([x, y]: Pair) {
+ const { orient } = this.attributes;
+ return orient === 'horizontal' ? x : y;
+ }
+
+ // 获取padding
+ protected getPadding(padding = this.attributes.padding) {
+ return normalPadding(padding);
+ }
+
+ // 获取容器内可用空间, 排除掉title的空间
+ protected getAvailableSpace() {
+ // 连续图例不固定外部大小
+ // 容器大小 - padding - title
+ const spacing = get(this.attributes, ['title', 'spacing']);
+ const [top, , , left] = this.getPadding();
+ const { height: titleHeight } = getShapeSpace(this.titleShape);
+
+ return {
+ x: left,
+ y: top + titleHeight + spacing,
+ };
+ }
+
+ // 绘制背景
+ protected createBackground() {
+ this.backgroundShape = new Rect({
+ name: 'background',
+ attrs: this.getBackgroundAttrs(),
+ });
+ this.appendChild(this.backgroundShape);
+ this.backgroundShape.toBack();
+ }
+
+ /**
+ * 创建图例标题配置
+ */
+ protected getTitleAttrs() {
+ const { title } = this.attributes;
+ const { content, style, formatter } = title;
+
+ return {
+ ...style,
+ text: formatter(content),
+ };
+ }
+
+ /**
+ * 创建图例标题
+ */
+ protected createTitle() {
+ this.titleShape = new Text({
+ name: 'title',
+ attrs: this.getTitleAttrs(),
+ });
+
+ this.appendChild(this.titleShape);
+ }
+
+ protected adjustTitle() {
+ const { title } = this.attributes;
+ const { width } = getShapeSpace(this);
+ const { align } = title;
+ const [top, right, , left] = this.getPadding();
+
+ let layout: Object;
+ switch (align) {
+ case 'left':
+ layout = { x: left, y: top, textAlign: 'left' };
+ break;
+ case 'right':
+ layout = { x: width - left - right, y: top, textAlign: 'end' };
+ break;
+ case 'center':
+ layout = { x: (width - left - right) / 2, y: top, textAlign: 'center' };
+ break;
+ default:
+ break;
+ }
+ this.titleShape.attr(layout);
+ }
+}
diff --git a/src/ui/legend/category-item.ts b/src/ui/legend/category-item.ts
new file mode 100644
index 000000000..30e1ced9d
--- /dev/null
+++ b/src/ui/legend/category-item.ts
@@ -0,0 +1,48 @@
+import { CustomElement, Text, Rect } from '@antv/g';
+import { Marker } from '../marker';
+import type { ShapeCfg } from '../../types';
+import type { CategoryItemsCfg } from './types';
+
+type CategoryItemCfg = ShapeCfg & {
+ attrs: CategoryItemsCfg['itemCfg'];
+};
+
+export class CategoryItem extends CustomElement {
+ // marker
+ private markerShape: Marker;
+
+ // name
+ private nameShape: Text;
+
+ // value
+ private valueShape: Text;
+
+ // background
+ private backgroundShape: Rect;
+
+ constructor({ attrs, ...rest }: CategoryItemCfg) {
+ super({ type: 'categoryItem', attrs, ...rest });
+ this.render(attrs);
+ }
+
+ attributeChangedCallback(name: string, value: any) {
+ // 更新item
+ }
+
+ public render(itemCfg: CategoryItemsCfg['itemCfg']) {
+ // render markerShape
+ // render nameShape
+ // render valueShape
+ // render backgroundShape
+ }
+
+ /**
+ * 获得缩略文本
+ */
+ private getEllipsisName() {}
+
+ /**
+ * 获得精简值
+ */
+ private getConciseValue() {}
+}
diff --git a/src/ui/legend/category.ts b/src/ui/legend/category.ts
new file mode 100644
index 000000000..76514e948
--- /dev/null
+++ b/src/ui/legend/category.ts
@@ -0,0 +1,102 @@
+import { DisplayObject } from '@antv/g';
+import { deepMix } from '@antv/util';
+import { Marker } from '../marker';
+import { LegendBase } from './base';
+import { CategoryItem } from './category-item';
+import type { CategoryCfg, CategoryOptions } from './types';
+import { CATEGORY_DEFAULT_OPTIONS } from './constant';
+
+export type { CategoryOptions };
+
+export class Category extends LegendBase {
+ public static tag = 'Category';
+
+ private itemsContainer: DisplayObject;
+
+ // 图例项
+ private items: CategoryItem[];
+
+ // 前进按钮
+ private prevNavigation: Marker;
+
+ // 后退按钮
+ private nextNavigation: Marker;
+
+ protected static defaultOptions = {
+ type: Category.tag,
+ ...CATEGORY_DEFAULT_OPTIONS,
+ };
+
+ constructor(options: CategoryOptions) {
+ super(deepMix({}, Category.defaultOptions, options));
+ }
+
+ attributeChangedCallback(name: string, value: any) {}
+
+ public init() {}
+
+ public update(attrs: CategoryCfg) {}
+
+ public clear() {}
+
+ protected getColor() {
+ return 'red';
+ }
+
+ protected getBackgroundAttrs() {}
+
+ private bindEvents() {
+ // 图例项hover事件
+ // 图例项点击事件
+ }
+
+ /**
+ * 创建图例项
+ */
+ private createItems() {}
+
+ /**
+ * 获得一页图例项可用空间
+ */
+ private getItemsSpace() {
+ /**
+ * 情况1 按钮在上下、左右 无页码
+ * ↑
+ * item item item
+ * ← item item item →
+ * item item item
+ * ↓
+ *
+ * 情况2 按钮在内
+ * item item item
+ * item item <- ->
+ *
+ * item item item
+ * item <- 1/3 ->
+ *
+ * 情况3 按钮在外
+ * <- 1/3 ->
+ * ↑ item item item ↑
+ *1/3 item item item 1/3
+ * ↓ item item item ↓
+ * <- 1/3 ->
+ */
+ }
+
+ /**
+ * 计算图例布局
+ */
+ private calcLayout() {}
+
+ // 创建翻页器
+ private createPageNavigator() {}
+
+ // 前翻页
+ private onNavigationPrev = () => {};
+
+ // 后翻页
+ private onNavigationNext = () => {};
+
+ // 设置图例项状态
+ private setItemStatus() {}
+}
diff --git a/src/ui/legend/constant.ts b/src/ui/legend/constant.ts
new file mode 100644
index 000000000..cb917748b
--- /dev/null
+++ b/src/ui/legend/constant.ts
@@ -0,0 +1,190 @@
+import { deepMix } from '@antv/util';
+import { leftArrow, rightArrow, upArrow, downArrow } from './utils';
+
+export const LEGEND_BASE_DEFAULT_OPTIONS = {
+ attrs: {
+ x: 0,
+ y: 0,
+ padding: 0,
+ orient: 'horizontal',
+ backgroundStyle: {
+ default: {
+ fill: '#dcdee2',
+ lineWidth: 0,
+ },
+ },
+ title: {
+ content: '',
+ spacing: 10,
+ align: 'left',
+ style: {
+ fill: 'gray',
+ fontWeight: 'bold',
+ fontSize: 16,
+ textBaseline: 'top',
+ },
+ formatter: (text: string) => text,
+ },
+ },
+};
+
+export const CATEGORY_DEFAULT_OPTIONS = deepMix({}, LEGEND_BASE_DEFAULT_OPTIONS, {
+ attrs: {
+ type: 'category',
+ items: {
+ items: [],
+ itemCfg: {
+ height: 16,
+ width: 40,
+ spacing: 10,
+ marker: {
+ symbol: 'circle',
+ size: 16,
+ style: {
+ fill: '#f8be4b',
+ lineWidth: 0,
+ active: {
+ fill: '#f3774a',
+ },
+ },
+ },
+ name: {
+ spacing: 5,
+ style: {
+ stroke: 'gray',
+ fontSize: 16,
+ checked: {
+ stroke: 'black',
+ fontWeight: 'bold',
+ },
+ },
+ formatter: (name: string) => name,
+ },
+ value: {
+ spacing: 5,
+ align: 'right',
+ style: {
+ stroke: 'gray',
+ fontSize: 16,
+ checked: {
+ stroke: 'black',
+ fontWeight: 'bold',
+ },
+ },
+ },
+ backgroundStyle: {
+ fill: 'white',
+ opacity: 0.5,
+ active: {
+ fill: '#2c2c2c',
+ },
+ },
+ },
+ },
+ reverse: false, // 倒序放置图例
+ pageNavigator: {
+ button: {
+ marker: (type: 'prev' | 'next', orient: 'horizontal' | 'vertical') => {
+ if (orient === 'horizontal') {
+ if (type === 'prev') {
+ return leftArrow;
+ }
+ return rightArrow;
+ }
+ // vertical
+ if (type === 'prev') {
+ return upArrow;
+ }
+ return downArrow;
+ },
+ size: 12,
+ style: {
+ default: {},
+ active: {},
+ disabled: {},
+ },
+ },
+ },
+ },
+});
+
+export const CONTINUOUS_DEFAULT_OPTIONS = deepMix({}, LEGEND_BASE_DEFAULT_OPTIONS, {
+ attrs: {
+ type: 'continuous',
+ color: [
+ '#d0e3fa',
+ '#acc7f6',
+ '#8daaf2',
+ '#6d8eea',
+ '#4d73cd',
+ '#325bb1',
+ '#5a3e75',
+ '#8c3c79',
+ '#e23455',
+ '#e7655b',
+ ],
+ padding: 10,
+ label: {
+ style: {
+ fill: 'black',
+ textAlign: 'center',
+ textBaseline: 'middle',
+ },
+ spacing: 10,
+ formatter: (value: number) => String(value),
+ align: 'rail',
+ },
+ rail: {
+ width: 100,
+ height: 50,
+ type: 'color',
+ chunked: false,
+ ticks: [],
+ isGradient: 'auto',
+ backgroundColor: '#c5c5c5',
+ },
+ // 不可滑动时隐藏手柄
+ slidable: true,
+ handle: {
+ size: 12,
+ spacing: 10,
+ icon: {
+ marker: 'default',
+ style: {
+ stroke: '#c5c5c5',
+ fill: '#fff',
+ lineWidth: 1,
+ },
+ },
+ text: {
+ align: 'outside',
+ style: {
+ fill: '#63656e',
+ fontSize: 12,
+ textAlign: 'center',
+ textBaseline: 'middle',
+ },
+ formatter: (value: number) => value,
+ },
+ },
+ indicator: {
+ size: 8,
+ spacing: 5,
+ padding: 5,
+ backgroundStyle: {
+ fill: '#262626',
+ radius: 5,
+ },
+ text: {
+ style: {
+ fill: 'white',
+ fontSize: 12,
+ },
+ formatter: (value: number) => String(value),
+ },
+ },
+ },
+});
+
+// 步长比例
+export const STEP_RATIO = 0.01;
diff --git a/src/ui/legend/continuous.ts b/src/ui/legend/continuous.ts
new file mode 100644
index 000000000..fa4f64e8e
--- /dev/null
+++ b/src/ui/legend/continuous.ts
@@ -0,0 +1,966 @@
+import { Group, Rect, Text } from '@antv/g';
+import { clamp, deepMix, get, isUndefined } from '@antv/util';
+import { Rail } from './rail';
+import { Labels } from './labels';
+import { LegendBase } from './base';
+import { getShapeSpace, getValueOffset, getStepValueByValue } from './utils';
+import { CONTINUOUS_DEFAULT_OPTIONS, STEP_RATIO } from './constant';
+import { Marker } from '../marker';
+import { toPrecision } from '../../util';
+import type { Pair } from '../slider/types';
+import type { MarkerAttrs } from '../marker';
+import type { ContinuousCfg, ContinuousOptions } from './types';
+import type { DisplayObject, ShapeAttrs } from '../../types';
+
+export type { ContinuousOptions };
+
+type HandleType = 'start' | 'end';
+
+export class Continuous extends LegendBase {
+ public static tag = 'Continuous';
+
+ /**
+ * 结构:
+ * this
+ * |- titleShape
+ * |-backgroundShape
+ * |- labelsShape
+ * |- railShape
+ * |- pathGroup
+ * |- backgroundGroup
+ * |- startHandle
+ * |- endHandle
+ * |- indicator
+ */
+
+ //
+ private labelsShape: Group;
+
+ // 色板
+ private railShape: Rail;
+
+ // 开始滑块
+ private startHandle: Group;
+
+ // 结束滑块
+ private endHandle: Group;
+
+ /**
+ * 指示器
+ */
+ private indicatorShape: Group;
+
+ /**
+ * 当前交互的对象
+ */
+ private target: string;
+
+ /**
+ * 上次鼠标事件的位置
+ */
+ private prevValue: number;
+
+ protected static defaultOptions = {
+ type: Continuous.tag,
+ ...CONTINUOUS_DEFAULT_OPTIONS,
+ };
+
+ constructor(options: ContinuousOptions) {
+ super(deepMix({}, Continuous.defaultOptions, options));
+ super.init();
+ this.init();
+ }
+
+ public init() {
+ // 创建labels
+ this.createLabels();
+ // 创建色板及其背景
+ this.createRail();
+ // // 创建滑动手柄
+ this.createHandles();
+ // 设置手柄文本
+ this.setHandleText();
+ // 调整布局
+ this.adjustLayout();
+ // 调整title
+ this.adjustTitle();
+ // 最后再绘制背景
+ this.createBackground();
+ // 指示器
+ this.createIndicator();
+ // // 监听事件
+ this.bindEvents();
+ }
+
+ public update(attrs: ContinuousCfg) {
+ this.attr(deepMix({}, this.attributes, attrs));
+ // 更新label内容
+ this.labelsShape.attr({ labelsAttrs: this.getLabelsAttrs() });
+ // 更新rail
+ this.railShape.update(this.getRailAttrs());
+ // 更新选区
+ this.setSelection(...this.getSelection());
+ // 更新title内容
+ this.titleShape.attr(this.getTitleAttrs());
+ // 更新handle
+ this.updateHandles();
+ // 更新手柄文本
+ this.setHandleText();
+ // 关闭指示器
+ this.setIndicator(false);
+ // 更新布局
+ this.adjustLayout();
+ // 更新背景
+ this.backgroundShape.attr(this.getBackgroundAttrs());
+ }
+
+ public clear() {}
+
+ /**
+ * 设置指示器
+ * @param value 设置的值,用于确定位置
+ * @param text 可选;显示的文本,若无则通过value取值
+ * @param useFormatter 是否使用formatter
+ * @returns
+ */
+ public setIndicator(value: false | number, text?: string, useFormatter = true) {
+ // 值校验
+ const { min, max, rail, indicator } = this.attributes;
+ const safeValue = value === false ? false : clamp(value, min, max);
+ if (safeValue === false) {
+ this.indicatorShape.hide();
+ return;
+ }
+ this.indicatorShape.show();
+
+ const { type, width: railWidth, height: railHeight } = rail;
+ const { spacing } = indicator;
+ const offsetX = this.getValueOffset(safeValue);
+ // 设置指示器位置
+ // type = color;
+ // type=size时,指示器需要贴合轨道上边缘
+ // handle会影响rail高度
+
+ const offsetY =
+ type === 'size' ? (1 - (safeValue - min) / (max - min)) * this.getOrientVal([railHeight, railWidth]) : 0;
+
+ this.indicatorShape?.attr(
+ this.getOrientVal([
+ { x: offsetX, y: offsetY - spacing },
+ { x: offsetY + spacing, y: offsetX },
+ ])
+ );
+
+ const formatter = get(this.attributes, ['indicator', 'text', 'formatter']);
+ let showText = text || safeValue;
+ if (useFormatter) {
+ showText = formatter(showText);
+ }
+ // 设置文本
+ this.indicatorShape.getElementsByTagName('text')[0].attr({
+ text: showText,
+ });
+
+ // 调整指示器
+ this.adjustIndicator();
+ }
+
+ public getSelection() {
+ const { min, max, start, end } = this.attributes;
+ return [start || min, end || max] as [number, number];
+ }
+
+ /**
+ * 设置选区
+ * @param stVal 开始值
+ * @param endVal 结束值
+ * @param isOffset stVal和endVal是否为偏移量
+ */
+ public setSelection(stVal: number, endVal: number, isOffset: boolean = false) {
+ const [currSt, currEnd] = this.getSelection();
+ let [start, end] = [stVal, endVal];
+ if (isOffset) {
+ // 获取当前值
+ start += currSt;
+ end += currEnd;
+ }
+ // 值校验
+ [start, end] = this.getSafetySelections(start, end);
+
+ this.setAttribute('start', start);
+ this.setAttribute('end', end);
+ this.railShape.update({ start, end });
+ this.adjustLayout();
+ this.setHandleText();
+ }
+
+ /**
+ * 设置Handle的文本
+ */
+ public setHandleText(text1?: string, text2?: string, useFormatter: boolean = true) {
+ const [start, end] = this.getSelection();
+ let [startText, endText] = [text1 || String(start), text2 || String(end)];
+ if (useFormatter) {
+ const formatter =
+ get(this.attributes, ['handle', 'text', 'formatter']) ||
+ get(Continuous.defaultOptions, ['attrs', 'handle', 'text', 'formatter']);
+ [startText, endText] = [formatter(startText), formatter(endText)];
+ }
+ this.getHandle('start', 'text').attr('text', startText);
+ this.getHandle('end', 'text').attr('text', endText);
+ }
+
+ // TODO
+ // public getBounds() {}
+
+ /**
+ * 获取颜色
+ */
+ protected getColor() {
+ const { color } = this.attributes;
+ // TODO 待G 5.0 提供渐变色方法后使用
+ // // 数组颜色
+ // if (isArray(color)) {
+ // // 生成渐变色样式
+ // return color.join('-');
+ // }
+ return color;
+ }
+
+ protected getBackgroundAttrs() {
+ const { handle } = this.attributes;
+ const { width, height } = getShapeSpace(this);
+ const [, right, bottom, left] = this.getPadding();
+ // 问就是调试工程
+ const offsetX = handle ? -20 : 0;
+ const offsetY = handle ? -10 : 0;
+ return {
+ width: width + left + right + offsetX,
+ height: height + bottom + offsetY,
+ ...this.getStyle('backgroundStyle'),
+ };
+ }
+
+ /**
+ * 获得滑动步长
+ * 未指定时默认为range的1%;
+ */
+ private getStep(): number {
+ const { step, min, max } = this.attributes;
+ if (isUndefined(step)) {
+ return toPrecision((max - min) * STEP_RATIO, 0);
+ }
+ return step;
+ }
+
+ /**
+ * 取值附近的步长刻度上的值
+ */
+ private getStepValueByValue(value: number): number {
+ const { min } = this.attributes;
+ return getStepValueByValue(value, this.getStep(), min);
+ }
+
+ /**
+ * 取值所在的刻度范围
+ */
+ private getTickIntervalByValue(value: number) {
+ const [start, end] = this.getSelection();
+ const ticks = get(this.attributes, ['rail', 'ticks']);
+ const temp = [start, ...ticks, end];
+ for (let i = 1; i < temp.length; i += 1) {
+ const st = temp[i - 1];
+ const end = temp[i];
+ if (value >= st && value <= end) {
+ return [st, end];
+ }
+ }
+ return false;
+ }
+
+ /**
+ * 将选区调整至tick位置
+ */
+ private adjustSelection() {
+ const [start, end] = this.getSelection();
+ this.setSelection(this.getStepValueByValue(start), this.getStepValueByValue(end));
+ }
+
+ /**
+ * 获取某个值在orient方向上的偏移量
+ * reverse: 屏幕偏移量 -> 值
+ */
+ private getValueOffset(value: number, reverse = false): number {
+ const { min, max, rail } = this.attributes;
+ const { width: railWidth, height: railHeight } = rail;
+ const innerLen = this.getOrientVal([railWidth, railHeight]);
+ return getValueOffset(value, min, max, innerLen, reverse);
+ }
+
+ private getSafetySelections(start: number, end: number, precision: number = 4): [number, number] {
+ const { min, max } = this.attributes;
+ const [prevStart, prevEnd] = this.getSelection();
+ let [startVal, endVal] = [start, end];
+ const range = endVal - startVal;
+ // 交换startVal endVal
+ if (startVal > endVal) {
+ [startVal, endVal] = [endVal, startVal];
+ }
+ // 超出范围就全选
+ if (range > max - min) {
+ return [min, max];
+ }
+
+ if (startVal < min) {
+ if (prevStart === min && prevEnd === endVal) {
+ return [min, endVal];
+ }
+ return [min, range + min];
+ }
+ if (endVal > max) {
+ if (prevEnd === max && prevStart === startVal) {
+ return [startVal, max];
+ }
+ return [max - range, max];
+ }
+
+ // 保留小数
+ return [toPrecision(startVal, precision), toPrecision(endVal, precision)];
+ }
+
+ // 获取Label属性
+ private getLabelsAttrs(): ShapeAttrs[] {
+ const { label } = this.attributes;
+ // 不绘制label
+ if (!label) {
+ return [];
+ }
+ const { min, max, rail } = this.attributes;
+ const { style, formatter, align } = label;
+ const attrs = [];
+ // align为rail时仅显示min、max的label
+ if (align === 'rail') {
+ [min, max].forEach((value, idx) => {
+ attrs.push({
+ x: 0,
+ y: 0,
+ text: formatter(value, idx),
+ ...style,
+ });
+ });
+ } else {
+ const ticks = [min, ...rail.ticks, max];
+ ticks.forEach((value, idx) => {
+ attrs.push({
+ x: 0,
+ y: 0,
+ text: formatter(value, idx),
+ ...style,
+ });
+ });
+ }
+ return attrs;
+ }
+
+ // 创建Label
+ private createLabels() {
+ // 创建label容器
+ this.labelsShape = new Labels({
+ name: 'labels',
+ id: 'labels',
+ attrs: {
+ labelsAttrs: this.getLabelsAttrs(),
+ },
+ });
+ this.appendChild(this.labelsShape);
+ }
+
+ // 获取色板属性
+ private getRailAttrs() {
+ // 直接绘制色板,布局在adjustLayout方法中进行
+ const { min, max, rail, orient } = this.attributes;
+ const [start, end] = this.getSelection();
+ const color = this.getColor();
+ return {
+ x: 0,
+ y: 0,
+ min,
+ max,
+ start,
+ end,
+ orient,
+ color,
+ cursor: 'point',
+ ...rail,
+ };
+ }
+
+ // 创建色板
+ private createRail() {
+ // 确定绘制类型
+ this.railShape = new Rail({
+ name: 'rail',
+ id: 'rail',
+ attrs: this.getRailAttrs(),
+ });
+ this.appendChild(this.railShape);
+ }
+
+ /**
+ * 获取手柄属性
+ */
+ private getHandleAttrs() {
+ const { handle } = this.attributes;
+ if (!handle)
+ return {
+ markerAttrs: {
+ size: 8,
+ symbol: 'hiddenHandle',
+ opacity: 0,
+ },
+ textAttrs: {
+ text: '',
+ opacity: 0,
+ },
+ };
+
+ const { size, icon, text } = handle;
+ const { style: textStyle } = text;
+ const { marker, style: markerStyle } = icon;
+ // 替换默认手柄
+ const symbol = marker === 'default' ? this.getOrientVal(['horizontalHandle', 'verticalHandle']) : marker;
+ return {
+ markerAttrs: {
+ symbol,
+ size,
+ cursor: this.getOrientVal(['ew-resize', 'ns-resize']),
+ ...markerStyle,
+ },
+ textAttrs: {
+ text: '',
+ ...textStyle,
+ },
+ };
+ }
+
+ /**
+ * 创建手柄
+ */
+ private createHandle(handleType: HandleType) {
+ const { markerAttrs, textAttrs } = this.getHandleAttrs();
+ const groupName = `${handleType}Handle`;
+ const el = new Group({
+ name: groupName,
+ id: groupName,
+ });
+ // 将tag挂载到rail
+ this.railShape.appendChild(el);
+
+ const text = new Text({
+ name: 'text',
+ attrs: textAttrs,
+ });
+ el.appendChild(text);
+
+ const icon = new Marker({
+ name: 'icon',
+ attrs: markerAttrs as MarkerAttrs,
+ });
+ el.appendChild(icon);
+ this[`${handleType}handle`] = el;
+ }
+
+ // 创建手柄
+ private createHandles() {
+ this.createHandle('start');
+ this.createHandle('end');
+ }
+
+ /**
+ * 更新handle
+ */
+ updateHandles() {
+ const { markerAttrs, textAttrs } = this.getHandleAttrs();
+ ['start', 'end'].forEach((handleType) => {
+ const el = this.getElementById(`${handleType}Handle`);
+ const icon = el.getElementsByName('icon')[0] as Marker;
+ icon.update(markerAttrs);
+ el.getElementsByName('text')[0].attr(textAttrs);
+ });
+ }
+
+ /**
+ * 获得手柄、手柄内icon和text的对象
+ */
+ private getHandle(handleType: HandleType, subNode?: 'text' | 'icon') {
+ // TODO fix 需要替换成查询方式
+ const handle = this.getElementById(`${handleType}Handle`);
+ if (subNode) {
+ return handle.getElementsByName(subNode)[0];
+ }
+ return handle;
+ }
+
+ private getRail(item?: string) {
+ if (item === 'rail') {
+ return this.getElementById('railPathGroup');
+ }
+ if (item === 'background') {
+ return this.getElementById('railBackgroundGroup');
+ }
+ return this.getElementById('rail');
+ }
+
+ /**
+ * 获得指示器配置
+ */
+ private getIndicatorAttrs() {
+ const { indicator } = this.attributes;
+ const { size, text, backgroundStyle } = indicator;
+ const { style: textStyle } = text;
+ return {
+ markerAttrs: {
+ size,
+ symbol: this.getOrientVal(['downArrow', 'leftArrow']),
+ fill: backgroundStyle.fill,
+ },
+ textAttrs: {
+ text: '',
+ ...this.getOrientVal([
+ {
+ textAlign: 'center',
+ textBaseline: 'middle',
+ },
+ {
+ textAlign: 'left',
+ textBaseline: 'middle',
+ },
+ ]),
+ ...textStyle,
+ },
+ backgroundAttrs: backgroundStyle,
+ };
+ }
+
+ private createIndicator() {
+ const { markerAttrs, textAttrs, backgroundAttrs } = this.getIndicatorAttrs();
+ const el = new Group({
+ name: 'indicator',
+ id: 'indicator',
+ });
+ // 将tag挂载到rail
+ this.railShape.appendChild(el);
+
+ const text = new Text({
+ name: 'text',
+ attrs: textAttrs,
+ });
+ el.appendChild(text);
+
+ // 创建 取用icon填充色
+ // 位置大小在setIndicator中设置
+
+ const background = new Rect({
+ name: 'background',
+ attrs: {
+ width: 0,
+ height: 0,
+ ...backgroundAttrs,
+ },
+ });
+ el.appendChild(background);
+ background.toBack();
+
+ // 指示器小箭头
+ const icon = new Marker({
+ name: `icon`,
+ attrs: markerAttrs as MarkerAttrs,
+ });
+ el.appendChild(icon);
+ this.indicatorShape = el;
+ // 默认隐藏
+ this.indicatorShape.hide();
+ }
+
+ /**
+ * 调整handle结构
+ */
+ private adjustHandle() {
+ const { x: innerX, y: innerY } = this.getAvailableSpace();
+ const { rail, handle } = this.attributes;
+ const [start, end] = this.getSelection();
+ const { height: railHeight } = rail;
+
+ // handle为false时,取默认布局方式进行布局,但不会显示出来
+ const handleCfg = handle || get(Continuous.defaultOptions, ['attrs', 'handle']);
+ const { spacing: handleSpacing, text: handleText } = handleCfg;
+ const { align: handleTextAlign } = handleText;
+
+ this.railShape.attr({
+ x: innerX,
+ y: innerY,
+ });
+ // 设置Handle位置
+ const startHandle = this.getHandle('start');
+ const endHandle = this.getHandle('end');
+ startHandle.attr({
+ x: this.getValueOffset(start),
+ y: railHeight / 2,
+ });
+ endHandle.attr({
+ x: this.getValueOffset(end),
+ y: railHeight / 2,
+ });
+ // 调整文本位置
+ let handleTextStyle = {};
+ if (handleTextAlign === 'rail') {
+ // 不做处理
+ handleTextStyle = {};
+ } else if (handleTextAlign === 'inside') {
+ handleTextStyle = {
+ y: -railHeight / 2 - handleSpacing,
+ textBaseline: 'bottom',
+ };
+ } else if (handleTextAlign === 'outside') {
+ handleTextStyle = {
+ y: railHeight / 2 + handleSpacing,
+ textBaseline: 'top',
+ };
+ }
+
+ this.getHandle('start', 'text').attr(handleTextStyle);
+ this.getHandle('end', 'text').attr(handleTextStyle);
+ }
+
+ /**
+ * 调整labels结构
+ */
+ private adjustLabels() {
+ const { min, max, label, rail, orient } = this.attributes;
+ // 容器内可用空间起点
+ const { x: innerX, y: innerY } = this.getAvailableSpace();
+ const { width: railWidth, height: railHeight, ticks: _t } = rail;
+ // 绘制label
+ const { align: labelAlign, spacing: labelSpacing } = label;
+ // label位置
+ if (labelAlign === 'rail') {
+ /**
+ * 此时labelsShape中只包含min、max label
+ * 1. 设置minLabel位置
+ */
+ if (orient === 'horizontal') {
+ /**
+ * 0 |||||||||||||||||||||| 100
+ */
+
+ this.labelsShape.attr({
+ x: innerX,
+ y: innerY + railHeight / 2,
+ });
+ // 设置左侧文本
+ this.labelsShape.firstChild.attr({ textAlign: 'start' });
+
+ // 左侧文本的宽度
+ const { width: leftTextWidth } = getShapeSpace(this.labelsShape.firstChild);
+
+ const railStart = innerX + leftTextWidth + labelSpacing;
+ // 设置rail位置
+ this.railShape.attr({
+ x: railStart,
+ y: innerY,
+ });
+
+ // 设置右侧文本位置
+ this.labelsShape.lastChild.attr({
+ x: railStart + railWidth + labelSpacing,
+ y: 0,
+ textAlign: 'start',
+ });
+ return;
+ }
+ /**
+ * else if orient === 'vertical'
+ * 0
+ * --
+ * --
+ * --
+ * --
+ * --
+ * --
+ * 400
+ */
+ this.labelsShape.attr({
+ x: railWidth / 2,
+ y: innerY,
+ });
+ // 顶部文本高度
+ const { height: topTextHeight } = getShapeSpace(this.labelsShape.firstChild);
+ this.labelsShape.firstChild.attr({
+ textBaseline: 'top',
+ });
+ // rail位置
+ const railStart = innerY + topTextHeight + labelSpacing;
+ this.railShape.attr({
+ x: innerX,
+ y: railStart,
+ });
+
+ // 底部文本位置
+ this.labelsShape.lastChild.attr({
+ x: 0,
+ y: railStart + railHeight + labelSpacing,
+ textBaseline: 'top',
+ });
+ return;
+ }
+
+ /**
+ * if labelAlign == 'inside' | 'outside'
+ * ||||||||||||||||||||||
+ * 0 20 40 60 80 100
+ *
+ * -- 0
+ * -- 20
+ * -- 40
+ * -- 60
+ * -- 80
+ * -- 100
+ */
+ if (orient === 'horizontal') {
+ // labelsShape 高度
+ const { height: labelsHeight } = getShapeSpace(this.labelsShape);
+ const labelSpace = labelsHeight + labelSpacing;
+
+ this.railShape.attr({
+ x: innerX,
+ // label在上,rail在下
+ y: innerY + (labelAlign === 'inside' ? labelSpace : 0),
+ });
+ this.labelsShape.attr({
+ x: innerX,
+ // label在上
+ y: innerY + (labelAlign === 'inside' ? 0 : labelSpace) + 10,
+ });
+
+ // 补上min,max
+ const ticks = [min, ..._t, max];
+ // 设置labelsShape中每个文本的位置
+ this.labelsShape.children.forEach((child, idx) => {
+ const val = ticks[idx];
+ // 通过val拿到偏移量
+
+ child.attr({
+ x: this.getValueOffset(val),
+ y: 0,
+ textBaseline: 'top',
+ textAlign: (() => {
+ // 调整两端label位置
+ if (idx === 0) return 'start';
+ if (idx === ticks.length - 1) return 'end';
+ return 'center';
+ })(),
+ });
+ });
+ return;
+ }
+ /**
+ * orient === 'vertical'
+ */
+ const a = 1;
+ }
+
+ /**
+ * 调整指示器结构
+ */
+ private adjustIndicator() {
+ const text = this.indicatorShape.getElementsByName('text')[0];
+ const background = this.indicatorShape.getElementsByName('background')[0];
+ const { width, height } = getShapeSpace(text);
+
+ const [top, right, bottom, left] = this.getPadding(get(this.attributes, ['indicator', 'padding']));
+
+ const leftRight = left + right;
+ const topBottom = top + bottom;
+
+ // 设置背景大小
+ background.attr({
+ x: -width / 2 - left,
+ y: -height - topBottom,
+ width: width + leftRight,
+ height: height + topBottom,
+ });
+ text.attr({
+ y: -height / 2 - top,
+ });
+ }
+
+ /**
+ * 对图例进行布局
+ */
+ private adjustLayout() {
+ // if (!label && !handle) {
+ // // 没有label和Handle
+ // // 直接绘制色板即可
+ // }
+
+ const { handle, label } = this.attributes;
+ // 调整handle
+ // 显示handle时不显示label
+ if (handle) {
+ this.labelsShape?.hide();
+ } else {
+ this.labelsShape?.show();
+ }
+ if (this.getHandle('start')) {
+ this.adjustHandle();
+ }
+
+ // 调整labels
+ if (label && this.labelsShape.isVisible()) {
+ this.adjustLabels();
+ }
+
+ // rail位置
+ }
+
+ /**
+ * 获取鼠标、触摸事件中的指针坐标
+ */
+ private getEventPos(e) {
+ // TODO 需要区分touch和mouse事件
+ const pos = e.screen;
+ return [pos.x, pos.y] as Pair;
+ }
+
+ /**
+ * 事件触发的位置对应的value值
+ */
+ private getEventPosValue(e, limit: boolean = false) {
+ const { min, max } = this.attributes;
+ const startPos = this.getOrientVal(this.getRail().getPosition().slice(0, 2) as Pair);
+ const currValue = this.getOrientVal(this.getEventPos(e));
+ const offset = currValue - startPos;
+ const value = clamp(this.getValueOffset(offset, true) + min, min, max);
+ return value;
+ }
+
+ /**
+ * 绑定事件
+ */
+ private bindEvents() {
+ // 如果!slidable,则不绑定事件或者事件响应不生效
+ // 各种hover事件
+
+ // 放置需要绑定drag事件的对象
+ const dragObject = new Map();
+ dragObject.set('rail', this.getElementById('railBackgroundGroup'));
+ dragObject.set('start', this.getHandle('start', 'icon'));
+ dragObject.set('end', this.getHandle('end', 'icon'));
+ // 绑定drag开始事件
+ dragObject.forEach((obj, key) => {
+ obj.addEventListener('mousedown', this.onDragStart(key));
+ obj.addEventListener('touchstart', this.onDragStart(key));
+ });
+
+ // indicator hover事件
+ this.getRail('background').addEventListener('mouseenter', this.onHoverStart('rail'));
+ this.backgroundShape.addEventListener('mouseover', this.onHoverEnd);
+ }
+
+ /**
+ * 开始拖拽
+ */
+ private onDragStart = (target: string) => (e) => {
+ e.stopPropagation();
+ const { slidable } = this.attributes;
+ // 关闭滑动
+ if (!slidable) return;
+ this.onHoverEnd();
+ this.target = target;
+ this.prevValue = this.getStepValueByValue(this.getEventPosValue(e));
+ this.addEventListener('mousemove', this.onDragging);
+ this.addEventListener('touchmove', this.onDragging);
+ document.addEventListener('mouseup', this.onDragEnd);
+ document.addEventListener('touchend', this.onDragEnd);
+ };
+
+ /**
+ * 拖拽
+ */
+ private onDragging = (e) => {
+ e.stopPropagation();
+ const [start, end] = this.getSelection();
+ const currValue = this.getStepValueByValue(this.getEventPosValue(e));
+ const diffValue = currValue - this.prevValue;
+
+ switch (this.target) {
+ case 'start':
+ start !== currValue && this.setSelection(currValue, end);
+ break;
+ case 'end':
+ end !== currValue && this.setSelection(start, currValue);
+ break;
+ case 'rail':
+ if (diffValue !== 0) {
+ this.prevValue = currValue;
+ this.setSelection(diffValue, diffValue, true);
+ }
+ break;
+ default:
+ break;
+ }
+ };
+
+ /**
+ * 结束拖拽
+ */
+ private onDragEnd = () => {
+ this.removeEventListener('mousemove', this.onDragging);
+ this.removeEventListener('touchmove', this.onDragging);
+ document.removeEventListener('mouseup', this.onDragEnd);
+ document.removeEventListener('touchend', this.onDragEnd);
+ // 抬起时修正位置
+ this.target = undefined;
+ };
+
+ private onHoverStart = (target: string) => (e) => {
+ e.stopPropagation();
+ // 如果target不为undefine,表明当前有其他事件被监听
+ if (isUndefined(this.target)) {
+ this.target = target;
+ this.addEventListener('mousemove', this.onHovering);
+ this.addEventListener('touchmove', this.onHovering);
+ }
+ };
+
+ private onHovering = (e) => {
+ e.stopPropagation();
+ const value = this.getEventPosValue(e);
+ // chunked为true时
+ if (get(this.attributes, ['rail', 'chunked'])) {
+ const interval = this.getTickIntervalByValue(value);
+ if (!interval) return;
+ const [st, end] = interval;
+ this.setIndicator(toPrecision((st + end) / 2, 0), `${st}-${end}`, false);
+ // 计算value并发出事件
+ this.emit('onIndicated', interval);
+ } else {
+ const val = this.getStepValueByValue(value);
+ this.setIndicator(val);
+ // TODO 节流
+ this.emit('onIndicated', val);
+ }
+ };
+
+ /**
+ * hover结束
+ */
+ private onHoverEnd = () => {
+ this.removeEventListener('mousemove', this.onHovering);
+ this.removeEventListener('touchmove', this.onHovering);
+ // 关闭指示器
+ this.setIndicator(false);
+ // 恢复状态
+ this.target = undefined;
+ };
+}
diff --git a/src/ui/legend/index.ts b/src/ui/legend/index.ts
index 9c22c0b59..1b53f5be1 100644
--- a/src/ui/legend/index.ts
+++ b/src/ui/legend/index.ts
@@ -1,5 +1,2 @@
-import { LegendOptions } from './types';
-
-export { LegendOptions };
-
-export class Legend {}
+export { Category, CategoryOptions } from './category';
+export { Continuous, ContinuousOptions } from './continuous';
diff --git a/src/ui/legend/labels.ts b/src/ui/legend/labels.ts
new file mode 100644
index 000000000..32aef376b
--- /dev/null
+++ b/src/ui/legend/labels.ts
@@ -0,0 +1,31 @@
+import { CustomElement, Text } from '@antv/g';
+import type { ShapeCfg } from '../../types';
+
+type LabelsAttrs = ShapeCfg[];
+
+export class Labels extends CustomElement {
+ constructor({ attrs, ...rest }: ShapeCfg) {
+ super({ type: 'lines', attrs, ...rest });
+ this.render(attrs.labelsAttrs);
+ }
+
+ public render(labelsAttrs: LabelsAttrs): void {
+ // 清空label
+ this.removeChildren(true);
+ // 重新绘制
+ labelsAttrs.forEach((attr) => {
+ this.appendChild(
+ new Text({
+ name: 'label',
+ attrs: attr,
+ })
+ );
+ });
+ }
+
+ attributeChangedCallback(name: string, value: any) {
+ if (name === 'labelsAttrs') {
+ this.render(value);
+ }
+ }
+}
diff --git a/src/ui/legend/rail.ts b/src/ui/legend/rail.ts
new file mode 100644
index 000000000..27fd115f2
--- /dev/null
+++ b/src/ui/legend/rail.ts
@@ -0,0 +1,215 @@
+import { isString, deepMix } from '@antv/util';
+import { CustomElement, Group, Path } from '@antv/g';
+import type { PathCommand } from '@antv/g-base';
+import type { RailCfg as defaultCfg } from './types';
+import type { ShapeCfg } from '../../types';
+import { createTrapezoidRailPath, createRectRailPath, getValueOffset } from './utils';
+
+type RailAttrs = defaultCfg & {
+ min?: number;
+ max?: number;
+ start?: number;
+ end?: number;
+ color?: string | string[];
+ orient?: 'horizontal' | 'vertical';
+};
+
+export class Rail extends CustomElement {
+ // 色板的path group
+ private railPathGroup: Group;
+
+ // 背景的path group
+ private backgroundPathGroup: Group;
+
+ constructor({ attrs, ...rest }: ShapeCfg & { attrs: RailAttrs }) {
+ super({ type: 'rail', attrs, ...rest });
+ this.init();
+ }
+
+ attributeChangedCallback(name: string, value: any) {
+ // if (['type', 'chunked'].includes(name)) {
+ // this.render();
+ // } else {
+ // this.update(value);
+ // }
+ // if (['start', 'end'].includes(name)) {
+ // this.updateSelection();
+ // }
+ }
+
+ public init() {
+ this.railPathGroup = new Group({
+ name: 'railPathGroup',
+ id: 'railPathGroup',
+ });
+ this.appendChild(this.railPathGroup);
+ this.backgroundPathGroup = new Group({
+ name: 'backgroundGroup',
+ id: 'railBackgroundGroup',
+ });
+ this.appendChild(this.backgroundPathGroup);
+ this.render();
+ }
+
+ public render() {
+ this.clear();
+ const { width, color, backgroundColor, orient } = this.attributes;
+ const railPath = this.createRailPath();
+ const railBackgroundPath = this.createBackgroundPath();
+
+ // 绘制背景
+ railBackgroundPath.forEach((path) => {
+ this.backgroundPathGroup.appendChild(
+ new Path({
+ name: 'background',
+ attrs: {
+ path,
+ fill: backgroundColor,
+ },
+ })
+ );
+ });
+
+ railPath.forEach((path, idx) => {
+ // chunked的情况下,只显示start到end范围内的梯形
+ this.railPathGroup.appendChild(
+ new Path({
+ name: 'railPath',
+ attrs: {
+ path,
+ fill: isString(color) ? color : color[idx],
+ },
+ })
+ );
+ });
+ // 根据orient对railPath旋转
+ if (orient === 'vertical') {
+ this.setOrigin(0, width);
+ this.translateLocal(0, -width);
+ // this.rotate(45);
+ setTimeout(() => {
+ this.rotate(45);
+ });
+ }
+ }
+
+ public update(railAttrs: RailAttrs) {
+ // deepMix railAttrs into this.attributes
+ this.attr(railAttrs);
+ this.render();
+ this.updateSelection();
+ }
+
+ /**
+ * 设置选区
+ */
+ public updateSelection() {
+ // 更新背景
+ const backgroundPaths = this.createBackgroundPath();
+ this.backgroundPathGroup.children.forEach((shape, index) => {
+ shape.attr({
+ path: backgroundPaths[index],
+ });
+ });
+ }
+
+ public clear() {
+ this.railPathGroup.removeChildren();
+ this.backgroundPathGroup.removeChildren();
+ }
+
+ private getOrientVal(val1: T, val2: T) {
+ const { orient } = this.attributes;
+ return orient === 'horizontal' ? val1 : val2;
+ }
+
+ /**
+ * 获取值对应的offset
+ */
+ private getValueOffset(value: number) {
+ const { min, max, width, height } = this.attributes;
+ return getValueOffset(value, min, max, this.getOrientVal(width, height));
+ }
+
+ /**
+ * 生成rail path
+ */
+ private createRailPath() {
+ const { width, height, type, chunked, min, max } = this.attributes;
+ let railPath: PathCommand[][];
+ // 颜色映射
+ if (chunked) {
+ railPath = this.createChunkPath();
+ } else {
+ const startOffset = this.getValueOffset(min);
+ const endOffset = this.getValueOffset(max);
+ switch (type) {
+ case 'color':
+ railPath = [createRectRailPath(width, height, 0, 0, startOffset, endOffset)];
+ break;
+ case 'size':
+ railPath = [createTrapezoidRailPath(width, height, 0, 0, startOffset, endOffset)];
+ break;
+ default:
+ break;
+ }
+ }
+ return railPath;
+ }
+
+ /**
+ * 分块连续图例下的path
+ */
+ private createChunkPath(): PathCommand[][] {
+ const { width, height, min, max, type, ticks: _t } = this.attributes;
+ const range = max - min;
+ const [len, thick] = this.getOrientVal([width, height], [height, width]);
+ // 每块四个角的位置
+ const blocksPoints = [];
+
+ // 插入端值
+ const ticks = [min, ..._t, max];
+ // 块起始位置
+ let prevPos = 0;
+ let prevThick = type === 'size' ? 0 : thick;
+ for (let index = 1; index < ticks.length; index += 1) {
+ const ratio = (ticks[index] - ticks[index - 1]) / range;
+ const currLen = len * ratio;
+ // 根据type确定形状
+ const currThick = type === 'size' ? thick * ratio + prevThick : thick;
+ const currPos = prevPos + currLen;
+ blocksPoints.push([
+ [prevPos, thick],
+ [prevPos, thick - prevThick],
+ [currPos, thick - currThick],
+ [currPos, thick],
+ ]);
+ prevPos = currPos;
+ prevThick = currThick;
+ }
+ const paths = [];
+ blocksPoints.forEach((points) => {
+ const path = [];
+ points.forEach((point, index) => {
+ path.push([index === 0 ? 'M' : 'L', ...point]);
+ });
+ path.push(['Z']);
+ paths.push(path);
+ });
+ return paths;
+ }
+
+ /**
+ * 场景背景掩膜
+ */
+ private createBackgroundPath() {
+ const { width, height, min, max, start, end, type } = this.attributes;
+ const startOffset = this.getValueOffset(start);
+ const endOffset = this.getValueOffset(end);
+ const minOffset = this.getValueOffset(min);
+ const maxOffset = this.getValueOffset(max);
+ const creator = type === 'size' ? createTrapezoidRailPath : createRectRailPath;
+
+ return [creator(width, height, 0, 0, minOffset, startOffset), creator(width, height, 0, 0, endOffset, maxOffset)];
+ }
+}
diff --git a/src/ui/legend/types.ts b/src/ui/legend/types.ts
index 4b8849e79..e44f79d8b 100644
--- a/src/ui/legend/types.ts
+++ b/src/ui/legend/types.ts
@@ -1 +1,173 @@
-export type LegendOptions = {};
+import type { ShapeCfg, ShapeAttrs, MixAttrs } from '../../types';
+import type { MarkerAttrs } from '../marker/types';
+// marker配置
+type MarkerCfg = string | MarkerAttrs['symbol'];
+
+// 色板
+export type RailCfg = {
+ // 色板宽度
+ width?: number;
+ // 色板高度
+ height?: number;
+ // 色板类型
+ type?: 'color' | 'size';
+ // 是否分块
+ chunked?: boolean;
+ // 分块连续图例分割点
+ ticks?: number[];
+ // 是否使用渐变色
+ isGradient?: boolean | 'auto';
+ // 色板背景色
+ backgroundColor?: string;
+};
+
+// 滑动手柄
+type HandleCfg = {
+ size?: number;
+ spacing?: number;
+ icon?: {
+ marker?: MarkerCfg;
+ style?: ShapeAttrs;
+ };
+ text?: {
+ style?: ShapeAttrs;
+ formatter?: (value: number) => string;
+ align?: 'rail' | 'inside' | 'outside';
+ };
+};
+
+// 图例项
+type CategoryItem = {
+ name: string;
+ value: number | string;
+ id: number | string;
+};
+
+// 图例项图标
+type ItemMarkerCfg = {
+ symbol: MarkerCfg;
+ size: number;
+ style: MixAttrs;
+};
+
+// 图例项Name
+type ItemNameCfg = {
+ // name与marker的距离
+ spacing: number;
+ style: MixAttrs;
+ formatter: (name: string, item: CategoryItem, index: number) => string;
+};
+
+// 图例项值
+type ItemValueCfg = {
+ spacing: number;
+ align: 'left' | 'right';
+ style: MixAttrs;
+ formatter: (value: number, item: CategoryItem, index: number) => number | string;
+};
+
+// 图例项配置
+export type CategoryItemsCfg = {
+ items: CategoryItem[];
+ itemCfg: {
+ // 单个图例项高度
+ height: number;
+ // 单个图例项宽度
+ width: number;
+ // 图例项间的间隔
+ spacing: number;
+ marker: ItemMarkerCfg | ((item: CategoryItem, index: number, items: CategoryItem[]) => ItemMarkerCfg);
+ name: ItemNameCfg | ((item: CategoryItem, index: number, items: CategoryItem[]) => ItemNameCfg);
+ value: ItemValueCfg | ((item: CategoryItem, index: number, items: CategoryItem[]) => ItemValueCfg);
+ // 图例项背景
+ backgroundStyle: MixAttrs | ((item: CategoryItem, index: number, items: CategoryItem[]) => MixAttrs);
+ };
+};
+
+// 分页器
+type pageNavigatorCfg = {
+ // 按钮
+ button: {
+ // 按钮图标
+ marker: MarkerCfg | ((type: 'prev' | 'next') => MarkerCfg);
+ // 按钮状态样式
+ style: MixAttrs;
+ // 尺寸
+ size: number;
+ };
+ // 页码
+ pagination: {
+ style: ShapeAttrs;
+ divider: string;
+ formatter: (pageNumber: number) => number | string;
+ };
+};
+
+export type LegendBaseCfg = ShapeCfg['attrs'] & {
+ // 图例内边距
+ padding?: number | number[];
+ // 背景
+ backgroundStyle?: MixAttrs;
+ // 布局
+ orient?: 'horizontal' | 'vertical';
+ // 标题
+ title?: {
+ content: string;
+ spacing?: number;
+ align?: 'left' | 'center' | 'right';
+ style?: ShapeAttrs;
+ formatter?: (text: string) => string;
+ };
+ // Legend类型
+ type?: 'category' | 'continuous';
+ // 指示器
+ indicator?: false | {};
+};
+
+export type LegendBaseOptions = {
+ attrs: LegendBaseCfg;
+};
+
+// 连续图例配置
+export type ContinuousCfg = LegendBaseCfg & {
+ // 最小值
+ min: number;
+ // 最大值
+ max: number;
+ // 色板颜色
+ color?: string | string[];
+ // 标签
+ label:
+ | false
+ | {
+ style?: ShapeAttrs;
+ spacing?: number;
+ formatter?: (value: number, idx: number) => string;
+ align?: 'rail' | 'inside' | 'outside';
+ };
+ // 色板配置
+ rail?: RailCfg;
+ // 是否可滑动
+ slidable?: boolean;
+ // 选择区域
+ value?: [number, number];
+ // 滑动步长
+ step?: number;
+ // 手柄配置
+ Handle?: false | HandleCfg;
+};
+
+export type ContinuousOptions = {
+ attrs: ContinuousCfg;
+};
+
+// 分类图例配置
+export type CategoryCfg = LegendBaseCfg & {
+ items: CategoryItemsCfg;
+ reverse: boolean;
+ pageNavigator: false | pageNavigatorCfg;
+};
+
+export type CategoryOptions = {
+ attrs: CategoryCfg;
+};
diff --git a/src/ui/legend/utils.ts b/src/ui/legend/utils.ts
new file mode 100644
index 000000000..4e67934a9
--- /dev/null
+++ b/src/ui/legend/utils.ts
@@ -0,0 +1,191 @@
+import type { PathCommand } from '@antv/g-base';
+import { isUndefined } from '@antv/util';
+import { DisplayObject } from '@antv/g';
+import { Marker } from '../marker';
+import { toPrecision } from '../../util';
+
+/**
+ * 梯形色板
+ * @param width 完整的宽度
+ * @param height 完整高度
+ * @param x 起点x坐标
+ * @param y 起点y坐标
+ * @param start 梯形起点
+ * @param end 梯形终点
+ * @returns PathCommand[]
+ */
+export function createTrapezoidRailPath(
+ width: number,
+ height: number,
+ x: number = 0,
+ y: number = 0,
+ start?: number,
+ end?: number
+) {
+ const st = isUndefined(start) ? x : start;
+ const ed = isUndefined(end) ? x + width : end;
+ const slope = height / width;
+
+ return [
+ ['M', st, height],
+ ['L', st, slope * (width - st - x)],
+ ['L', ed, slope * (width - ed - x)],
+ ['L', ed, height],
+ ['Z'],
+ ] as PathCommand[];
+}
+
+/**
+ * 矩形色板
+ * @param width 完整的宽度
+ * @param height 完整高度
+ * @param x 起点x坐标
+ * @param y 起点y坐标
+ * @param start 矩形起点
+ * @param end 矩形终点
+ * @returns PathCommand[]
+ */
+export function createRectRailPath(
+ width: number,
+ height: number,
+ x: number = 0,
+ y: number = 0,
+ start?: number,
+ end?: number
+) {
+ const st = isUndefined(start) ? x : start;
+ const ed = isUndefined(end) ? x + width : end;
+
+ return [['M', st, height], ['L', st, 0], ['L', ed, 0], ['L', ed, height], ['Z']] as PathCommand[];
+}
+
+/**
+ * 获得图形的x、y、width、height
+ */
+export function getShapeSpace(shape: DisplayObject) {
+ const bounds = shape.getBounds();
+ const max = bounds.getMax();
+ const min = bounds.getMin();
+ return {
+ x: min[0],
+ y: min[1],
+ width: max[0] - min[0],
+ height: max[1] - min[1],
+ };
+}
+
+/**
+ * 根据值转换为其在rail上的偏移量
+ * @param value 值
+ * @param min 最小值
+ * @param max 最大值
+ * @param railLen rail长度
+ * @param reverse 反向取值
+ * @returns
+ */
+export function getValueOffset(
+ value: number,
+ min: number,
+ max: number,
+ railLen: number,
+ reverse: boolean = false
+): number {
+ // 将value映射到 startRail, endRail
+ if (reverse) return (value / railLen) * (max - min);
+ return toPrecision(((value - min) / (max - min)) * railLen, 2);
+}
+
+/**
+ * 将值转换至步长tick上
+ */
+export function getStepValueByValue(value: number, step: number, min: number) {
+ const count = Math.round((value - min) / step);
+ return min + count * step;
+}
+
+// 左箭头
+export function leftArrow(x: number, y: number, r: number) {
+ const diffY = r * Math.sin((1 / 3) * Math.PI);
+ return [['M', x - r, y], ['L', x + r, y - diffY], ['L', x + r, y + diffY], ['Z']];
+}
+
+// 右箭头
+export function rightArrow(x: number, y: number, r: number) {
+ const diffY = r * Math.sin((1 / 3) * Math.PI);
+ return [['M', x + r, y], ['L', x - r, y - diffY], ['L', x - r, y + diffY], ['Z']];
+}
+
+// 上三角
+export function upArrow(x: number, y: number, r: number) {
+ const diffY = r * Math.cos((1 / 3) * Math.PI);
+ return [['M', x - r, y + diffY], ['L', x, y - diffY], ['L', x + r, y + diffY], ['Z']];
+}
+
+// 下三角
+export function downArrow(x: number, y: number, r: number) {
+ const diffY = r * Math.cos((1 / 3) * Math.PI);
+ return [['M', x - r, y - diffY], ['L', x + r, y - diffY], ['L', x, y + diffY], ['Z']];
+}
+
+export function hiddenHandle(x: number, y: number, r: number) {
+ // 长宽比
+ const ratio = 1.4;
+ const diffY = ratio * r;
+ return [['M', x - r, y - diffY], ['L', x + r, y - diffY], ['L', x + r, y + diffY], ['L', x - r, y + diffY], ['Z']];
+}
+
+// 控制手柄
+const HANDLE_HEIGHT_RATIO = 1.4;
+const HANDLE_TRIANGLE_RATIO = 0.4;
+
+// 纵向手柄
+export function verticalHandle(x: number, y: number, r: number) {
+ const width = r;
+ const height = width * HANDLE_HEIGHT_RATIO;
+ const halfWidth = width / 2;
+ const oneSixthWidth = width / 6;
+ const triangleX = x + height * HANDLE_TRIANGLE_RATIO;
+ return [
+ ['M', x, y],
+ ['L', triangleX, y + halfWidth],
+ ['L', x + height, y + halfWidth],
+ ['L', x + height, y - halfWidth],
+ ['L', triangleX, y - halfWidth],
+ ['Z'],
+ // 绘制两条横线
+ ['M', triangleX, y + oneSixthWidth],
+ ['L', x + height - 2, y + oneSixthWidth],
+ ['M', triangleX, y - oneSixthWidth],
+ ['L', x + height - 2, y - oneSixthWidth],
+ ];
+}
+
+// 横向手柄
+export function horizontalHandle(x: number, y: number, r: number) {
+ const width = r;
+ const height = width * HANDLE_HEIGHT_RATIO;
+ const halfWidth = width / 2;
+ const oneSixthWidth = width / 6;
+ const triangleY = y + height * HANDLE_TRIANGLE_RATIO;
+ return [
+ ['M', x, y],
+ ['L', x - halfWidth, triangleY],
+ ['L', x - halfWidth, y + height],
+ ['L', x + halfWidth, y + height],
+ ['L', x + halfWidth, triangleY],
+ ['Z'],
+ // 绘制两条竖线
+ ['M', x - oneSixthWidth, triangleY],
+ ['L', x - oneSixthWidth, y + height - 2],
+ ['M', x + oneSixthWidth, triangleY],
+ ['L', x + oneSixthWidth, y + height - 2],
+ ];
+}
+
+Marker.registerSymbol('leftArrow', leftArrow);
+Marker.registerSymbol('rightArrow', rightArrow);
+Marker.registerSymbol('upArrow', upArrow);
+Marker.registerSymbol('downArrow', downArrow);
+Marker.registerSymbol('hiddenHandle', hiddenHandle);
+Marker.registerSymbol('verticalHandle', verticalHandle);
+Marker.registerSymbol('horizontalHandle', horizontalHandle);