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)) / _; +}