diff --git a/__tests__/unit/ui/slider/index.spec.ts b/__tests__/unit/ui/slider/index.spec.ts
new file mode 100644
index 000000000..bc44aeb6f
--- /dev/null
+++ b/__tests__/unit/ui/slider/index.spec.ts
@@ -0,0 +1,183 @@
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Slider } from '../../../../src';
+import { createDiv } from '../../../utils';
+
+const renderer = new CanvasRenderer({
+ enableDirtyRectangleRenderingDebug: false,
+ enableAutoRendering: true,
+ enableDirtyRectangleRendering: true,
+});
+
+describe('slider', () => {
+ test('basic', async () => {
+ const div = createDiv();
+
+ // @ts-ignore
+ const canvas = new Canvas({
+ container: div,
+ width: 800,
+ height: 300,
+ renderer,
+ });
+
+ const slider = new Slider({
+ attrs: {
+ x: 50,
+ y: 50,
+ width: 400,
+ height: 40,
+ values: [0.3, 0.7],
+ names: ['leftVal', 'rightVal'],
+ },
+ });
+
+ expect(slider.getValues()).toStrictEqual([0.3, 0.7]);
+ expect(slider.getNames()).toStrictEqual(['leftVal', 'rightVal']);
+
+ slider.setValues([0, 1]);
+ expect(slider.getValues()).toStrictEqual([0, 1]);
+
+ slider.setValues([-0.5, 1]);
+ expect(slider.getValues()).toStrictEqual([0, 1]);
+
+ slider.setValues([-0.5, 1.5]);
+ expect(slider.getValues()).toStrictEqual([0, 1]);
+
+ slider.setValues([-0.5, 0]);
+ expect(slider.getValues()).toStrictEqual([0, 0.5]);
+
+ slider.setValues([-2, -1]);
+ expect(slider.getValues()).toStrictEqual([0, 1]);
+
+ canvas.appendChild(slider);
+ slider.destroy();
+ });
+
+ test('vertical', async () => {
+ const div = createDiv();
+
+ // @ts-ignore
+ const canvas = new Canvas({
+ container: div,
+ width: 800,
+ height: 300,
+ renderer,
+ });
+
+ const slider = new Slider({
+ attrs: {
+ x: 50,
+ y: 50,
+ width: 40,
+ height: 400,
+ orient: 'vertical',
+ values: [0.3, 0.7],
+ names: ['aboveVal', 'belowVal'],
+ },
+ });
+
+ canvas.appendChild(slider);
+ slider.destroy();
+ });
+
+ test('custom icon', async () => {
+ const div = createDiv();
+
+ // @ts-ignore
+ const canvas = new Canvas({
+ container: div,
+ width: 800,
+ height: 300,
+ renderer,
+ });
+
+ const slider = new Slider({
+ attrs: {
+ x: 50,
+ y: 50,
+ width: 400,
+ height: 40,
+ values: [0.3, 0.7],
+ names: ['leftVal', 'rightVal'],
+ handle: {
+ start: {
+ size: 15,
+ formatter: (name, value) => {
+ return `${name}: ${value * 100}%`;
+ },
+ handleIcon: 'https://gw.alipayobjects.com/mdn/rms_6ae20b/afts/img/A*N4ZMS7gHsUIAAAAAAAAAAABkARQnAQ',
+ },
+ end: {
+ spacing: 20,
+ handleIcon: 'diamond',
+ },
+ },
+ },
+ });
+
+ canvas.appendChild(slider);
+ slider.destroy();
+ });
+
+ test('vertical', async () => {
+ const div = createDiv();
+
+ // @ts-ignore
+ const canvas = new Canvas({
+ container: div,
+ width: 800,
+ height: 300,
+ renderer,
+ });
+
+ const slider = new Slider({
+ attrs: {
+ x: 50,
+ y: 50,
+ width: 40,
+ height: 400,
+ orient: 'vertical',
+ values: [0.3, 0.7],
+ names: ['aboveVal', 'belowVal'],
+ },
+ });
+
+ canvas.appendChild(slider);
+ slider.destroy();
+ });
+
+ test('slider with sparkline', async () => {
+ const div = createDiv();
+
+ // @ts-ignore
+ const canvas = new Canvas({
+ container: div,
+ width: 800,
+ height: 300,
+ renderer,
+ });
+
+ const slider = new Slider({
+ attrs: {
+ x: 50,
+ y: 50,
+ width: 400,
+ height: 40,
+ values: [0.3, 0.7],
+ names: ['leftVal', 'rightVal'],
+ sparklineCfg: {
+ // type: 'column',
+ data: [
+ [1, 3, 2, -4, 1, 3, 2, -4],
+ [5, 1, 5, -8, 5, 1, 5, -8],
+ ],
+ },
+ },
+ });
+
+ canvas.appendChild(slider);
+ slider.destroy();
+ canvas.destroy();
+ });
+});
diff --git a/docs/api/ui/slider.en.md b/docs/api/ui/slider.en.md
new file mode 100644
index 000000000..0f33346ea
--- /dev/null
+++ b/docs/api/ui/slider.en.md
@@ -0,0 +1,50 @@
+---
+title: Slider
+order: 5
+---
+
+# 缩略轴
+
+> 缩略轴
+
+## 引入
+
+```ts
+import { Slider } from '@antv/gui';
+```
+
+## 配置项
+
+| **Property** | **Description** | **Type** | **Default** |
+| --------------- | --------------- | --------------------------------------------------------- | ------------ |
+| orient | Slider 朝向 | horizontal | vertical
| `horizontal` |
+| width | 宽度 | number
| `200` |
+| height | 高度 | number | `20` |
+| values | 缩略轴范围 | [number, number] | `[0, 1]` |
+| names | 手柄文本 | [string, string] | `['', '']` |
+| min | 最小可滚动范围 | number | `0` |
+| max | 最大可滚动范围 | number | `1` |
+| sparkline | 缩略图配置 | SparklineOptions | `[]` |
+| backgroundStyle | 自定义背景样式 | ShapeAttrs & {active: ShapeAttrs} | `[]` |
+| foregroundStyle | 自定义前景样式 | ShapeAttrs & {active: ShapeAttrs} | `[]` |
+| handle | 手柄配置 | handleCfg \| {start: handleCfg;end:handleCfg} | `[]` |
+
+### SparklineOptions
+
+`markdown:docs/common/sparkline-options.zh.md`
+
+## handleCfg
+
+| **Property** | **Description** | **Type** | **Default** |
+| ------------ | ----------------------------------------------- | -------------------------------------------------------------------------------- | ----------- |
+| show | boolean
| 是否显示手柄 | `true` |
+| size | number
| 手柄图标大小 | `10` |
+| formatter | (name, value)=>string
| 文本格式化 | `[]` |
+| textStyle | ShapeAttrs
| 文字样式 | `[]` |
+| spacing | number
| 文字与手柄的间隔 | `10` |
+| handleIcon | (x,y,r)=>PathCommand \| string
| 手柄图标,支持**image URL**、**data URL**、**Symbol Name**、 **Symbol Function** | `[]` |
+| handleStyle | ShapeAttrs & {active?: ShapeAttrs}
| 手柄图标样式 | `[]` |
+
+### ShapeAttrs
+
+`markdown:docs/common/shape-attrs.zh.md`
diff --git a/docs/api/ui/slider.zh.md b/docs/api/ui/slider.zh.md
new file mode 100644
index 000000000..312e59895
--- /dev/null
+++ b/docs/api/ui/slider.zh.md
@@ -0,0 +1,47 @@
+---
+title: Slider
+order: 5
+---
+
+# 缩略轴
+
+> 缩略轴
+
+## 引入
+
+```ts
+import { Slider } from '@antv/gui';
+```
+
+## 配置项
+
+| **属性** | **描述** | **类型** | **默认值** |
+| --------------- | -------------- | ----------------------------------------------------------- | ------------ |
+| orient | Slider 朝向 | horizontal | vertical
| `horizontal` |
+| width | 宽度 | number
| `200` |
+| height | 高度 | number | `20` |
+| values | 缩略轴范围 | [number, number] | `[0, 1]` |
+| names | 手柄文本 | [string, string] | `['', '']` |
+| min | 最小可滚动范围 | number | `0` |
+| max | 最大可滚动范围 | number | `1` |
+| sparkline | 缩略图配置 | SparklineOptions | `[]` |
+| backgroundStyle | 自定义背景样式 | ShapeAttrs & {active?: ShapeAttrs} | `[]` |
+| foregroundStyle | 自定义前景样式 | ShapeAttrs & {active?: ShapeAttrs} | `[]` |
+| handle | 手柄配置 | handleCfg \| {start: handleCfg; end: handleCfg} | `[]` |
+
+## SparklineOptions
+`markdown:docs/common/sparkline-options.zh.md`
+
+## handleCfg
+| **属性** | **类型** | **描述** | **默认值** |
+| ----------- | ----------------------------------------------- | -------------------------------------------------------------------------------- | ---------- |
+| show | boolean
| 是否显示手柄 | `true` |
+| size | number
| 手柄图标大小 | `10` |
+| formatter | (name, value)=>string
| 文本格式化 | `[]` |
+| textStyle | ShapeAttrs
| 文字样式 | `[]` |
+| spacing | number
| 文字与手柄的间隔 | `10` |
+| handleIcon | (x,y,r)=>PathCommand \| string
| 手柄图标,支持**image URL**、**data URL**、**Symbol Name**、 **Symbol Function** | `[]` |
+| handleStyle | ShapeAttrs & {active?: ShapeAttrs}
| 手柄图标样式 | `[]` |
+
+## ShapeAttrs
+`markdown:docs/common/shape-attrs.zh.md`
diff --git a/docs/api/ui/sparkline.en.md b/docs/api/ui/sparkline.en.md
index 82efce8b9..2d9192e2f 100644
--- a/docs/api/ui/sparkline.en.md
+++ b/docs/api/ui/sparkline.en.md
@@ -15,16 +15,4 @@ import { Sparkline } from '@antv/gui';
## Options
-| **Property** | **Description** | **Type** | **Default** |
-| ------------ | ------------------------ | -------------------------------------------------------- | -------------------------------------------------------- |
-| type | type of sparkline | line | bar
| `default` |
-| width | width | number
| `200` |
-| height | height | number | `20` |
-| data | data of sparkline | number[] | number[][] | `[]` |
-| isStack | whether to stack | boolean | `false` |
-| color | color of visual elements | color | color[] | (index) => color | `'#83daad', '#edbf45', '#d2cef9', '#e290b3', '#6f63f4']` |
-| smooth | use smooth curves | boolean | `true` |
-| lineStyle | custom line styles | StyleAttr | `[]` |
-| areaStyle | custom area styles | StyleAttr | `[]` |
-| isGroup | whether to group series | boolean | `false` |
-| columnStyle | custom column styles | ShapeAttrs | `[]` |
+`markdown:docs/common/sparkline-options.en.md`
diff --git a/docs/api/ui/sparkline.zh.md b/docs/api/ui/sparkline.zh.md
index bc5b498a1..b09eef14a 100644
--- a/docs/api/ui/sparkline.zh.md
+++ b/docs/api/ui/sparkline.zh.md
@@ -15,16 +15,4 @@ import { Sparkline } from '@antv/gui';
## 配置项
-| **属性** | **描述** | **类型** | **默认值** |
-| ----------- | ------------------ | -------------------------------------------------------- | -------------------------------------------------------- |
-| type | sparkline 类型 | line | bar
| `default` |
-| width | 宽度 | number
| `200` |
-| height | 高度 | number | `20` |
-| data | 数据 | number[] | number[][] | `[]` |
-| isStack | 是否堆积 | boolean | `false` |
-| color | 颜色 | color | color[] | (index) => color | `'#83daad', '#edbf45', '#d2cef9', '#e290b3', '#6f63f4']` |
-| smooth | 平滑曲线 | boolean | `true` |
-| lineStyle | 自定义线条样式 | StyleAttr | `[]` |
-| areaStyle | 自定义线条填充样式 | StyleAttr | `[]` |
-| isGroup | 是否分组 | boolean | `false` |
-| columnStyle | 柱体样式 | ShapeAttrs | `[]` |
+`markdown:docs/common/sparkline-options.zh.md`
diff --git a/docs/common/shape-attrs.zh.md b/docs/common/shape-attrs.zh.md
new file mode 100644
index 000000000..84cf00948
--- /dev/null
+++ b/docs/common/shape-attrs.zh.md
@@ -0,0 +1,40 @@
+### 基本属性
+
+| **属性名** | **类型** | **描述** |
+| ------------- | ------------------- | ---------------------------------------- |
+| x | number
| x 坐标 |
+| y | number
| y 坐标 |
+| r | number
| 半径 |
+| width | number
| 宽度 |
+| height | number
| 高度 |
+| stroke | color
| 描边颜色,可以是 rgba 值、颜色名(下同) |
+| strokeOpacity | number
| 描边透明度 |
+| fill | color
| 填充颜色 |
+| fillOpacity | number
| 填充透明度 |
+| Opacity | number
| 整体透明度 |
+| shadowBlur | number
| 模糊效果程度 |
+| shadowColor | color
| 阴影颜色 |
+| shadowOffsetX | number
| 阴影水平偏移距离 |
+| shadowOffsetY | number
| 阴影垂直偏移距离 |
+
+### 线条属性
+
+| **属性名** | **类型** | **描述** |
+| ---------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
+| lineWidth | number
| 线条或图形边框宽度 |
+| lineCap | 'butt' \| 'round' \| 'square'
| 线段末端样式 |
+| lineJoin | 'bevel' \| 'round' \| 'miter'
| 设置 2 个长度不为 0 的相连部分(线段,圆弧,曲线)如何连接在一起的属性(长度为 0 的变形部分,其指定的末端和控制点在同一位置,会被忽略) |
+| lineDash | number[] \| null
| 线条或图形边框的虚线样式 |
+
+### 文本属性
+
+| **属性名** | **类型** | **描述** |
+| ------------ | ---------------------------------------------------------------------------------------- | ----------------------------------- |
+| textAlign | 'start' \| 'center' \| 'end' \| 'left' \| 'right'
| 设置文本内容的当前对齐方式 |
+| textBaseline | 'top' \| 'hanging' \| 'middle' \| 'alphabetic' \| 'ideographic' \| 'bottom'
| 设置在绘制文本时使用的当前文本基线, |
+| fontStyle | 'normal' \| 'italic' \| 'oblique'
| 设置字体样式 |
+| fontSize | number
| 设置字号 |
+| fontFamily | string
| 设置字体系列 |
+| fontWeight | 'normal' \| 'bold' \| 'bolder' \| 'lighter' \| number
| 设置字体的粗细 |
+| fontVariant | 'normal' \| 'small-caps' \| string
| 设置字体变体 |
+| lineHeight | number
| 设置行高 |
diff --git a/docs/common/sparkline-options.en.md b/docs/common/sparkline-options.en.md
new file mode 100644
index 000000000..5d2e546e0
--- /dev/null
+++ b/docs/common/sparkline-options.en.md
@@ -0,0 +1,13 @@
+| **Property** | **Description** | **Type** | **Default** |
+| ------------ | ------------------------ | -------------------------------------------------------- | -------------------------------------------------------- |
+| type | type of sparkline | line | bar
| `default` |
+| width | width | number
| `200` |
+| height | height | number
| `20` |
+| data | data of sparkline | number[] | number[][]
| `[]` |
+| isStack | whether to stack | boolean
| `false` |
+| color | color of visual elements | color | color[] | (index) => color
| `'#83daad', '#edbf45', '#d2cef9', '#e290b3', '#6f63f4']` |
+| smooth | use smooth curves | boolean
| `true` |
+| lineStyle | custom line styles | ShapeAttr
| `[]` |
+| areaStyle | custom area styles | ShapeAttr
| `[]` |
+| isGroup | whether to group series | boolean
| `false` |
+| columnStyle | custom column styles | ShapeAttrs | `[]` |
diff --git a/docs/common/sparkline-options.zh.md b/docs/common/sparkline-options.zh.md
new file mode 100644
index 000000000..7f3be79d0
--- /dev/null
+++ b/docs/common/sparkline-options.zh.md
@@ -0,0 +1,13 @@
+| **属性** | **描述** | **类型** | **默认值** |
+| ----------- | ------------------ | --------------------------------------------------------- | -------------------------------------------------------- |
+| type | sparkline 类型 | line | bar
| `default` |
+| width | 宽度 | number
| `200` |
+| height | 高度 | number
| `20` |
+| data | 数据 | number[] | number[][]
| `[]` |
+| isStack | 是否堆积 | boolean
| `false` |
+| color | 颜色 | color | color[] | (index) => color
| `'#83daad', '#edbf45', '#d2cef9', '#e290b3', '#6f63f4']` |
+| smooth | 平滑曲线 | boolean
| `true` |
+| lineStyle | 自定义线条样式 | ShapeAttr
| `[]` |
+| areaStyle | 自定义线条填充样式 | ShapeAttr
| `[]` |
+| isGroup | 是否分组 | boolean
| `false` |
+| columnStyle | 柱体样式 | ShapeAttrs | `[]` |
diff --git a/examples/slider/API.en.md b/examples/slider/API.en.md
new file mode 100644
index 000000000..ca3286b49
--- /dev/null
+++ b/examples/slider/API.en.md
@@ -0,0 +1 @@
+`markdown:docs/api/ui/slider.en.md`
diff --git a/examples/slider/API.zh.md b/examples/slider/API.zh.md
new file mode 100644
index 000000000..30cd414b5
--- /dev/null
+++ b/examples/slider/API.zh.md
@@ -0,0 +1 @@
+`markdown:docs/api/ui/slider.zh.md`
diff --git a/examples/slider/demo/basic-slider.ts b/examples/slider/demo/basic-slider.ts
new file mode 100644
index 000000000..a1e4c9b99
--- /dev/null
+++ b/examples/slider/demo/basic-slider.ts
@@ -0,0 +1,30 @@
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Slider } 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 slider = new Slider({
+ attrs: {
+ x: 50,
+ y: 50,
+ width: 400,
+ height: 40,
+ values: [0.3, 0.7],
+ names: ['leftVal', 'rightVal'],
+ },
+});
+
+canvas.appendChild(slider);
diff --git a/examples/slider/demo/custom-handleIcon-slider.ts b/examples/slider/demo/custom-handleIcon-slider.ts
new file mode 100644
index 000000000..16f9fade4
--- /dev/null
+++ b/examples/slider/demo/custom-handleIcon-slider.ts
@@ -0,0 +1,43 @@
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Slider } 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 slider = new Slider({
+ attrs: {
+ x: 50,
+ y: 50,
+ width: 400,
+ height: 40,
+ values: [0.3, 0.7],
+ names: ['leftVal', 'rightVal'],
+ handle: {
+ start: {
+ size: 15,
+ formatter: (name, value) => {
+ return `${name}: ${(value * 100).toFixed(2)}%`;
+ },
+ handleIcon: 'https://gw.alipayobjects.com/mdn/rms_6ae20b/afts/img/A*N4ZMS7gHsUIAAAAAAAAAAABkARQnAQ',
+ },
+ end: {
+ spacing: 20,
+ handleIcon: 'diamond',
+ },
+ },
+ },
+});
+
+canvas.appendChild(slider);
diff --git a/examples/slider/demo/meta.json b/examples/slider/demo/meta.json
new file mode 100644
index 000000000..5d10970f0
--- /dev/null
+++ b/examples/slider/demo/meta.json
@@ -0,0 +1,40 @@
+{
+ "title": {
+ "zh": "中文分类",
+ "en": "Category"
+ },
+ "demos": [
+ {
+ "filename": "basic-slider.ts",
+ "title": {
+ "zh": "基础缩略轴",
+ "en": "Basic Slider"
+ },
+ "screenshot": ""
+ },
+ {
+ "filename": "vertical-slider.ts",
+ "title": {
+ "zh": "垂直缩略轴",
+ "en": "Vertical Slider"
+ },
+ "screenshot": ""
+ },
+ {
+ "filename": "custom-handleIcon-slider.ts",
+ "title": {
+ "zh": "自定义手柄缩略轴",
+ "en": "Custom Handle Icon Slider"
+ },
+ "screenshot": ""
+ },
+ {
+ "filename": "sparkline-slider.ts",
+ "title": {
+ "zh": "具有缩略图的缩略轴",
+ "en": "Slider with Sparkline"
+ },
+ "screenshot": ""
+ }
+ ]
+}
diff --git a/examples/slider/demo/sparkline-slider.ts b/examples/slider/demo/sparkline-slider.ts
new file mode 100644
index 000000000..a33e2d8b8
--- /dev/null
+++ b/examples/slider/demo/sparkline-slider.ts
@@ -0,0 +1,37 @@
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Slider } 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 slider = new Slider({
+ attrs: {
+ x: 50,
+ y: 50,
+ width: 400,
+ height: 40,
+ values: [0.3, 0.7],
+ names: ['leftVal', 'rightVal'],
+ sparklineCfg: {
+ // type: 'column',
+ data: [
+ [1, 3, 2, -4, 1, 3, 2, -4],
+ [5, 1, 5, -8, 5, 1, 5, -8],
+ ],
+ },
+ },
+});
+
+canvas.appendChild(slider);
diff --git a/examples/slider/demo/vertical-slider.ts b/examples/slider/demo/vertical-slider.ts
new file mode 100644
index 000000000..50a652238
--- /dev/null
+++ b/examples/slider/demo/vertical-slider.ts
@@ -0,0 +1,31 @@
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Slider } from '@antv/gui';
+
+const renderer = new CanvasRenderer({
+ enableDirtyRectangleRenderingDebug: false,
+ enableAutoRendering: true,
+ enableDirtyRectangleRendering: true,
+});
+
+// @ts-ignore
+const canvas = new Canvas({
+ container: 'container',
+ width: 300,
+ height: 600,
+ renderer,
+});
+
+const slider = new Slider({
+ attrs: {
+ x: 50,
+ y: 50,
+ width: 40,
+ height: 400,
+ orient: 'vertical',
+ values: [0.3, 0.7],
+ names: ['aboveVal', 'belowVal'],
+ },
+});
+
+canvas.appendChild(slider);
diff --git a/examples/slider/index.en.md b/examples/slider/index.en.md
new file mode 100644
index 000000000..289681515
--- /dev/null
+++ b/examples/slider/index.en.md
@@ -0,0 +1,4 @@
+---
+title: Slider
+order: 6
+---
diff --git a/examples/slider/index.zh.md b/examples/slider/index.zh.md
new file mode 100644
index 000000000..289681515
--- /dev/null
+++ b/examples/slider/index.zh.md
@@ -0,0 +1,4 @@
+---
+title: Slider
+order: 6
+---
diff --git a/package.json b/package.json
index 23997f531..ed0aa160b 100644
--- a/package.json
+++ b/package.json
@@ -89,7 +89,14 @@
],
"collectCoverageFrom": [
"src/**/*.ts"
- ]
+ ],
+ "globals": {
+ "ts-jest": {
+ "tsConfig": {
+ "target": "ES2019"
+ }
+ }
+ }
},
"lint-staged": {
"*.{ts,tsx}": [
diff --git a/src/ui/slider/index.ts b/src/ui/slider/index.ts
index 95f00afba..978e85dd1 100644
--- a/src/ui/slider/index.ts
+++ b/src/ui/slider/index.ts
@@ -1,5 +1,653 @@
-import { SliderOptions } from './types';
+import { Rect, Text, Image, Line } from '@antv/g';
+import { deepMix, get, isFunction, isString, isObject } from '@antv/util';
+import { SliderOptions, HandleCfg, Pair } from './types';
+import { Marker, MarkerOptions } from '../marker';
+import { Sparkline } from '../sparkline';
+import { CustomElement, DisplayObject } from '../../types';
+import { applyAttrs, toPrecision } from '../../util';
export { SliderOptions };
-export class Slider {}
+type HandleType = 'start' | 'end';
+
+export class Slider extends CustomElement {
+ public static tag = 'slider';
+
+ /**
+ * 层级关系
+ * backgroundShape
+ * |- sparklineShape
+ * |- foregroundShape
+ * |- startHandle
+ * |- endHandle
+ */
+
+ /**
+ * 背景
+ */
+ private backgroundShape: DisplayObject;
+
+ /**
+ * 缩略图
+ */
+ private sparklineShape: DisplayObject;
+
+ /**
+ * 前景,即选区
+ */
+ private foregroundShape: DisplayObject;
+
+ /**
+ * 起始手柄
+ */
+ private startHandle: DisplayObject;
+
+ /**
+ * 终点手柄
+ */
+ private endHandle: DisplayObject;
+
+ /**
+ * 选区开始的位置
+ */
+ private selectionStartPos: number;
+
+ /**
+ * 选区宽度
+ */
+ private selectionWidth: number;
+
+ /**
+ * 记录上一次鼠标事件所在坐标
+ */
+ private prevPos: number;
+
+ /**
+ * drag事件当前选中的对象
+ */
+ private target: string;
+
+ constructor(options: SliderOptions) {
+ super(deepMix({}, Slider.defaultOptions, options));
+ this.init();
+ }
+
+ private static defaultOptions = {
+ type: Slider.tag,
+ attrs: {
+ orient: 'horizontal',
+ values: [0, 1],
+ names: ['', ''],
+ min: 0,
+ max: 1,
+ width: 200,
+ height: 20,
+ sparklineCfg: {
+ padding: {
+ left: 1,
+ right: 1,
+ top: 1,
+ bottom: 1,
+ },
+ },
+ padding: {
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ },
+ backgroundStyle: {
+ fill: '#fff',
+ stroke: '#e4eaf5',
+ lineWidth: 1,
+ },
+ foregroundStyle: {
+ fill: '#afc9fb',
+ opacity: 0.5,
+ stroke: '#afc9fb',
+ lineWidth: 1,
+ active: {
+ fill: '#ccdaf5',
+ },
+ },
+ handle: {
+ show: true,
+ formatter: (val: string) => val,
+ spacing: 10,
+ textStyle: {
+ fill: '#63656e',
+ textAlign: 'center',
+ textBaseline: 'middle',
+ },
+ handleStyle: {
+ stroke: '#c5c5c5',
+ fill: '#fff',
+ lineWidth: 1,
+ },
+ },
+ },
+ };
+
+ attributeChangedCallback(name: string, value: any) {
+ if (name === 'values') {
+ this.emit('valuechange', value);
+ }
+ if (name in ['names', 'values']) {
+ this.setHandle();
+ }
+ }
+
+ public getValues() {
+ return this.getAttribute('values');
+ }
+
+ public setValues(values: SliderOptions['values']) {
+ this.setAttribute('values', this.getSafetyValues(values));
+ }
+
+ public getNames() {
+ return this.getAttribute('names');
+ }
+
+ public setNames(names: SliderOptions['names']) {
+ this.setAttribute('names', names);
+ }
+
+ private init() {
+ this.createBackground();
+ this.createSparkline();
+ this.createForeground();
+ this.createHandles();
+ this.bindEvents();
+ }
+
+ /**
+ * 获得安全的Values
+ */
+ private getSafetyValues(values = this.getValues(), precision = 4): Pair {
+ const { min, max } = this.attributes;
+ const [prevStart, prevEnd] = this.getValues();
+ let [startVal, endVal] = values || [prevStart, prevEnd];
+ 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)];
+ }
+
+ private getAvailableSpace() {
+ const { padding, width, height } = this.attributes;
+ return {
+ x: padding.left,
+ y: padding.top,
+ width: width - (padding.left + padding.right),
+ height: height - (padding.top + padding.bottom),
+ };
+ }
+
+ /**
+ * 获取style
+ * @param name style名
+ * @param isActive 是否是active style
+ * @returns ShapeAttrs
+ */
+ private getStyle(name: string | string[], isActive?: boolean, handleType?: HandleType) {
+ const { active, ...args } = get(handleType ? this.getHandleCfg(handleType) : this.attributes, name);
+ if (isActive) {
+ return active || {};
+ }
+ return args?.default || args;
+ }
+
+ private createBackground() {
+ this.backgroundShape = new Rect({
+ name: 'background',
+ attrs: {
+ cursor: 'crosshair',
+ ...this.getAvailableSpace(),
+ ...this.getStyle('backgroundStyle'),
+ },
+ });
+ this.appendChild(this.backgroundShape);
+ }
+
+ /**
+ * 生成sparkline
+ */
+ private createSparkline() {
+ const { orient, sparklineCfg } = this.attributes;
+ console.log(sparklineCfg);
+
+ // 暂时只在水平模式下绘制
+ if (orient !== 'horizontal') {
+ return;
+ }
+ const { padding, ...args } = sparklineCfg;
+
+ const { width, height } = this.getAvailableSpace();
+ const { lineWidth: bkgLW } = this.getStyle('backgroundStyle');
+ this.sparklineShape = new Sparkline({
+ attrs: {
+ x: bkgLW / 2 + padding.left,
+ y: bkgLW / 2 + padding.top,
+ width: width - bkgLW - padding.left - padding.right,
+ height: height - bkgLW - padding.top - padding.bottom,
+ ...args,
+ },
+ });
+ this.backgroundShape.appendChild(this.sparklineShape);
+ this.sparklineShape.toBack();
+ }
+
+ /**
+ * 计算蒙板坐标和宽高
+ * 默认用来计算前景位置大小
+ */
+ private calcMask(values?: Pair) {
+ const [start, end] = this.getSafetyValues(values);
+ const { width, height } = this.getAvailableSpace();
+
+ return this.getOrientVal([
+ {
+ y: 0,
+ height,
+ x: start * width,
+ width: (end - start) * width,
+ },
+ {
+ x: 0,
+ width,
+ y: start * height,
+ height: (end - start) * height,
+ },
+ ]);
+ }
+
+ private createForeground() {
+ this.foregroundShape = new Rect({
+ name: 'foreground',
+ attrs: {
+ cursor: 'move',
+ ...this.calcMask(),
+ ...this.getStyle('foregroundStyle'),
+ },
+ });
+ this.backgroundShape.appendChild(this.foregroundShape);
+ }
+
+ /**
+ * 计算手柄的x y
+ */
+ private calcHandlePosition(handleType: HandleType) {
+ const { width, height } = this.getAvailableSpace();
+ const values = this.getSafetyValues();
+ const L = handleType === 'start' ? 0 : (values[1] - values[0]) * this.getOrientVal([width, height]);
+ return {
+ x: this.getOrientVal([L, width / 2]),
+ y: this.getOrientVal([height / 2, L]),
+ };
+ }
+
+ /**
+ * 设置选区
+ * 1. 设置前景大小及位置
+ * 2. 设置手柄位置
+ * 3. 更新文本位置
+ */
+ private setHandle() {
+ applyAttrs(this.foregroundShape, this.calcMask());
+ applyAttrs(this.startHandle, this.calcHandlePosition('start'));
+ applyAttrs(this.endHandle, this.calcHandlePosition('end'));
+ this.getElementsByName('handleText').forEach((handleText) => {
+ applyAttrs(handleText, this.calcHandleText(handleText.getConfig().identity));
+ });
+ }
+
+ /**
+ * 计算手柄应当处于的位置
+ * @param name 手柄文字
+ * @param handleType start手柄还是end手柄
+ * @returns
+ */
+ private calcHandleText(handleType: HandleType) {
+ const { orient, names } = this.attributes;
+ const { spacing, formatter, textStyle } = this.getHandleCfg(handleType);
+ const size = this.getHandleSize(handleType);
+ const values = this.getSafetyValues();
+
+ // 相对于获取两端可用空间
+ const { width: iW, height: iH } = this.getAvailableSpace();
+ const { x: fX, y: fY, width: fW, height: fH } = this.calcMask();
+
+ const formattedText = formatter(...(handleType === 'start' ? [names[0], values[0]] : [names[1], values[1]]));
+ const _ = new Text({
+ attrs: {
+ text: formattedText,
+ ...textStyle,
+ },
+ });
+ // 文字的包围盒
+ const tBox = _.getBounds();
+ _.destroy();
+
+ let x = 0;
+ let y = 0;
+ const R = size / 2;
+ if (orient === 'horizontal') {
+ const textWidth = tBox.getMax()[0] - tBox.getMin()[0];
+ const sh = spacing + R;
+ const _ = sh + textWidth / 2;
+ if (handleType === 'start') {
+ const left = fX - sh - textWidth;
+ x = left > 0 ? -_ : _;
+ } else {
+ x = iW - fX - fW - sh > textWidth ? _ : -_;
+ }
+ } else {
+ const _ = spacing + R;
+ const textHeight = tBox.getMax()[1] - tBox.getMin()[1];
+ if (handleType === 'start') {
+ y = fY - R > textHeight ? -_ : _;
+ } else {
+ y = iH - fY - fH - R > textHeight ? _ : -_;
+ }
+ }
+ return { x, y, text: formattedText };
+ }
+
+ /**
+ * 解析icon类型
+ */
+ private parseIcon(icon: MarkerOptions['symbol'] | string) {
+ let type = 'unknown';
+ if (isObject(icon) && icon instanceof Image) type = 'image';
+ else if (isFunction(icon)) type = 'symbol';
+ else if (isString(icon)) {
+ const dataURLsPattern = new RegExp('data:(image|text)');
+ if (icon.match(dataURLsPattern)) {
+ type = 'base64';
+ } else if (/^(https?:\/\/(([a-zA-Z0-9]+-?)+[a-zA-Z0-9]+\.)+[a-zA-Z]+)(:\d+)?(\/.*)?(\?.*)?(#.*)?$/.test(icon)) {
+ type = 'url';
+ } else {
+ // 不然就当作symbol string 处理
+ type = 'symbol';
+ }
+ }
+ return type;
+ }
+
+ /**
+ * 创建手柄
+ */
+ private createHandle(options: HandleCfg, handleType: HandleType) {
+ const { show, textStyle, handleIcon: icon, handleStyle } = options;
+ const size = this.getHandleSize(handleType);
+ const iconType = this.parseIcon(icon);
+ const baseCfg = {
+ name: 'handleIcon',
+ identity: handleType,
+ };
+ const cursor = this.getOrientVal(['ew-resize', 'ns-resize']);
+
+ const handleIcon = (() => {
+ if (!show) {
+ // 如果不显示的话,就创建透明的rect
+ return new Marker({
+ ...baseCfg,
+ attrs: {
+ r: size / 2,
+ symbol: 'square',
+ markerStyle: {
+ opacity: 0,
+ },
+ cursor,
+ },
+ });
+ }
+
+ if (['base64', 'url', 'image'].includes(iconType)) {
+ // TODO G那边似乎还是有点问题,暂不考虑Image
+ return new Image({
+ ...baseCfg,
+ attrs: {
+ x: -size / 2,
+ y: -size / 2,
+ width: size,
+ height: size,
+ img: icon,
+ cursor,
+ },
+ });
+ }
+ if (iconType === 'symbol') {
+ return new Marker({
+ ...baseCfg,
+ attrs: {
+ r: size / 2,
+ symbol: icon,
+ cursor,
+ ...handleStyle,
+ },
+ });
+ }
+
+ const width = size;
+ const height = size * 2.4;
+
+ // 创建默认图形
+ const handleBody = new Rect({
+ ...baseCfg,
+ attrs: {
+ cursor,
+ width,
+ height,
+ x: -width / 2,
+ y: -height / 2,
+ radius: size / 4,
+ ...handleStyle,
+ },
+ });
+ const { stroke, lineWidth } = handleStyle;
+ const X1 = (1 / 3) * width;
+ const X2 = (2 / 3) * width;
+ const Y1 = (1 / 4) * height;
+ const Y2 = (3 / 4) * height;
+
+ const createLine = (x1: number, y1: number, x2: number, y2: number) => {
+ return new Line({
+ name: 'line',
+ attrs: {
+ x1,
+ y1,
+ x2,
+ y2,
+ cursor,
+ stroke,
+ lineWidth,
+ },
+ });
+ };
+
+ handleBody.appendChild(createLine(X1, Y1, X1, Y2));
+ handleBody.appendChild(createLine(X2, Y1, X2, Y2));
+
+ // 根据orient进行rotate
+ // 设置旋转中心
+ handleBody.setOrigin(width / 2, height / 2);
+ handleBody.rotate(this.getOrientVal([0, 90]));
+
+ return handleBody;
+ })();
+
+ const handleText = new Text({
+ name: 'handleText',
+ identity: handleType,
+ attrs: {
+ // TODO 之后考虑添加文字超长省略,可以在calcHandleTextPosition中实现
+ ...textStyle,
+ ...this.calcHandleText(handleType),
+ },
+ });
+
+ // 用 Group 创建对象会提示没有attrs属性
+ const handle = new DisplayObject({
+ name: 'handle',
+ identity: handleType,
+ attrs: this.calcHandlePosition(handleType),
+ });
+ handle.appendChild(handleIcon);
+ handle.appendChild(handleText);
+ return handle;
+ }
+
+ private getHandleCfg(handleType: HandleType) {
+ const { start, end, ...args } = this.getAttribute('handle');
+ let _ = {};
+ if (handleType === 'start') {
+ _ = start;
+ } else if (handleType === 'end') {
+ _ = end;
+ }
+ return deepMix({}, args, _);
+ }
+
+ private getHandleSize(handleType: HandleType) {
+ const handleCfg = this.getHandleCfg(handleType);
+ const { size } = handleCfg;
+ if (size) return size;
+
+ // 没设置size的话,高度就取height的80%高度,手柄宽度是高度的1/2.4
+ const { width, height } = this.attributes;
+ return (this.getOrientVal([height, width]) * 0.8) / 2.4;
+ }
+
+ private createHandles() {
+ this.startHandle = this.createHandle(this.getHandleCfg('start'), 'start');
+ this.foregroundShape.appendChild(this.startHandle);
+ this.endHandle = this.createHandle(this.getHandleCfg('end'), 'end');
+ this.foregroundShape.appendChild(this.endHandle);
+ }
+
+ private bindEvents() {
+ // Drag and brush
+ this.backgroundShape.addEventListener('mousedown', this.onDragStart('background'));
+ this.backgroundShape.addEventListener('touchstart', this.onDragStart('background'));
+
+ this.foregroundShape.addEventListener('mousedown', this.onDragStart('foreground'));
+ this.foregroundShape.addEventListener('touchstart', this.onDragStart('foreground'));
+
+ this.getElementsByName('handleIcon').forEach((handleIcon) => {
+ handleIcon.addEventListener('mousedown', this.onDragStart(`${handleIcon.getConfig().identity}Handle`));
+ handleIcon.addEventListener('touchstart', this.onDragStart(`${handleIcon.getConfig().identity}Handle`));
+ });
+ // Hover
+ this.bindHoverEvents();
+ }
+
+ private getOrientVal([x, y]: Pair) {
+ const { orient } = this.attributes;
+ return orient === 'horizontal' ? x : y;
+ }
+
+ private setValuesOffset(stOffset: number, endOffset: number = 0) {
+ const [oldStartVal, oldEndVal] = this.getValues();
+ this.setValues([oldStartVal + stOffset, oldEndVal + endOffset].sort() as Pair);
+ }
+
+ private getRatio(val: number) {
+ const { width, height } = this.getAvailableSpace();
+ return val / this.getOrientVal([width, height]);
+ }
+
+ private onDragStart = (target: string) => (e) => {
+ e.stopPropagation();
+ this.target = target;
+ this.prevPos = this.getOrientVal([e.x, e.y]);
+ const { x, y } = this.getAvailableSpace();
+ const { x: X, y: Y } = this.attributes;
+ this.selectionStartPos = this.getRatio(this.prevPos - this.getOrientVal([x, y]) - this.getOrientVal([X, Y]));
+ this.selectionWidth = 0;
+ 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 currPos = this.getOrientVal([e.x, e.y]);
+ const _ = currPos - this.prevPos;
+ if (!_) return;
+ const dVal = this.getRatio(_);
+
+ switch (this.target) {
+ case 'startHandle':
+ this.setValuesOffset(dVal);
+ break;
+ case 'endHandle':
+ this.setValuesOffset(0, dVal);
+ break;
+ case 'foreground':
+ this.setValuesOffset(dVal, dVal);
+ break;
+ case 'background':
+ // 绘制蒙板
+ this.selectionWidth += dVal;
+ this.setValues([this.selectionStartPos, this.selectionStartPos + this.selectionWidth].sort() as Pair);
+ break;
+ default:
+ break;
+ }
+
+ this.prevPos = currPos;
+ };
+
+ private onDragEnd = () => {
+ this.removeEventListener('mousemove', this.onDragging);
+ this.removeEventListener('mousemove', this.onDragging);
+ document.removeEventListener('mouseup', this.onDragEnd);
+ document.removeEventListener('touchend', this.onDragEnd);
+ };
+
+ private bindHoverEvents = () => {
+ this.foregroundShape.addEventListener('mouseenter', () => {
+ applyAttrs(this.foregroundShape, this.getStyle('foregroundStyle', true));
+ });
+ this.foregroundShape.addEventListener('mouseleave', () => {
+ applyAttrs(this.foregroundShape, this.getStyle('foregroundStyle'));
+ });
+
+ this.getElementsByName('handle').forEach((handle) => {
+ const icon = handle.getElementsByName('handleIcon')[0];
+ const text = handle.getElementsByName('handleText')[0];
+ handle.addEventListener('mouseenter', () => {
+ applyAttrs(icon, this.getStyle('handleStyle', true, icon.getConfig().identity));
+ applyAttrs(text, this.getStyle('textStyle', true, text.getConfig().identity));
+ });
+ handle.addEventListener('mouseleave', () => {
+ applyAttrs(icon, this.getStyle('handleStyle', false, icon.getConfig().identity));
+ applyAttrs(text, this.getStyle('textStyle', false, text.getConfig().identity));
+ });
+ });
+ };
+}
diff --git a/src/ui/slider/types.ts b/src/ui/slider/types.ts
index 7a0b7297e..866991127 100644
--- a/src/ui/slider/types.ts
+++ b/src/ui/slider/types.ts
@@ -1 +1,70 @@
-export type SliderOptions = {};
+import { ShapeAttrs, ShapeCfg } from '../../types';
+import { MarkerOptions } from '../marker';
+import { SparklineOptions } from '../sparkline';
+
+export type Pair = [T, T];
+
+export type MixAttrs = ShapeAttrs & {
+ active?: ShapeAttrs;
+};
+
+export type HandleCfg = {
+ /**
+ * 是否显示Handle
+ */
+ show?: boolean;
+ /**
+ * 大小
+ */
+ size?: number;
+ /**
+ * 文本格式化
+ */
+ formatter?: (text: string) => string;
+ /**
+ * 文字样式
+ */
+ textStyle: ShapeAttrs;
+ /**
+ * 文字与手柄的间隔
+ */
+ spacing: number;
+ /**
+ * 手柄图标
+ */
+ handleIcon?: MarkerOptions['symbol'] | string;
+ /**
+ * 手柄图标样式
+ */
+ handleStyle: MixAttrs;
+};
+
+export type SliderOptions = ShapeCfg & {
+ orient?: 'vertical' | 'horizontal';
+ values?: Pair;
+ names?: Pair;
+ min?: number;
+ max?: number;
+ width?: number;
+ height?: number;
+ padding?: {
+ left: number;
+ right: number;
+ top: number;
+ buttons: number;
+ };
+ backgroundStyle?: MixAttrs;
+ selectionStyle?: MixAttrs;
+ foregroundStyle?: MixAttrs;
+ handle?:
+ | HandleCfg
+ | {
+ start: HandleCfg;
+ end: HandleCfg;
+ };
+
+ /**
+ * 缩略图数据及其配置
+ */
+ sparklineCfg?: SparklineOptions;
+};
diff --git a/src/ui/sparkline/index.ts b/src/ui/sparkline/index.ts
index 5e04dcdda..84d9cd433 100644
--- a/src/ui/sparkline/index.ts
+++ b/src/ui/sparkline/index.ts
@@ -25,7 +25,7 @@ export class Sparkline extends CustomElement {
type: 'line',
width: 200,
height: 20,
- data: [],
+ // data: [],
isStack: false,
color: ['#83daad', '#edbf45', '#d2cef9', '#e290b3', '#6f63f4'],
smooth: true,
@@ -53,16 +53,15 @@ export class Sparkline extends CustomElement {
}
private init() {
- const { x, y, type, width, height } = this.attributes;
+ const { data, type, width, height } = this.attributes;
this.sparkShapes = new Rect({
attrs: {
- x,
- y,
width,
height,
},
});
this.appendChild(this.sparkShapes);
+ if (!data) return;
switch (type) {
case 'line':
this.createLine();
@@ -109,7 +108,7 @@ export class Sparkline extends CustomElement {
paddingInner: isGroup ? barPadding : 0,
}),
y: new Linear({
- domain: [minVal > 0 ? 0 : minVal, maxVal],
+ domain: [minVal >= 0 ? 0 : minVal, maxVal],
range: [height, 0],
}),
};
diff --git a/src/util/index.ts b/src/util/index.ts
index 20ed23d82..403afaac4 100644
--- a/src/util/index.ts
+++ b/src/util/index.ts
@@ -1,3 +1,3 @@
export { svg2marker } from './svg2marker';
export { measureTextWidth, getEllipsisText } from './text';
-export { applyAttrs, isPC } from './utils';
+export { applyAttrs, isPC, toPrecision } from './utils';
diff --git a/src/util/utils.ts b/src/util/utils.ts
index 3113117f1..01c5c2384 100644
--- a/src/util/utils.ts
+++ b/src/util/utils.ts
@@ -26,3 +26,11 @@ export function isPC(userAgent = undefined) {
});
return flag;
}
+
+/**
+ * 保留x位小数
+ */
+export function toPrecision(num: number, precision: number) {
+ const _ = 10 ** precision;
+ return Number(Math.round(num * _).toFixed(0)) / _;
+}