Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(partition): drilldown #995

Merged
merged 14 commits into from
Feb 10, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/charts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2020,8 +2020,8 @@ export type YDomainRange = YDomainBase & DomainRange;
// src/chart_types/heatmap/layout/types/config_types.ts:28:13 - (ae-forgotten-export) The symbol "SizeRatio" needs to be exported by the entry point index.d.ts
// src/chart_types/heatmap/layout/types/config_types.ts:60:5 - (ae-forgotten-export) The symbol "TextAlign" needs to be exported by the entry point index.d.ts
// src/chart_types/heatmap/layout/types/config_types.ts:61:5 - (ae-forgotten-export) The symbol "TextBaseline" needs to be exported by the entry point index.d.ts
// src/chart_types/partition_chart/layout/types/config_types.ts:128:5 - (ae-forgotten-export) The symbol "TimeMs" needs to be exported by the entry point index.d.ts
// src/chart_types/partition_chart/layout/types/config_types.ts:129:5 - (ae-forgotten-export) The symbol "AnimKeyframe" needs to be exported by the entry point index.d.ts
// src/chart_types/partition_chart/layout/types/config_types.ts:134:5 - (ae-forgotten-export) The symbol "TimeMs" needs to be exported by the entry point index.d.ts
// src/chart_types/partition_chart/layout/types/config_types.ts:136:5 - (ae-forgotten-export) The symbol "AnimKeyframe" needs to be exported by the entry point index.d.ts
// src/chart_types/partition_chart/specs/index.ts:48:13 - (ae-forgotten-export) The symbol "NodeColorAccessor" needs to be exported by the entry point index.d.ts
// src/common/series_id.ts:40:3 - (ae-forgotten-export) The symbol "SeriesKey" needs to be exported by the entry point index.d.ts

Expand Down
4 changes: 4 additions & 0 deletions src/chart_types/partition_chart/layout/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ export const configMetadata = {
type: 'string',
values: Object.keys(PartitionLayout),
},
drilldown: {
dflt: false,
type: 'boolean',
},

