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);