diff --git a/.playground/index.html b/.playground/index.html index 6d829f699f..3973665311 100644 --- a/.playground/index.html +++ b/.playground/index.html @@ -29,8 +29,8 @@ /*display: inline-block; position: relative; */ - width: 800px; - height: 400px; + width: 300px; + height: 300px; margin: 20px; } diff --git a/.playground/playground.tsx b/.playground/playground.tsx index 7d1e4f864f..3a170dc677 100644 --- a/.playground/playground.tsx +++ b/.playground/playground.tsx @@ -17,10 +17,43 @@ * under the License. */ import React from 'react'; -import { example } from '../stories/sunburst/12_very_small'; +import { Chart, Partition, Settings, PartitionLayout, XYChartElementEvent, PartitionElementEvent } from '../src'; export class Playground extends React.Component { + onElementClick = (elements: (XYChartElementEvent | PartitionElementEvent)[]) => { + // eslint-disable-next-line no-console + console.log(elements); + }; render() { - return
{example()}
; + return ( +
+ + + { + return d.v; + }} + data={[ + { g1: 'a', g2: 'a', v: 1 }, + { g1: 'a', g2: 'b', v: 1 }, + { g1: 'b', g2: 'a', v: 1 }, + { g1: 'b', g2: 'b', v: 1 }, + ]} + layers={[ + { + groupByRollup: (datum: { g1: string }) => datum.g1, + }, + { + groupByRollup: (datum: { g2: string }) => datum.g2, + }, + ]} + /> + +
+ ); } } diff --git a/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts b/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts index 80655316ae..9f48c27c48 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts @@ -202,27 +202,9 @@ export function shapeViewModel( const innerRadius: Radius = outerRadius - (1 - emptySizeRatio) * outerRadius; const treeHeight = shownChildNodes.reduce((p: number, n: any) => Math.max(p, entryValue(n.node).depth), 0); // 1: pie, 2: two-ring donut etc. const ringThickness = (outerRadius - innerRadius) / treeHeight; - + const partToShapeFn = partToShapeTreeNode(treemapLayout, innerRadius, ringThickness); const quadViewModel = makeQuadViewModel( - shownChildNodes.slice(1).map( - (n: Part): ShapeTreeNode => { - const node: ArrayEntry = n.node; - return { - dataName: entryKey(node), - depth: depthAccessor(node), - value: aggregateAccessor(node), - parent: parentAccessor(node), - sortIndex: sortIndexAccessor(node), - x0: n.x0, - x1: n.x1, - y0: n.y0, - y1: n.y1, - y0px: treemapLayout ? n.y0 : innerRadius + n.y0 * ringThickness, - y1px: treemapLayout ? n.y1 : innerRadius + n.y1 * ringThickness, - yMidPx: treemapLayout ? (n.y0 + n.y1) / 2 : innerRadius + ((n.y0 + n.y1) / 2) * ringThickness, - }; - }, - ), + shownChildNodes.slice(1).map(partToShapeFn), layers, config.sectorLineWidth, config.sectorLineStroke, @@ -308,3 +290,23 @@ export function shapeViewModel( outerRadius, }; } + +function partToShapeTreeNode(treemapLayout: boolean, innerRadius: Radius, ringThickness: number) { + return (n: Part): ShapeTreeNode => { + const node: ArrayEntry = n.node; + return { + dataName: entryKey(node), + depth: depthAccessor(node), + value: aggregateAccessor(node), + parent: parentAccessor(node), + sortIndex: sortIndexAccessor(node), + x0: n.x0, + x1: n.x1, + y0: n.y0, + y1: n.y1, + y0px: treemapLayout ? n.y0 : innerRadius + n.y0 * ringThickness, + y1px: treemapLayout ? n.y1 : innerRadius + n.y1 * ringThickness, + yMidPx: treemapLayout ? (n.y0 + n.y1) / 2 : innerRadius + ((n.y0 + n.y1) / 2) * ringThickness, + }; + }; +} diff --git a/src/chart_types/partition_chart/state/selectors/picked_shapes.test.ts b/src/chart_types/partition_chart/state/selectors/picked_shapes.test.ts new file mode 100644 index 0000000000..25bb500631 --- /dev/null +++ b/src/chart_types/partition_chart/state/selectors/picked_shapes.test.ts @@ -0,0 +1,140 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ + +import { chartStoreReducer, GlobalChartState } from '../../../../state/chart_state'; +import { createStore, Store } from 'redux'; +import { PartitionSpec } from '../../specs'; +import { upsertSpec, specParsed, specParsing } from '../../../../state/actions/specs'; +import { MockGlobalSpec, MockSeriesSpec } from '../../../../mocks/specs'; +import { updateParentDimensions } from '../../../../state/actions/chart_settings'; +import { partitionGeometries } from './geometries'; +import { onMouseDown, onMouseUp, onPointerMove } from '../../../../state/actions/mouse'; +import { createOnElementClickCaller } from './on_element_click_caller'; +import { SettingsSpec, XYChartElementEvent, PartitionElementEvent } from '../../../../specs'; + +describe('Picked shapes selector', () => { + function initStore() { + const storeReducer = chartStoreReducer('chartId'); + return createStore(storeReducer); + } + function addSeries(store: Store, spec: PartitionSpec, settings?: Partial) { + store.dispatch(specParsing()); + store.dispatch(upsertSpec(MockGlobalSpec.settings(settings))); + store.dispatch(upsertSpec(spec)); + store.dispatch(specParsed()); + store.dispatch(updateParentDimensions({ width: 300, height: 300, top: 0, left: 0 })); + } + let store: Store; + let treemapSpec: PartitionSpec; + let sunburstSpec: PartitionSpec; + beforeEach(() => { + store = initStore(); + const common = { + valueAccessor: (d: { v: number }) => { + return d.v; + }, + data: [ + { g1: 'a', g2: 'a', v: 1 }, + { g1: 'a', g2: 'b', v: 1 }, + { g1: 'b', g2: 'a', v: 1 }, + { g1: 'b', g2: 'b', v: 1 }, + ], + layers: [ + { + groupByRollup: (datum: { g1: string }) => datum.g1, + }, + { + groupByRollup: (datum: { g2: string }) => datum.g2, + }, + ], + }; + treemapSpec = MockSeriesSpec.treemap(common); + sunburstSpec = MockSeriesSpec.sunburst(common); + }); + test('check initial geoms', () => { + addSeries(store, treemapSpec); + const treemapGeometries = partitionGeometries(store.getState()); + expect(treemapGeometries.quadViewModel).toHaveLength(6); + + addSeries(store, sunburstSpec); + const sunburstGeometries = partitionGeometries(store.getState()); + expect(sunburstGeometries.quadViewModel).toHaveLength(6); + }); + test('treemap check picked geometries', () => { + const onClickListener = jest.fn>( + (): undefined => undefined, + ); + addSeries(store, treemapSpec, { + onElementClick: onClickListener, + }); + const geometries = partitionGeometries(store.getState()); + expect(geometries.quadViewModel).toHaveLength(6); + + const onElementClickCaller = createOnElementClickCaller(); + store.subscribe(() => { + onElementClickCaller(store.getState()); + }); + store.dispatch(onPointerMove({ x: 200, y: 200 }, 0)); + store.dispatch(onMouseDown({ x: 200, y: 200 }, 1)); + store.dispatch(onMouseUp({ x: 200, y: 200 }, 2)); + expect(onClickListener).toBeCalled(); + expect(onClickListener.mock.calls[0][0]).toEqual([ + [ + [ + { groupByRollup: 'b', value: 2 }, + { groupByRollup: 'b', value: 1 }, + ], + { + specId: treemapSpec.id, + key: `spec{${treemapSpec.id}}`, + }, + ], + ]); + }); + test('sunburst check picked geometries', () => { + const onClickListener = jest.fn>( + (): undefined => undefined, + ); + addSeries(store, sunburstSpec, { + onElementClick: onClickListener, + }); + const geometries = partitionGeometries(store.getState()); + expect(geometries.quadViewModel).toHaveLength(6); + + const onElementClickCaller = createOnElementClickCaller(); + store.subscribe(() => { + onElementClickCaller(store.getState()); + }); + store.dispatch(onPointerMove({ x: 200, y: 200 }, 0)); + store.dispatch(onMouseDown({ x: 200, y: 200 }, 1)); + store.dispatch(onMouseUp({ x: 200, y: 200 }, 2)); + expect(onClickListener).toBeCalled(); + expect(onClickListener.mock.calls[0][0]).toEqual([ + [ + [ + { groupByRollup: 'b', value: 2 }, + { groupByRollup: 'b', value: 1 }, + ], + { + specId: sunburstSpec.id, + key: `spec{${sunburstSpec.id}}`, + }, + ], + ]); + }); +}); diff --git a/src/chart_types/partition_chart/state/selectors/picked_shapes.ts b/src/chart_types/partition_chart/state/selectors/picked_shapes.ts index ae370676a7..97667f9f07 100644 --- a/src/chart_types/partition_chart/state/selectors/picked_shapes.ts +++ b/src/chart_types/partition_chart/state/selectors/picked_shapes.ts @@ -42,8 +42,17 @@ export const getPickedShapes = createCachedSelector( /** @internal */ export const getPickedShapesLayerValues = createCachedSelector( [getPickedShapes], - (pickedShapes): Array> => { - const elements = pickedShapes.map>((model) => { + pickShapesLayerValues, +)((state) => state.chartId); + +/** @internal */ +export function pickShapesLayerValues(pickedShapes: QuadViewModel[]): Array> { + const maxDepth = pickedShapes.reduce((acc, curr) => { + return Math.max(acc, curr.depth); + }, 0); + const elements = pickedShapes + .filter(({ depth }) => depth === maxDepth) + .map>((model) => { const values: Array = []; values.push({ groupByRollup: model.dataName, @@ -61,6 +70,5 @@ export const getPickedShapesLayerValues = createCachedSelector( } return values.reverse(); }); - return elements; - }, -)((state) => state.chartId); + return elements; +} diff --git a/src/mocks/specs/specs.ts b/src/mocks/specs/specs.ts index 7e67a32f3b..61c9ce515a 100644 --- a/src/mocks/specs/specs.ts +++ b/src/mocks/specs/specs.ts @@ -32,6 +32,12 @@ import { ScaleType } from '../../scales'; import { ChartTypes } from '../../chart_types'; import { SettingsSpec, SpecTypes, TooltipType } from '../../specs'; import { LIGHT_THEME } from '../../utils/themes/light_theme'; +import { PartitionSpec } from '../../chart_types/partition_chart/specs'; +import { config, percentFormatter } from '../../chart_types/partition_chart/layout/config/config'; +import { ShapeTreeNode } from '../../chart_types/partition_chart/layout/types/viewmodel_types'; +import { Datum } from '../../utils/commons'; +import { AGGREGATE_KEY, PrimitiveValue } from '../../chart_types/partition_chart/layout/utils/group_by_rollup'; +import { PartitionLayout } from '../../chart_types/partition_chart/layout/types/config_types'; /** @internal */ export class MockSeriesSpec { @@ -100,6 +106,52 @@ export class MockSeriesSpec { data: [], }; + private static readonly sunburstBase: PartitionSpec = { + chartType: ChartTypes.Partition, + specType: SpecTypes.Series, + id: 'spec1', + config: { + ...config, + partitionLayout: PartitionLayout.sunburst, + }, + valueAccessor: (d: Datum) => (typeof d === 'number' ? d : 0), + valueGetter: (n: ShapeTreeNode): number => n[AGGREGATE_KEY], + valueFormatter: (d: number): string => String(d), + percentFormatter, + layers: [ + { + groupByRollup: (d: Datum, i: number) => i, + nodeLabel: (d: PrimitiveValue) => String(d), + showAccessor: () => true, + fillLabel: {}, + }, + ], + data: [], + }; + + private static readonly treemapBase: PartitionSpec = { + chartType: ChartTypes.Partition, + specType: SpecTypes.Series, + id: 'spec1', + config: { + ...config, + partitionLayout: PartitionLayout.treemap, + }, + valueAccessor: (d: Datum) => (typeof d === 'number' ? d : 0), + valueGetter: (n: ShapeTreeNode): number => n[AGGREGATE_KEY], + valueFormatter: (d: number): string => String(d), + percentFormatter, + layers: [ + { + groupByRollup: (d: Datum, i: number) => i, + nodeLabel: (d: PrimitiveValue) => String(d), + showAccessor: () => true, + fillLabel: {}, + }, + ], + data: [], + }; + static bar(partial?: Partial): BarSeriesSpec { return mergePartial(MockSeriesSpec.barBase, partial as RecursivePartial, { mergeOptionalPartialValues: true, @@ -128,14 +180,25 @@ export class MockSeriesSpec { }); } + static sunburst(partial?: Partial): PartitionSpec { + return mergePartial(MockSeriesSpec.sunburstBase, partial as RecursivePartial, { + mergeOptionalPartialValues: true, + }); + } + + static treemap(partial?: Partial): PartitionSpec { + return mergePartial(MockSeriesSpec.treemapBase, partial as RecursivePartial, { + mergeOptionalPartialValues: true, + }); + } + static byType(type?: 'line' | 'bar' | 'area'): BasicSeriesSpec { switch (type) { case 'line': return MockSeriesSpec.lineBase; - case 'bar': - return MockSeriesSpec.barBase; case 'area': return MockSeriesSpec.areaBase; + case 'bar': default: return MockSeriesSpec.barBase; } diff --git a/stories/interactions/4_sunburst_slice_clicks.tsx b/stories/interactions/4_sunburst_slice_clicks.tsx index 85f759eefe..01a6b084b5 100644 --- a/stories/interactions/4_sunburst_slice_clicks.tsx +++ b/stories/interactions/4_sunburst_slice_clicks.tsx @@ -18,8 +18,14 @@ import { action } from '@storybook/addon-actions'; import React from 'react'; -import { Chart, Position, Settings, Partition } from '../../src'; -import { indexInterpolatedFillColor, interpolatorCET2s } from '../utils/utils'; +import { Chart, Position, Settings, Partition, PartitionLayout } from '../../src'; +import { + indexInterpolatedFillColor, + interpolatorCET2s, + categoricalFillColor, + colorBrewerCategoricalPastel12, +} from '../utils/utils'; +import { select } from '@storybook/addon-knobs'; const onElementListeners = { onElementClick: action('onElementClick'), @@ -47,12 +53,20 @@ const pieData: Array = [ ]; export const example = () => { + const partitionLayout = select( + 'layout', + { sunburst: PartitionLayout.sunburst, treemap: PartitionLayout.treemap }, + 'sunburst', + ); return ( { return d[3]; }} @@ -66,8 +80,12 @@ export const example = () => { }, shape: { fillColor: (d) => { - // pick color from color palette based on mean angle - rather distinct colors in the inner ring - return indexInterpolatedFillColor(interpolatorCET2s)(d, (d.x0 + d.x1) / 2 / (2 * Math.PI), []); + if (partitionLayout === 'sunburst') { + // pick color from color palette based on mean angle - rather distinct colors in the inner ring + return indexInterpolatedFillColor(interpolatorCET2s)(d, (d.x0 + d.x1) / 2 / (2 * Math.PI), []); + } else { + return categoricalFillColor(colorBrewerCategoricalPastel12)(d.sortIndex); + } }, }, }, @@ -80,8 +98,12 @@ export const example = () => { }, shape: { fillColor: (d) => { - // pick color from color palette based on mean angle - rather distinct colors in the inner ring - return indexInterpolatedFillColor(interpolatorCET2s)(d, (d.x0 + d.x1) / 2 / (2 * Math.PI), []); + if (partitionLayout === 'sunburst') { + // pick color from color palette based on mean angle - rather distinct colors in the inner ring + return indexInterpolatedFillColor(interpolatorCET2s)(d, (d.x0 + d.x1) / 2 / (2 * Math.PI), []); + } else { + return categoricalFillColor(colorBrewerCategoricalPastel12)(d.sortIndex); + } }, }, },