// fill text layout config
circlePadding: { dflt: 2, min: 0.0, max: 8, type: 'number' },
Expand Down
7 changes: 7 additions & 0 deletions src/chart_types/partition_chart/layout/types/config_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export interface StaticConfig extends FillFontSizeRange {
clockwiseSectors: boolean;
specialFirstInnermostSector: boolean;
partitionLayout: PartitionLayout;
/** @alpha */
drilldown: boolean;

// general text config
fontFamily: FontFamily;
Expand Down Expand Up @@ -118,14 +120,19 @@ export interface StaticConfig extends FillFontSizeRange {
export type EasingFunction = (x: Ratio) => Ratio;

export interface AnimKeyframe {
/** @alpha */
markov00 marked this conversation as resolved.
Show resolved Hide resolved
time: number;
/** @alpha */
easingFunction: EasingFunction;
/** @alpha */
keyframeConfig: Partial<StaticConfig>;
}

export interface Config extends StaticConfig {
animation: {
/** @alpha */
duration: TimeMs;
/** @alpha */
keyframes: Array<AnimKeyframe>;
};
}
Expand Down
49 changes: 30 additions & 19 deletions src/chart_types/partition_chart/layout/utils/group_by_rollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,33 +108,44 @@ export function groupByRollup(
identity: () => any;
},
factTable: Relation,
drilldown: boolean,
drilldownSelection: CategoryKey[],
): HierarchyOfMaps {
const statistics: Statistics = {
globalAggregate: NaN,
};
const reductionMap: HierarchyOfMaps = factTable.reduce((p: HierarchyOfMaps, n, index) => {
const keyCount = keyAccessors.length;
let pointer: HierarchyOfMaps = p;
keyAccessors.forEach((keyAccessor, i) => {
const key: Key = keyAccessor(n, index);
const last = i === keyCount - 1;
const node = pointer.get(key);
const inputIndices = node?.[INPUT_KEY] ?? [];
const childrenMap = node?.[CHILDREN_KEY] ?? new Map();
const aggregate = node?.[AGGREGATE_KEY] ?? identity();
const reductionValue = reducer(aggregate, valueAccessor(n));
pointer.set(key, {
[AGGREGATE_KEY]: reductionValue,
[STATISTICS_KEY]: statistics,
[INPUT_KEY]: [...inputIndices, index],
[DEPTH_KEY]: i,
...(!last && { [CHILDREN_KEY]: childrenMap }),
keyAccessors
.filter(
() =>
!drilldown ||
keyAccessors
.slice(0, drilldownSelection.length)
.map((ka) => ka(n, index))
monfera marked this conversation as resolved.
Show resolved Hide resolved
.join(' | ') === drilldownSelection.slice(0, drilldownSelection.length).join(' | '),
markov00 marked this conversation as resolved.
Show resolved Hide resolved
)
.forEach((keyAccessor, i) => {
const key: Key = keyAccessor(n, index);
const last = i === keyCount - 1;
const node = pointer.get(key);
const inputIndices = node?.[INPUT_KEY] ?? [];
const childrenMap = node?.[CHILDREN_KEY] ?? new Map();
const aggregate = node?.[AGGREGATE_KEY] ?? identity();
const reductionValue = reducer(aggregate, valueAccessor(n));
pointer.set(key, {
[AGGREGATE_KEY]: reductionValue,
[STATISTICS_KEY]: statistics,
[INPUT_KEY]: [...inputIndices, index],
[DEPTH_KEY]: i,
...(!last && { [CHILDREN_KEY]: childrenMap }),
});
if (childrenMap) {
// will always be true except when exiting from forEach, ie. upon encountering the leaf node
pointer = childrenMap;
}
});
if (childrenMap) {
// will always be true except when exiting from forEach, ie. upon encountering the leaf node
pointer = childrenMap;
}
});
return p;
}, new Map());
if (reductionMap.get(HIERARCHY_ROOT_KEY) !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const groupByRollupAccessors = [() => null, (d: any) => d.sitc1];

describe('Test', () => {
test('getHierarchyOfArrays should omit zero and negative values', () => {
const outerResult = getHierarchyOfArrays(rawFacts, valueAccessor, groupByRollupAccessors);
const outerResult = getHierarchyOfArrays(rawFacts, valueAccessor, groupByRollupAccessors, null, false, []);
expect(outerResult.length).toBe(1);

const results = outerResult[0];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* under the License.
*/

import { CategoryKey } from '../../../../common/category';
import { IndexedAccessorFn } from '../../../../utils/accessor';
import { ValueAccessor } from '../../../../utils/common';
import { Relation } from '../types/types';
Expand All @@ -36,6 +37,8 @@ export function getHierarchyOfArrays(
valueAccessor: ValueAccessor,
groupByRollupAccessors: IndexedAccessorFn[],
sorter: Sorter | null = childOrders.descending,
drilldown: boolean,
drilldownSelection: CategoryKey[],
): HierarchyOfArrays {
const aggregator = aggregators.sum;

Expand All @@ -53,7 +56,7 @@ export function getHierarchyOfArrays(
// By introducing `scale`, we no longer need to deal with the dichotomy of
// size as data value vs size as number of pixels in the rectangle
return mapsToArrays(
groupByRollup(groupByRollupAccessors, valueAccessor, aggregator, facts),
groupByRollup(groupByRollupAccessors, valueAccessor, aggregator, facts, drilldown, drilldownSelection),
sorter && aggregateComparator(mapEntryValue, sorter),
);
}
20 changes: 15 additions & 5 deletions src/chart_types/partition_chart/state/selectors/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,33 +20,43 @@
import createCachedSelector from 're-reselect';

import { ChartTypes } from '../../..';
import { CategoryKey } from '../../../../common/category';
import { SpecTypes } from '../../../../specs';
import { GlobalChartState } from '../../../../state/chart_state';
import { GlobalChartState, SpecList } from '../../../../state/chart_state';
import { getSpecsFromStore } from '../../../../state/utils';
import { configMetadata } from '../../layout/config/config';
import { childOrders, HierarchyOfArrays, HIERARCHY_ROOT_KEY } from '../../layout/utils/group_by_rollup';
import { childOrders, HIERARCHY_ROOT_KEY, HierarchyOfArrays } from '../../layout/utils/group_by_rollup';
import { getHierarchyOfArrays } from '../../layout/viewmodel/hierarchy_of_arrays';
import { isSunburst, isTreemap } from '../../layout/viewmodel/viewmodel';
import { PartitionSpec } from '../../specs';

const getSpecs = (state: GlobalChartState) => state.specs;

const getDrilldownSelection = (state: GlobalChartState) => state.interactions.drilldown || [];

/** @internal */
export const getTree = createCachedSelector(
[getSpecs],
(specs): HierarchyOfArrays => {
[getSpecs, getDrilldownSelection],
(specs: SpecList, drilldownSelection: CategoryKey[]): HierarchyOfArrays => {
monfera marked this conversation as resolved.
Show resolved Hide resolved
const pieSpecs = getSpecsFromStore<PartitionSpec>(specs, ChartTypes.Partition, SpecTypes.Series);
if (pieSpecs.length !== 1) {
return [];
}
const { data, valueAccessor, layers } = pieSpecs[0];
const {
data,
valueAccessor,
layers,
config: { drilldown },
} = pieSpecs[0];
const layout = pieSpecs[0].config.partitionLayout ?? configMetadata.partitionLayout.dflt;
const sorter = isTreemap(layout) || isSunburst(layout) ? childOrders.descending : null;
return getHierarchyOfArrays(
data,
valueAccessor,
[() => HIERARCHY_ROOT_KEY, ...layers.map(({ groupByRollup }) => groupByRollup)],
sorter,
Boolean(drilldown),
drilldownSelection,
);
},
)((state) => state.chartId);
5 changes: 4 additions & 1 deletion src/state/chart_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { HeatmapState } from '../chart_types/heatmap/state/chart_state';
import { PrimitiveValue } from '../chart_types/partition_chart/layout/utils/group_by_rollup';
import { PartitionState } from '../chart_types/partition_chart/state/chart_state';
import { XYAxisChartState } from '../chart_types/xy_chart/state/chart_state';
import { CategoryKey } from '../common/category';
import { LegendItem, LegendItemExtraValues } from '../common/legend';
import { SeriesIdentifier, SeriesKey } from '../common/series_id';
import { TooltipAnchorPosition, TooltipInfo } from '../components/tooltip/types';
Expand Down Expand Up @@ -186,6 +187,7 @@ export interface InteractionsState {
highlightedLegendPath: LegendPath;
deselectedDataSeries: SeriesIdentifier[];
hoveredDOMElement: DOMElement | null;
drilldown: CategoryKey[];
markov00 marked this conversation as resolved.
Show resolved Hide resolved
}

/** @internal */
Expand Down Expand Up @@ -275,6 +277,7 @@ export const getInitialState = (chartId: string): GlobalChartState => ({
highlightedLegendPath: [],
deselectedDataSeries: [],
hoveredDOMElement: null,
drilldown: [],
},
externalEvents: {
pointer: null,
Expand Down Expand Up @@ -391,7 +394,7 @@ export const chartStoreReducer = (chartId: string) => {
return getInternalIsInitializedSelector(state) === InitStatus.Initialized
? {
...state,
interactions: interactionsReducer(state.interactions, action, getLegendItemsSelector(state)),
interactions: interactionsReducer(state, action, getLegendItemsSelector(state)),
}
: state;
}
Expand Down
11 changes: 8 additions & 3 deletions src/state/reducers/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
* under the License.
*/

import { getPickedShapesLayerValues } from '../../chart_types/partition_chart/state/selectors/picked_shapes';
import { getSeriesIndex } from '../../chart_types/xy_chart/utils/series';
import { LegendItem } from '../../common/legend';
import { SeriesIdentifier } from '../../common/series_id';
import { LayerValue } from '../../specs';
import { getDelta } from '../../utils/point';
import { DOMElementActions, ON_DOM_ELEMENT_ENTER, ON_DOM_ELEMENT_LEAVE } from '../actions/dom_element';
import { KeyActions, ON_KEY_UP } from '../actions/key';
Expand All @@ -30,8 +32,8 @@ import {
ON_TOGGLE_DESELECT_SERIES,
ToggleDeselectSeriesAction,
} from '../actions/legend';
import { MouseActions, ON_MOUSE_DOWN, ON_MOUSE_UP, ON_POINTER_MOVE } from '../actions/mouse';
import { InteractionsState } from '../chart_state';
import { ON_MOUSE_DOWN, ON_MOUSE_UP, ON_POINTER_MOVE, MouseActions } from '../actions/mouse';
import { GlobalChartState, InteractionsState } from '../chart_state';
import { getInitialPointerState } from '../utils';

/**
Expand All @@ -46,10 +48,11 @@ const DRAG_DETECTION_PIXEL_DELTA = 4;

/** @internal */
export function interactionsReducer(
state: InteractionsState,
globalState: GlobalChartState,
action: LegendActions | MouseActions | KeyActions | DOMElementActions,
legendItems: LegendItem[],
): InteractionsState {
const { interactions: state } = globalState;
switch (action.type) {
case ON_KEY_UP:
if (action.key === 'Escape') {
Expand Down Expand Up @@ -79,8 +82,10 @@ export function interactionsReducer(
},
};
case ON_MOUSE_DOWN:
const layerValues: LayerValue[] = getPickedShapesLayerValues(globalState)[0];
Copy link
Contributor Author

@monfera monfera Jan 28, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is the best approach, though

  • it's a very compact addition
  • fully reuses the existing data flow subgraph (DRY)
  • doesn't require anti-patterns(?) such as firing an action (eg. SET_DRILLDOWN) from within another action (ON_MOUSE_DOWN), or redux "middleware" and error prone thunks etc.
  • doesn't require asynchrony, timeout or some other fragile way to update
  • doesn't interfere with anything else linked to ON_MOUSE_DOWN, eg. the callbacks registered for shape selections go through the same code path as before

Disadvantages:

  • it still ties the gesture to the more semantic action; we should eventually be flexible about how to trigger drilldown; even an API callback could eventually do it
  • it broadens the reducer from state.interactions to the full global state so it feels a bit more coupled

It seems possible to currently solve this via selectors, ie. drilldown wouldn't be a state property; it'd be a selector. But

  • it'd have no information about the last action, because selectors receive only state, not the action
  • it'd not be possible to access the previous drilldown state: this is not currently done, but it's needed by tweening, which is very frequently done with this type of charts—while, with the current reducer action, it's trivial to retain the previous state:
      return {
        ...state,
        drilldown: layerValues ? layerValues[layerValues.length - 1].path.map((n) => n.value) : [],
        previousDrilldown: state.drilldown,
        ...

I ran into this tradeoff with the Kibana Canvas layout engine, which is rich in inherently stateful direct manipulation interactions (stuff being dragged/resized, snapping etc.) and used an approach there that worked out quite well; it uses a global inert state like our redux, but has some differences: it solved the issue of one action arising from some other, lower level actions.

return {
...state,
drilldown: layerValues ? layerValues[layerValues.length - 1].path.map((n) => n.value) : [],
pointer: {
...state.pointer,
dragging: false,
Expand Down
2 changes: 1 addition & 1 deletion stories/icicle/01_unix_icicle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const Example = () => {
valueAccessor={(d: Datum) => d.value as number}
valueFormatter={() => ''}
layers={getLayerSpec(color)}
config={{ ...config, partitionLayout: PartitionLayout.icicle }}
config={{ ...config, partitionLayout: PartitionLayout.icicle, drilldown: true }}
/>
</Chart>
);
Expand Down
6 changes: 1 addition & 5 deletions stories/icicle/02_unix_flame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,14 @@ export const Example = () => {
legendStrategy={LegendStrategy.PathWithDescendants}
legendMaxDepth={maxDepth}
theme={STORYBOOK_LIGHT_THEME}
onElementClick={(e) => {
// eslint-disable-next-line no-console
console.log(e);
}}
/>
<Partition
id="spec_1"
data={getFlatData()}
valueAccessor={(d: Datum) => d.value as number}
valueFormatter={() => ''}
layers={getLayerSpec(color)}
config={{ ...config, partitionLayout: PartitionLayout.flame }}
config={{ ...config, partitionLayout: PartitionLayout.flame, drilldown: true }}
/>
</Chart>
);
Expand Down