diff --git a/__tests__/integration/snapshots/static/flareTreemapCustom.svg b/__tests__/integration/snapshots/static/flareTreemapCustom.svg index b31fc92ecc..e627017ebb 100644 --- a/__tests__/integration/snapshots/static/flareTreemapCustom.svg +++ b/__tests__/integration/snapshots/static/flareTreemapCustom.svg @@ -3821,7 +3821,7 @@ transform="matrix(1,0,0,1,282.230560,723.987915)" class="label" > - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/__tests__/integration/snapshots/static/flareTreemapDrillDown.svg b/__tests__/integration/snapshots/static/flareTreemapDrillDown.svg new file mode 100644 index 0000000000..9d39707902 --- /dev/null +++ b/__tests__/integration/snapshots/static/flareTreemapDrillDown.svg @@ -0,0 +1,571 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 文具 + + + + + + + + + + + + + + + + + + + + 零食 + + + + + + + + + + + + + + + + + + + + 其他 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 文具 + + + + + + + + + + + + + 零食 + + + + + + + + + + + + + 其他 + + + + + + + + + + + + + \ No newline at end of file diff --git a/__tests__/integration/snapshots/tooltip/flare-treemap-default/step0.html b/__tests__/integration/snapshots/tooltip/flare-treemap-default/step0.html index 9d9e955131..9715ae0a28 100644 --- a/__tests__/integration/snapshots/tooltip/flare-treemap-default/step0.html +++ b/__tests__/integration/snapshots/tooltip/flare-treemap-default/step0.html @@ -3,12 +3,6 @@ class="g2-tooltip" style="pointer-events: none; position: absolute; visibility: visible; z-index: 8; transition: visibility 0.2s cubic-bezier(0.23, 1, 0.32, 1), left 0.4s cubic-bezier(0.23, 1, 0.32, 1), top 0.4s cubic-bezier(0.23, 1, 0.32, 1); background-color: rgba(255, 255, 255, 0.96); box-shadow: 0 6px 12px 0 rgba(0, 0, 0, 0.12); border-radius: 4px; color: rgba(0, 0, 0, 0.65); font-size: 12px; line-height: 20px; padding: 12px; min-width: 120px; max-width: 360px; font-family: Roboto-Regular; left: 10px; top: 10px;" > -
- flare.vis.Visualization -
    { }, labelFill: '#000', labelPosition: 'top-left', - dx: 3, - dy: 3, + labelDx: 3, + labelDy: 3, // shape style fillOpacity: 0.5, }, diff --git a/__tests__/plots/static/flare-treemap-drill-down.ts b/__tests__/plots/static/flare-treemap-drill-down.ts new file mode 100644 index 0000000000..9975fc7191 --- /dev/null +++ b/__tests__/plots/static/flare-treemap-drill-down.ts @@ -0,0 +1,68 @@ +import { G2Spec } from '../../../src'; + +export function flareTreemapDrillDown(): G2Spec { + return { + type: 'treemap', + width: 600, + height: 400, + layout: { tile: 'treemapBinary', paddingInner: 5 }, + data: { + value: { + name: '商品', + children: [ + { + name: '文具', + children: [ + { + name: '笔', + children: [ + { name: '铅笔', value: 430 }, + { name: '圆珠笔', value: 530 }, + { name: '钢笔', value: 80 }, + { name: '水彩', value: 130 }, + ], + }, + { name: '铅笔盒', value: 30 }, + { name: '直尺', value: 60 }, + { name: '笔记本', value: 160 }, + { name: '其他', value: 80 }, + ], + }, + { + name: '零食', + children: [ + { name: '饼干', value: 280 }, + { name: '辣条', value: 150 }, + { name: '牛奶糖', value: 210 }, + { name: '泡泡糖', value: 80 }, + { + name: '饮品', + children: [ + { name: '可乐', value: 122 }, + { name: '矿泉水', value: 244 }, + { name: '果汁', value: 49 }, + { name: '牛奶', value: 82 }, + ], + }, + { name: '其他', value: 40 }, + ], + }, + { name: '其他', value: 450 }, + ], + }, + }, + encode: { value: 'value' }, + style: { + labelFill: '#000', + labelStroke: '#fff', + labelLineWidth: 1.5, + labelFontSize: 14, + labelPosition: 'top-left', + labelDx: 5, + labelDy: 5, + }, + interaction: { + treemapDrillDown: { breadCrumbY: 12, activeFill: '#873bf4' }, + }, + }; +} diff --git a/__tests__/plots/static/index.ts b/__tests__/plots/static/index.ts index 220fe3d41a..8d3ecfe4fb 100644 --- a/__tests__/plots/static/index.ts +++ b/__tests__/plots/static/index.ts @@ -144,6 +144,7 @@ export { londonTubeLineGeo } from './london-tube-lines-geo'; export { countries50mProjectionComparison } from './countries-50m-projection-comparison'; export { flareTreemapDefault } from './flare-treemap-default'; export { flareTreemapCustom } from './flare-treemap-custom'; +export { flareTreemapDrillDown } from './flare-treemap-drill-down'; export { metrosLinkTrending } from './metros-link-trending'; export { incomeLinkAnnotation } from './income-link-annotation'; export { alphabetIntervalSpaceLayer } from './alphabet-interval-space-layer'; diff --git a/__tests__/plots/tooltip/flare-treemap-default.ts b/__tests__/plots/tooltip/flare-treemap-default.ts index e0abcab541..406d5fdc06 100644 --- a/__tests__/plots/tooltip/flare-treemap-default.ts +++ b/__tests__/plots/tooltip/flare-treemap-default.ts @@ -20,6 +20,10 @@ export async function flareTreemapDefault(): Promise { value: 'size', color: (d) => d.parent.data.name.split('.')[1], }, + tooltip: { + title: null, + items: [{ field: 'value' }], + }, style: { labelText: (d) => { const name = d.data.name @@ -30,11 +34,11 @@ export async function flareTreemapDefault(): Promise { }, labelFill: '#000', labelPosition: 'top-left', - dx: 3, - dy: 3, + labelDx: 3, + labelDy: 3, fillOpacity: 0.5, }, - }; + } as any; } flareTreemapDefault.steps = tooltipSteps(0); diff --git a/__tests__/plots/tooltip/flare-treemap-poptip-custom.ts b/__tests__/plots/tooltip/flare-treemap-poptip-custom.ts index 4d2f789c85..6a8bb6f324 100644 --- a/__tests__/plots/tooltip/flare-treemap-poptip-custom.ts +++ b/__tests__/plots/tooltip/flare-treemap-poptip-custom.ts @@ -2,7 +2,7 @@ import { schemeTableau10 } from 'd3-scale-chromatic'; import { CustomEvent, DisplayObject } from '@antv/g'; import { G2Spec, PLOT_CLASS_NAME } from '../../../src'; -export async function flareTreemapPoptipCustom(): Promise { +export function flareTreemapPoptipCustom(): G2Spec { return { type: 'view', height: 600, @@ -35,8 +35,8 @@ export async function flareTreemapPoptipCustom(): Promise { }, labelFill: '#000', labelPosition: 'top-left', - dx: 3, - dy: 3, + labelDx: 3, + labelDy: 3, fillOpacity: 0.5, }, }, diff --git a/__tests__/plots/tooltip/flare-treemap-poptip.ts b/__tests__/plots/tooltip/flare-treemap-poptip.ts index c5ce5487cd..88f5ea3282 100644 --- a/__tests__/plots/tooltip/flare-treemap-poptip.ts +++ b/__tests__/plots/tooltip/flare-treemap-poptip.ts @@ -2,7 +2,7 @@ import { schemeTableau10 } from 'd3-scale-chromatic'; import { CustomEvent, DisplayObject } from '@antv/g'; import { G2Spec, PLOT_CLASS_NAME } from '../../../src'; -export async function flareTreemapPoptip(): Promise { +export function flareTreemapPoptip(): G2Spec { return { type: 'view', height: 600, @@ -35,8 +35,8 @@ export async function flareTreemapPoptip(): Promise { }, labelFill: '#000', labelPosition: 'top-left', - dx: 3, - dy: 3, + labelDx: 3, + labelDy: 3, fillOpacity: 0.5, }, }, diff --git a/__tests__/unit/lib/core.spec.ts b/__tests__/unit/lib/core.spec.ts index 4b03dff1d8..6869e4b3f5 100644 --- a/__tests__/unit/lib/core.spec.ts +++ b/__tests__/unit/lib/core.spec.ts @@ -101,6 +101,7 @@ import { ScrollbarFilter, LegendHighlight, Poptip, + TreemapDrillDown, } from '../../../src/interaction'; import { SpaceLayer, @@ -301,6 +302,7 @@ describe('corelib', () => { 'interaction.sliderFilter': SliderFilter, 'interaction.scrollbarFilter': ScrollbarFilter, 'interaction.poptip': Poptip, + 'interaction.treemapDrillDown': TreemapDrillDown, 'composition.spaceLayer': SpaceLayer, 'composition.spaceFlex': SpaceFlex, 'composition.facetRect': FacetRect, diff --git a/__tests__/unit/lib/std.spec.ts b/__tests__/unit/lib/std.spec.ts index 8546a82ac7..b8ebf9c8da 100644 --- a/__tests__/unit/lib/std.spec.ts +++ b/__tests__/unit/lib/std.spec.ts @@ -111,6 +111,7 @@ import { ScrollbarFilter, LegendHighlight, Poptip, + TreemapDrillDown, } from '../../../src/interaction'; import { SpaceLayer, @@ -329,6 +330,7 @@ describe('stdlib', () => { 'interaction.sliderFilter': SliderFilter, 'interaction.scrollbarFilter': ScrollbarFilter, 'interaction.poptip': Poptip, + 'interaction.treemapDrillDown': TreemapDrillDown, 'composition.spaceLayer': SpaceLayer, 'composition.spaceFlex': SpaceFlex, 'composition.facetRect': FacetRect, diff --git a/package.json b/package.json index 1d60ac7295..6b2b078ed4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dist" ], "scripts": { + "start": "cd site && npm run start", "dev": "cross-env TZ=Asia/Shanghai vite", "dev:link": "cross-env LINK=1 vite", "clean": "rimraf lib esm dist", @@ -163,7 +164,7 @@ }, { "path": "dist/g2.lite.min.js", - "limit": "240 Kb", + "limit": "250 Kb", "gzip": true } ], diff --git a/site/docs/spec/graph/treemap.zh.md b/site/docs/spec/graph/treemap.zh.md index 41ab6a7d34..c8d56be1b1 100644 --- a/site/docs/spec/graph/treemap.zh.md +++ b/site/docs/spec/graph/treemap.zh.md @@ -31,7 +31,6 @@ chart paddingInner: 1, }) .encode('value', 'size') - .encode('color', (d) => d.parent.data.name.split('.')[1]) .scale('color', { range: schemeTableau10 }) .style( 'labelText', diff --git a/site/docs/spec/interaction/poptip.zh.md b/site/docs/spec/interaction/poptip.zh.md index be3f5fb47e..e1a42889dc 100644 --- a/site/docs/spec/interaction/poptip.zh.md +++ b/site/docs/spec/interaction/poptip.zh.md @@ -30,7 +30,6 @@ chart paddingInner: 1, }) .encode('value', 'size') - .encode('color', (d) => d.parent.data.name.split('.')[1]) .scale('color', { range: schemeTableau10 }) .style( 'labelText', diff --git a/site/examples/general/sunburst/demo/sunburst-interaction.ts b/site/examples/general/sunburst/demo/sunburst-interaction.ts index c926c496e5..66b4618ae4 100644 --- a/site/examples/general/sunburst/demo/sunburst-interaction.ts +++ b/site/examples/general/sunburst/demo/sunburst-interaction.ts @@ -24,7 +24,7 @@ chart ], }) .interaction({ - drillDown: { + treemapDrillDown: { breadCrumb: { rootText: '起始', style: { @@ -35,7 +35,7 @@ chart fill: 'red', }, }, - // FixedColor default: true, true -> drillDown update scale, false -> scale keep. + // FixedColor default: true, true -> treemapDrillDown update scale, false -> scale keep. fixedColor: false, }, }) diff --git a/site/examples/graph/hierarchy/demo/meta.json b/site/examples/graph/hierarchy/demo/meta.json index c85426ee75..7efb9f0c99 100644 --- a/site/examples/graph/hierarchy/demo/meta.json +++ b/site/examples/graph/hierarchy/demo/meta.json @@ -12,6 +12,14 @@ }, "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*BD2zQIr7D5MAAAAAAAAAAAAADmJ7AQ/original" }, + { + "filename": "treemap-drill-down.ts", + "title": { + "zh": "矩阵树图下钻", + "en": "Treemap DrillDown" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*FwWXQqqpc_UAAAAAAAAAAAAADmJ7AQ/original" + }, { "filename": "circle-packing.ts", "title": { diff --git a/site/examples/graph/hierarchy/demo/treemap-drill-down.ts b/site/examples/graph/hierarchy/demo/treemap-drill-down.ts new file mode 100644 index 0000000000..7d92f4b6e9 --- /dev/null +++ b/site/examples/graph/hierarchy/demo/treemap-drill-down.ts @@ -0,0 +1,79 @@ +import { Chart } from '@antv/g2'; + +const chart = new Chart({ + container: 'container', + width: 600, + height: 400, +}); + +const data = { + name: '商品', + children: [ + { + name: '文具', + children: [ + { + name: '笔', + children: [ + { name: '铅笔', value: 430 }, + { name: '圆珠笔', value: 530 }, + { name: '钢笔', value: 80 }, + { name: '水彩', value: 130 }, + ], + }, + { name: '铅笔盒', value: 30 }, + { name: '直尺', value: 60 }, + { name: '笔记本', value: 160 }, + { name: '其他', value: 80 }, + ], + }, + { + name: '零食', + children: [ + { name: '饼干', value: 280 }, + { name: '辣条', value: 150 }, + { name: '牛奶糖', value: 210 }, + { name: '泡泡糖', value: 80 }, + { + name: '饮品', + children: [ + { name: '可乐', value: 122 }, + { name: '矿泉水', value: 244 }, + { name: '果汁', value: 49 }, + { name: '牛奶', value: 82 }, + ], + }, + { name: '其他', value: 40 }, + ], + }, + { name: '其他', value: 450 }, + ], +}; + +chart + .treemap() + .data({ + value: data, + }) + .layout({ + tile: 'treemapBinary', + paddingInner: 5, + }) + .encode('value', 'value') + .interaction({ + treemapDrillDown: { + breadCrumbY: 12, + activeFill: '#873bf4', + }, + }) + .style({ + labelFill: '#000', + labelStroke: '#fff', + labelLineWidth: 1.5, + labelFontSize: 14, + labelPosition: 'top-left', + labelDx: 5, + labelDy: 5, + }); + +chart.render(); diff --git a/site/examples/graph/hierarchy/demo/treemap.ts b/site/examples/graph/hierarchy/demo/treemap.ts index 64e4dbafec..530ac30323 100644 --- a/site/examples/graph/hierarchy/demo/treemap.ts +++ b/site/examples/graph/hierarchy/demo/treemap.ts @@ -19,7 +19,6 @@ chart paddingInner: 1, }) .encode('value', 'size') - .encode('color', (d) => d.parent.data.name.split('.')[1]) .scale('color', { range: schemeTableau10 }) .style( 'labelText', diff --git a/src/interaction/index.ts b/src/interaction/index.ts index b0b1ed3eb4..43b4631cc8 100644 --- a/src/interaction/index.ts +++ b/src/interaction/index.ts @@ -20,3 +20,4 @@ export { SliderFilter } from './sliderFilter'; export { ScrollbarFilter } from './scrollbarFilter'; export { Poptip } from './poptip'; export { Event } from './event'; +export { TreemapDrillDown } from './treemapDrillDown'; diff --git a/src/interaction/legendFilter.ts b/src/interaction/legendFilter.ts index 742c892c87..a7000179ee 100644 --- a/src/interaction/legendFilter.ts +++ b/src/interaction/legendFilter.ts @@ -33,6 +33,14 @@ export function legendsContinuousOf(root) { return root.getElementsByClassName(CONTINUOUS_LEGEND_CLASS_NAME); } +export function legendClearSetState(root, setState) { + const legends = [...legendsOf(root), ...legendsContinuousOf(root)]; + + legends.forEach((legend) => { + setState(legend, (v) => v); + }); +} + export function dataOf(root) { // legend -> layout -> container let parent = root.parentNode; diff --git a/src/interaction/treemapDrillDown.ts b/src/interaction/treemapDrillDown.ts new file mode 100644 index 0000000000..7812183a99 --- /dev/null +++ b/src/interaction/treemapDrillDown.ts @@ -0,0 +1,282 @@ +import { Text, Group } from '@antv/g'; +import { get, deepMix, pick, keys, find, size, last } from '@antv/util'; +import type { Node } from 'd3-hierarchy'; +import type { DisplayObject } from '@antv/g'; +import { subObject } from '../utils/helper'; +import { PLOT_CLASS_NAME } from '../runtime'; +import { select } from '../utils/selection'; +import { treeDataTransform } from '../utils/treeDataTransform'; +import { legendClearSetState } from './legendFilter'; + +// Get element. +const getElements = (plot) => { + return plot.querySelectorAll('.element'); +}; + +function selectPlotArea(root: DisplayObject): DisplayObject { + return select(root).select(`.${PLOT_CLASS_NAME}`).node(); +} + +export type DrillDownOptions = { + originData?: Node[]; + layout?: any; + [key: string]: any; +}; + +// Default breadCrumb config. +const DEFAULT_BREADCRUMB_STYLE = { + breadCrumbFill: 'rgba(0, 0, 0, 0.85)', + breadCrumbFontSize: 12, + breadCrumbY: 12, + activeFill: 'rgba(0, 0, 0, 0.5)', +}; + +/** + * TreemapDrillDown interaction. + */ +export function TreemapDrillDown(drillDownOptions: DrillDownOptions = {}) { + const { originData = [], layout, ...style } = drillDownOptions; + + const breadCrumb = deepMix({}, DEFAULT_BREADCRUMB_STYLE, style); + + const breadCrumbStyle = subObject(breadCrumb, 'breadCrumb'); + const breadCrumbActiveStyle = subObject(breadCrumb, 'active'); + + return (context) => { + const { update, setState, container, options } = context; + const plotArea = selectPlotArea(container); + const mark = options.marks[0]; + + const { state } = mark; + + // Create breadCrumbTextsGroup,save textSeparator、drillTexts. + const textGroup = new Group(); + plotArea.appendChild(textGroup); + + // Modify the data and scale according to the path and the level of the current click, so as to achieve the effect of drilling down and drilling up and initialization. + const drillDownClick = async (path: string[], depth: number) => { + // Clear text. + textGroup.removeChildren(); + + // More path creation text. + if (depth) { + let name = ''; + let y = breadCrumbStyle.y; + let x = 0; + const textPath = []; + + const maxWidth = plotArea.getBBox().width; + + // Create path: 'type1 / type2 / type3' -> '/ type1 / type2 / type3'. + const drillTexts = path.map((text, index) => { + name = `${name}${text}/`; + textPath.push(text); + const drillText = new Text({ + name: name.replace(/\/$/, ''), + style: { + text, + x, + // @ts-ignore + path: [...textPath], + depth: index, + ...breadCrumbStyle, + y, + }, + }); + + textGroup.appendChild(drillText); + + x += drillText.getBBox().width; + + const textSeparator = new Text({ + style: { + x, + text: ' / ', + ...breadCrumbStyle, + y, + }, + }); + + textGroup.appendChild(textSeparator); + + x += textSeparator.getBBox().width; + + /** + * Page width exceeds maximum, line feed. + * | ----maxWidth---- | + * | / tyep1 / tyep2 / type3 | + * -> + * | ----maxWidth---- | + * | / tyep1 / tyep2 | + * | / type3 | + */ + if (x > maxWidth) { + y = textGroup.getBBox().height + breadCrumbStyle.y; + x = 0; + drillText.attr({ + x, + y, + }); + x += drillText.getBBox().width; + textSeparator.attr({ + x, + y, + }); + x += textSeparator.getBBox().width; + } + + if (index === size(path) - 1) { + textSeparator.remove(); + } + + return drillText; + }); + + // Add Active, Add TreemapDrillDown + drillTexts.forEach((item, index) => { + // Last drillText + if (index === size(drillTexts) - 1) return; + const originalAttrs = { ...item.attributes }; + item.attr('cursor', 'pointer'); + item.addEventListener('mouseenter', () => { + item.attr(breadCrumbActiveStyle); + }); + item.addEventListener('mouseleave', () => { + item.attr(originalAttrs); + }); + item.addEventListener('click', () => { + drillDownClick( + get(item, ['style', 'path']), + get(item, ['style', 'depth']), + ); + }); + }); + } + + // LegendFilter interaction and treemapDrillDown clash. + legendClearSetState(container, setState); + + // Update marks. + setState('treemapDrillDown', (viewOptions) => { + const { marks } = viewOptions; + // Add filter transform for every marks, + // which will skip for mark without color channel. + + const strPath = path.join('/'); + + const newMarks = marks.map((mark) => { + if (mark.type !== 'rect') return mark; + let newData = originData; + + if (depth) { + const filterData = originData + .filter((item) => { + const id = get(item, ['id']); + return id && (id.match(`${strPath}/`) || strPath.match(id)); + }) + .map((item) => ({ + value: item.height === 0 ? get(item, ['value']) : undefined, + name: get(item, ['id']), + })); + + const { paddingLeft, paddingBottom, paddingRight } = layout; + + // New drill layout for calculation x y and filtration data. + const newLayout = { + ...layout, + paddingTop: + (layout.paddingTop || textGroup.getBBox().height + 10) / + (depth + 1), + paddingLeft: paddingLeft / (depth + 1), + paddingBottom: paddingBottom / (depth + 1), + paddingRight: paddingRight / (depth + 1), + path: (d) => d.name, + layer: (d) => d.depth === depth + 1, + }; + + // Transform the new matrix tree data. + newData = treeDataTransform(filterData, newLayout, { + value: 'value', + })[0]; + } else { + newData = originData.filter((item) => { + return item.depth === 1; + }); + } + + const colorDomain = []; + newData.forEach(({ path }) => { + colorDomain.push(last(path)); + }); + + // TreemapDrillDown by filtering the data and scale. + return deepMix({}, mark, { + data: newData, + scale: { + color: { domain: colorDomain }, + }, + }); + }); + + return { ...viewOptions, marks: newMarks }; + }); + + // The second argument is to allow the legendFilter event to be re-added; the update method itself causes legend to lose the interaction event. + await update(undefined, ['legendFilter']); + }; + + // Elements and BreadCrumb click. + const createDrillClick = (e) => { + const item = e.target; + if (get(item, ['markType']) !== 'rect') return; + + const key = get(item, ['__data__', 'key']); + const node = find(originData, (d) => d.id === key); + + // Node height = 0 no children + if (get(node, 'height')) { + drillDownClick(get(node, 'path'), get(node, 'depth')); + } + }; + + // Add click drill interaction. + plotArea.addEventListener('click', createDrillClick); + + // Change attributes keys. + const changeStyleKey = keys({ ...state.active, ...state.inactive }); + + const createActive = () => { + const elements = getElements(plotArea); + elements.forEach((element) => { + const cursor = get(element, ['style', 'cursor']); + const node = find( + originData, + (d) => d.id === get(element, ['__data__', 'key']), + ); + + if (cursor !== 'pointer' && node?.height) { + element.style.cursor = 'pointer'; + const originalAttrs = pick(element.attributes, changeStyleKey); + + element.addEventListener('mouseenter', () => { + element.attr(state.active); + }); + + element.addEventListener('mouseleave', () => { + element.attr(deepMix(originalAttrs, state.inactive)); + }); + } + }); + }; + + createActive(); + // Animate elements update, Add active. + plotArea.addEventListener('mousemove', createActive); + + return () => { + textGroup.remove(); + plotArea.removeEventListener('click', createDrillClick); + plotArea.removeEventListener('mousemove', createActive); + }; + }; +} diff --git a/src/lib/core.ts b/src/lib/core.ts index fd0ed00339..afb9bba9e0 100644 --- a/src/lib/core.ts +++ b/src/lib/core.ts @@ -100,6 +100,7 @@ import { SliderFilter, Poptip, ScrollbarFilter, + TreemapDrillDown, } from '../interaction'; import { SpaceLayer, @@ -299,6 +300,7 @@ export function corelib() { 'interaction.sliderFilter': SliderFilter, 'interaction.scrollbarFilter': ScrollbarFilter, 'interaction.poptip': Poptip, + 'interaction.treemapDrillDown': TreemapDrillDown, 'composition.spaceLayer': SpaceLayer, 'composition.spaceFlex': SpaceFlex, 'composition.facetRect': FacetRect, diff --git a/src/mark/treemap.ts b/src/mark/treemap.ts index e0b8a45ec0..1c5bc6edf4 100644 --- a/src/mark/treemap.ts +++ b/src/mark/treemap.ts @@ -1,111 +1,12 @@ -import { deepMix } from '@antv/util'; -import { - treemap as treemapLayout, - treemapBinary, - treemapDice, - treemapSlice, - treemapSliceDice, - treemapSquarify, - treemapResquarify, -} from 'd3-hierarchy'; +import { deepMix, get, last } from '@antv/util'; import { subObject } from '../utils/helper'; import { CompositionComponent as CC } from '../runtime'; import { TreemapMark } from '../spec'; import { maybeTooltip } from '../utils/mark'; -import { generateHierarchyRoot, field } from './utils'; +import { treeDataTransform } from '../utils/treeDataTransform'; export type TreemapOptions = Omit; -type Layout = { - tile?: - | 'treemapBinary' - | 'treemapDice' - | 'treemapSlice' - | 'treemapSliceDice' - | 'treemapSquarify' - | 'treemapResquarify'; - size?: [number, number]; - round?: boolean; - // Ignore the value of the parent node when calculating the total value. - ignoreParentValue?: boolean; - ratio?: number; - padding?: number; - paddingInner?: number; - paddingOuter?: number; - paddingTop?: number; - paddingRight?: number; - paddingBottom?: number; - paddingLeft?: number; - sort?(a: any, b: any): number; - path?: (d: any) => any; - /** The granularity of Display layer. */ - layer?: number | ((d: any) => any); -}; - -type TreemapData = { - name: string; - children: TreemapData[]; - [key: string]: any; -}[]; - -function getTileMethod(tile: string, ratio: number) { - const tiles = { - treemapBinary, - treemapDice, - treemapSlice, - treemapSliceDice, - treemapSquarify, - treemapResquarify, - }; - const tileMethod = - tile === 'treemapSquarify' ? tiles[tile].ratio(ratio) : tiles[tile]; - if (!tileMethod) { - throw new TypeError('Invalid tile method!'); - } - return tileMethod; -} - -function dataTransform(data, layout: Layout, encode): TreemapData { - const { value } = encode; - const tileMethod = getTileMethod(layout.tile, layout.ratio); - const root = generateHierarchyRoot(data, layout.path); - - // Calculate the value and sort. - value - ? root - .sum((d) => - layout.ignoreParentValue && d.children ? 0 : field(value)(d), - ) - .sort(layout.sort) - : root.count(); - - treemapLayout() - .tile(tileMethod) - // @ts-ignore - .size(layout.size) - .round(layout.round) - .paddingInner(layout.paddingInner) - .paddingOuter(layout.paddingOuter) - .paddingTop(layout.paddingTop) - .paddingRight(layout.paddingRight) - .paddingBottom(layout.paddingBottom) - .paddingLeft(layout.paddingLeft)(root); - - return root - .descendants() - .map((d) => - Object.assign(d, { - x: [d.x0, d.x1], - y: [d.y0, d.y1], - }), - ) - .filter( - typeof layout.layer === 'function' - ? layout.layer - : (d) => d.height === layout.layer, - ); -} - // Defaults const GET_DEFAULT_LAYOUT_OPTIONS = (width, height) => ({ tile: 'treemapSquarify', @@ -130,7 +31,8 @@ const GET_DEFAULT_OPTIONS = (width, height) => ({ encode: { x: 'x', y: 'y', - color: (d) => d.data.parent.name, + key: 'id', + color: (d) => d.path[1], }, scale: { x: { domain: [0, width], range: [0, 1] }, @@ -139,11 +41,15 @@ const GET_DEFAULT_OPTIONS = (width, height) => ({ style: { stroke: '#fff', }, + state: { + active: { opacity: 0.6 }, + inactive: { opacity: 1 }, + }, }); const DEFAULT_LABEL_OPTIONS = { fontSize: 10, - text: (d) => d.data.name, + text: (d) => last(d.path), position: 'inside', fill: '#000', textOverflow: 'clip', @@ -153,12 +59,17 @@ const DEFAULT_LABEL_OPTIONS = { }; const DEFAULT_TOOLTIP_OPTIONS = { - title: (d) => d.data.name, + title: (d) => d.path?.join?.('.'), + items: [{ field: 'value' }], +}; + +const DEFAULT_TOOLTIP_OPTIONS_DRILL = { + title: (d) => last(d.path), items: [{ field: 'value' }], }; export const Treemap: CC = (options, context) => { - const { width, height } = context; + const { width, height, options: markOptions } = context; const { data, @@ -171,32 +82,74 @@ export const Treemap: CC = (options, context) => { ...resOptions } = options; + const treemapDrillDown = get(markOptions, [ + 'interaction', + 'treemapDrillDown', + ]); + + // Layout + const layoutOptions = deepMix( + {}, + GET_DEFAULT_LAYOUT_OPTIONS(width, height), + layout, + { + layer: treemapDrillDown + ? (d) => { + return d.depth === 1; + } + : layout.layer, + }, + ); + // Data - const transformedData = dataTransform( + const [transformedData, transformedDataAll] = treeDataTransform( data, - deepMix({}, GET_DEFAULT_LAYOUT_OPTIONS(width, height), layout), + layoutOptions, encode, ); // Label const labelStyle = subObject(style, 'label'); - return deepMix({}, GET_DEFAULT_OPTIONS(width, height), { - data: transformedData, - encode, - scale, - style, - labels: [ - { - ...DEFAULT_LABEL_OPTIONS, - ...labelStyle, - }, - ...labels, - ], - ...resOptions, - tooltip: maybeTooltip(tooltip, DEFAULT_TOOLTIP_OPTIONS), - axis: false, - }); + return deepMix( + {}, + GET_DEFAULT_OPTIONS(width, height), + { + data: transformedData, + scale, + style, + labels: [ + { + ...DEFAULT_LABEL_OPTIONS, + ...labelStyle, + }, + ...labels, + ], + ...resOptions, + encode, + tooltip: maybeTooltip(tooltip, DEFAULT_TOOLTIP_OPTIONS), + axis: false, + }, + treemapDrillDown + ? { + interaction: { + ...resOptions.interaction, + treemapDrillDown: treemapDrillDown + ? { + ...treemapDrillDown, + originData: transformedDataAll, + layout: layoutOptions, + } + : undefined, + }, + encode: { + color: (d) => last(d.path), + ...encode, + }, + tooltip: maybeTooltip(tooltip, DEFAULT_TOOLTIP_OPTIONS_DRILL), + } + : {}, + ); }; Treemap.props = {}; diff --git a/src/mark/utils.ts b/src/mark/utils.ts index 9efdb1c238..a91bb873bb 100644 --- a/src/mark/utils.ts +++ b/src/mark/utils.ts @@ -1,5 +1,4 @@ import { Band } from '@antv/scale'; -import { stratify, hierarchy } from 'd3-hierarchy'; import { Primitive } from 'd3-array'; import { Vector2 } from '@antv/coord'; import { Scale } from '../runtime/types/component'; @@ -156,19 +155,3 @@ export function initializeData( nodes: nodes || Array.from(new Set([...LS, ...LT]), (key) => ({ key })), }; } - -/** - * @description Path need when the data is a flat json structure, - * and the tree object structure do not need. - */ -export function generateHierarchyRoot( - data: any[] | Record, - path: (d: any) => any, -) { - if (Array.isArray(data)) { - return typeof path === 'function' - ? stratify().path(path)(data) - : stratify()(data); - } - return hierarchy(data); -} diff --git a/src/runtime/plot.ts b/src/runtime/plot.ts index f63f2fbd95..905e1bac42 100644 --- a/src/runtime/plot.ts +++ b/src/runtime/plot.ts @@ -1,7 +1,7 @@ import { Vector2 } from '@antv/coord'; import { DisplayObject, IAnimation as GAnimation, Rect } from '@antv/g'; -import { deepMix, get, upperFirst } from '@antv/util'; -import { group, groups, sort } from 'd3-array'; +import { deepMix, upperFirst, isArray } from '@antv/util'; +import { group, groups } from 'd3-array'; import { format } from 'd3-format'; import { mapObject } from '../utils/array'; import { ChartEvent } from '../utils/event'; @@ -90,6 +90,8 @@ import { G2ViewTree, } from './types/options'; +type Store = Map G2ViewTree>; + export async function plot( options: T, selection: Selection, @@ -242,12 +244,19 @@ export async function plot( // Apply interactions. const viewInstanceof = ( viewContainer: Map, + updateInteractions?: ( + container: Map, + updateTypes?: string[], + store?: Store, + ) => void, + oldStore?: Store, ) => { return Array.from(viewContainer.entries()).map(([view, container]) => { // Index state by component or interaction name, // such as legend, scrollbar, brushFilter. // Each state transform options to another options. - const store = new Map G2ViewTree>(); + const store = + oldStore || new Map G2ViewTree>(); const setState = (key, reducer = (x) => x) => store.set(key, reducer); const options = viewNode.get(view); const update = createUpdateView( @@ -261,18 +270,68 @@ export async function plot( container, options, setState, - update: async (from) => { + update: async (from, updateTypes) => { // Apply all state functions to get new options. const reducer = compose(Array.from(store.values())); const newOptions = reducer(options); - return await update(newOptions, from); + return await update(newOptions, from, () => { + if (isArray(updateTypes)) { + updateInteractions(viewContainer, updateTypes, store); + } + }); }, }; }); }; + const updateInteractions = ( + container = updateContainer, + updateType?: string[], + oldStore?: Map G2ViewTree>, + ) => { + // Interactions for update views. + const updateViewInstances = viewInstanceof( + container, + updateInteractions, + oldStore, + ); + + for (const target of updateViewInstances) { + const { options, container } = target; + const nameInteraction = container['nameInteraction']; + let typeOptions = inferInteraction(options); + + if (updateType) { + typeOptions = typeOptions.filter((v) => updateType.includes(v[0])); + } + + for (const typeOption of typeOptions) { + const [type, option] = typeOption; + // Remove interaction for existed views. + const prevInteraction = nameInteraction.get(type); + if (prevInteraction) prevInteraction.destroy?.(); + + // Apply new interaction. + if (option) { + const interaction = useThemeInteraction( + target.view, + type, + option as Record, + useInteraction, + ); + const destroy = interaction( + target, + updateViewInstances, + context.emitter, + ); + nameInteraction.set(type, { destroy }); + } + } + } + }; + // Interactions for enter views. - const enterViewInstances = viewInstanceof(enterContainer); + const enterViewInstances = viewInstanceof(enterContainer, updateInteractions); for (const target of enterViewInstances) { const { options } = target; @@ -300,35 +359,7 @@ export async function plot( } } - // Interactions for update views. - const updateViewInstances = viewInstanceof(updateContainer); - for (const target of updateViewInstances) { - const { options, container } = target; - const nameInteraction = container['nameInteraction']; - for (const typeOption of inferInteraction(options)) { - const [type, option] = typeOption; - - // Remove interaction for existed views. - const prevInteraction = nameInteraction.get(type); - if (prevInteraction) prevInteraction.destroy?.(); - - // Apply new interaction. - if (option) { - const interaction = useThemeInteraction( - target.view, - type, - option as Record, - useInteraction, - ); - const destroy = interaction( - target, - updateViewInstances, - context.emitter, - ); - nameInteraction.set(type, { destroy }); - } - } - } + updateInteractions(); // Author animations. const { width, height } = options; @@ -403,7 +434,7 @@ function createUpdateView( .filter(filter) .map((d) => d[0]); - return async (newOptions, source) => { + return async (newOptions, source, callback) => { const transitions = []; const [newView, newChildren] = await initializeView(newOptions, library); plotView(newView, selection, transitions, library, context); @@ -416,7 +447,7 @@ function createUpdateView( for (const child of newChildren) { plot(child, selection, library, context); } - + callback(); return { options: newOptions, view: newView }; }; } diff --git a/src/runtime/types/common.ts b/src/runtime/types/common.ts index d2d8282c1e..866b40662e 100644 --- a/src/runtime/types/common.ts +++ b/src/runtime/types/common.ts @@ -53,7 +53,11 @@ export type G2ViewInstance = { view: G2ViewDescriptor; container: DisplayObject; options: G2ViewTree; - update: (options: G2ViewTree, source?: string) => Promise; + update: ( + options: G2ViewTree, + source?: string | string[], + callback?: any, + ) => Promise; }; export type ChannelGroups = { diff --git a/src/utils/treeDataTransform.ts b/src/utils/treeDataTransform.ts new file mode 100644 index 0000000000..a8767e5aea --- /dev/null +++ b/src/utils/treeDataTransform.ts @@ -0,0 +1,153 @@ +import { isArray, get } from '@antv/util'; +import { + stratify, + hierarchy, + treemap as treemapLayout, + treemapBinary, + treemapDice, + treemapSlice, + treemapSliceDice, + treemapSquarify, + treemapResquarify, +} from 'd3-hierarchy'; +import type { Node } from 'd3-hierarchy'; +import { field } from '../mark/utils'; + +type Layout = { + tile?: + | 'treemapBinary' + | 'treemapDice' + | 'treemapSlice' + | 'treemapSliceDice' + | 'treemapSquarify' + | 'treemapResquarify'; + size?: [number, number]; + round?: boolean; + // Ignore the value of the parent node when calculating the total value. + ignoreParentValue?: boolean; + ratio?: number; + padding?: number; + paddingInner?: number; + paddingOuter?: number; + paddingTop?: number; + paddingRight?: number; + paddingBottom?: number; + paddingLeft?: number; + sort?(a: any, b: any): number; + path?: (d: any) => any; + /** The granularity of Display layer. */ + layer?: number | ((d: any) => any); +}; + +/** + * @description Path need when the data is a flat json structure, + * and the tree object structure do not need. + */ +function generateHierarchyRoot( + data: any[] | Record, + path: (d: any) => any, +): Node { + if (Array.isArray(data)) { + return typeof path === 'function' + ? stratify().path(path)(data) + : stratify()(data); + } + return hierarchy(data); +} + +function addObjectDataPath(root: Node, path = [root.data.name]) { + root.id = root.id || root.data.name; + root.path = path; + + if (root.children) { + root.children.forEach((item) => { + item.id = `${root.id}/${item.data.name}`; + item.path = [...path, item.data.name]; + addObjectDataPath(item, item.path); + }); + } +} + +function addArrayDataPath(root: Node) { + const name = get(root, ['data', 'name']); + if (name.replaceAll) { + root.path = name.replaceAll('.', '/').split('/'); + } + + if (root.children) { + root.children.forEach((item) => { + addArrayDataPath(item); + }); + } +} + +function getTileMethod(tile: string, ratio: number) { + const tiles = { + treemapBinary, + treemapDice, + treemapSlice, + treemapSliceDice, + treemapSquarify, + treemapResquarify, + }; + const tileMethod = + tile === 'treemapSquarify' ? tiles[tile].ratio(ratio) : tiles[tile]; + if (!tileMethod) { + throw new TypeError('Invalid tile method!'); + } + return tileMethod; +} + +export function treeDataTransform( + data, + layout: Layout, + encode, +): [Node[], Node[]] { + const { value } = encode; + const tileMethod = getTileMethod(layout.tile, layout.ratio); + + const root = generateHierarchyRoot(data, layout.path); + + if (isArray(data)) { + addArrayDataPath(root); + } else { + addObjectDataPath(root); + } + + // Calculate the value and sort. + value + ? root + .sum((d) => + layout.ignoreParentValue && d.children ? 0 : field(value)(d), + ) + .sort(layout.sort) + : root.count(); + + treemapLayout() + .tile(tileMethod) + // @ts-ignore + .size(layout.size) + .round(layout.round) + .paddingInner(layout.paddingInner) + .paddingOuter(layout.paddingOuter) + .paddingTop(layout.paddingTop) + .paddingRight(layout.paddingRight) + .paddingBottom(layout.paddingBottom) + .paddingLeft(layout.paddingLeft)(root); + + const nodes = root.descendants().map((d) => + Object.assign(d, { + id: d.id.replace(/^\//, ''), + x: [d.x0, d.x1], + y: [d.y0, d.y1], + }), + ); + + const filterData = nodes.filter( + typeof layout.layer === 'function' + ? layout.layer + : (d) => d.height === layout.layer, + ); + + return [filterData, nodes]; +}