Skip to content
This repository has been archived by the owner on Dec 10, 2021. It is now read-only.

Commit

Permalink
feat: add basic functionality for icicle chart to display static data (
Browse files Browse the repository at this point in the history
…#165)

* feat: add basic functionality for icicle chart to display static data

adds functionality for the icicle chart to have data passed in and display (static, no interactions
yet)

* feat: increase code coverage to pass check

* feat: clarify contentRenderer argument types
  • Loading branch information
cguan7 committed Aug 7, 2019
1 parent 364f157 commit 50e9df5
Show file tree
Hide file tree
Showing 7 changed files with 297 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@
"dependencies": {
"@types/d3": "^5.7.2",
"@types/d3-hierarchy": "^1.1.6",
"d3": "^5.9.7",
"@types/d3-selection": "^1.4.1",
"@types/memoize-one": "^4.1.1",
"d3-array": "^2.2.0",
"d3-hierarchy": "^1.1.8",
"d3-selection": "^1.4.0",
"lodash": "^4.17.11",
"memoize-one": "^5.0.5",
"prop-types": "^15.6.2"
},
"peerDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
*/
import React, { Component, createRef } from 'react';
import { HierarchyRectangularNode } from 'd3-hierarchy';
import { BaseType, select as d3Select } from 'd3-selection';
import { IcicleEventNode } from '../types/IcicleEventNode';
import { x0, y0, rectWidth, rectHeight } from './utils/RenderedIcicleAccessors';

interface Props {
className?: string;
Expand All @@ -29,14 +31,48 @@ interface Props {
y: number;
};
color: (name: string) => string;
contentRenderer: () => void;
contentRenderer: (
datum: HierarchyRectangularNode<IcicleEventNode>,
container: BaseType,
rect: {
width: number;
height: number;
},
) => void;
d3TreeRoot: HierarchyRectangularNode<IcicleEventNode>;
isVertical: boolean;
rounding: number;
transitionDuration: number;
}

function defaultContentRenderer(
datum: HierarchyRectangularNode<IcicleEventNode>,
container: HTMLDivElement,
rect: { width: number; height: number },
) {
const minRectHeight = 20;
const minRectWidth = 10;

d3Select(container)
.attr('class', 'icicleContent')
.style('text-overflow', 'ellipsis')
.style('white-space', 'nowrap')
.style('overflow', 'hidden')
.style('font-size', `${rect.height / 2}px`)
.style('line-height', `${rect.height}px`)
.text(`${rect.height > minRectHeight && rect.width > minRectWidth ? datum.data.name : ''}`);
}

export default class IcicleEventChart extends Component<Props> {
static defaultProps = {
boxMargin: {
x: 1,
y: 3,
},
color: (name: string) => 'pink',
contentRenderer: defaultContentRenderer,
};

private chartRef = createRef<HTMLDivElement>();

constructor(props: Props) {
Expand All @@ -50,10 +86,82 @@ export default class IcicleEventChart extends Component<Props> {
}

// Check for changed data to rerender the icicle chart
componentDidUpdate(prevProps: Props) {}
componentDidUpdate(prevProps: Props) {
const root = this.props.d3TreeRoot;
const prevRoot = prevProps.d3TreeRoot;

if (root.data.id !== prevRoot.data.id || root.data.value !== prevRoot.data.value) {
this.renderIcicleChart();
}
}

// Creates chart using svg & chartRef to the div element
renderIcicleChart() {}
renderIcicleChart() {
const { boxMargin, color, contentRenderer, isVertical, width, height, rounding } = this.props;

const root = this.props.d3TreeRoot;

// Clear all elements and redraw the Icicle Chart
d3Select(this.chartRef.current)
.selectAll('*')
.remove();

const svg = d3Select(this.chartRef.current)
.append('svg')
.style('width', `${width}px`)
.style('height', `${height}px`)
.style('overflow', 'hidden');

const cell = svg
.selectAll('g')
.data(root.descendants())
.enter()
.append('g')
.attr(
'transform',
d => `translate(${y0(isVertical, d) + boxMargin.x},${x0(isVertical, d) + boxMargin.y})`,
)
.attr('key', (d, i) => `${i}`);

// Create the color coded rectangles for the events
cell
.append('rect')
.attr('width', d => rectWidth(isVertical, boxMargin, d))
.attr('height', d => rectHeight(isVertical, boxMargin, d))
.attr('rx', rounding)
.attr('fill', d => color(d.data.name || ''))
.style('cursor', 'pointer');

// Create container for each rectangle to append content (name of event)
const content = cell
.append('foreignObject')
.classed('container', true)
.attr('pointer-events', 'none')
.style('width', d => `${rectWidth(isVertical, boxMargin, d)}px`)
.style('height', d => `${rectHeight(isVertical, boxMargin, d)}px`)
.style('padding', '0px')
.style('overflow', 'hidden')
.style('background', 'none');

if (!isVertical) {
content
.attr('transform', d => `translate(${rectWidth(isVertical, boxMargin, d)}) rotate(90)`)
.style('height', d => `${rectWidth(isVertical, boxMargin, d)}px`)
.style('width', d => `${rectHeight(isVertical, boxMargin, d)}px`);
}

content
.append('xhtml:div')
.style('width', '100%')
.style('height', '100%')
.style('padding-left', '2px')
.each((d, i, elements) =>
contentRenderer(d, elements[i], {
height: rectHeight(isVertical, boxMargin, d),
width: rectWidth(isVertical, boxMargin, d),
}),
);
}

render() {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
* under the License.
*/
import React, { PureComponent } from 'react';
import memoizeOne from 'memoize-one';
import { IcicleEventNode } from '../types/IcicleEventNode';
import IcicleEventChart from './IcicleEventChart';
import { createPartitionAndLayout } from './utils/IcicleEventTreeHelpers';

const memoizedCreatePartitionAndLayout = memoizeOne(createPartitionAndLayout);

interface Props {
className?: string;
Expand All @@ -32,8 +37,26 @@ interface Props {

export default class IcicleEventViz extends PureComponent<Props> {
render() {
// TODO: create d3 partition & layout w/ memoization & pass into chart here
const { data, isVertical, width, height, rounding, transitionDuration } = this.props;

// Memoized to prevent the creation of a new tree with every render
const root = memoizedCreatePartitionAndLayout(
data,
isVertical ? width : height,
isVertical ? height : width,
);

return <div>Icicle Event Chart Component</div>;
return (
<div>
<IcicleEventChart
d3TreeRoot={root}
width={width}
height={height}
isVertical={isVertical}
rounding={rounding}
transitionDuration={transitionDuration}
/>
</div>
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,26 @@
* under the License.
*/
import { ChartProps } from '@superset-ui/chart';
import { findDepth } from './utils/IcicleEventTreeHelpers';

export default function transformProps(chartProps: ChartProps) {
return {};
const { formData, payload, width } = chartProps;
// Need to double check if actually part of formData
const { color, isVertical, rounding, transitionDuration } = formData;
const { data } = payload;

const chartPropsHeight = chartProps.height;
const rectHeight = 30;
const heightFromTreeDepth = findDepth(data) * rectHeight;
const height = chartPropsHeight > heightFromTreeDepth ? chartPropsHeight : heightFromTreeDepth;

return {
color,
data,
height,
isVertical,
rounding,
transitionDuration,
width,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
* under the License.
*/
import { max as d3Max } from 'd3-array';
import { HierarchyRectangularNode } from 'd3-hierarchy';
import {
HierarchyNode,
HierarchyRectangularNode,
hierarchy as d3Hierarchy,
partition as d3Partition,
} from 'd3-hierarchy';
import { IcicleEventNode } from '../../types/IcicleEventNode';

export function findDepth(node: IcicleEventNode, depth: number = 0): number {
Expand All @@ -31,12 +36,24 @@ export function findDepth(node: IcicleEventNode, depth: number = 0): number {
}

export function hierarchySort(
a: HierarchyRectangularNode<IcicleEventNode>,
b: HierarchyRectangularNode<IcicleEventNode>,
a: HierarchyNode<IcicleEventNode>,
b: HierarchyNode<IcicleEventNode>,
): number {
if (a && a.value && b && b.value) {
return b.value - a.value || b.height - a.height;
}

return 0;
}

export function createPartitionAndLayout(
data: IcicleEventNode,
width: number,
height: number,
): HierarchyRectangularNode<IcicleEventNode> {
const root = d3Hierarchy(data).sort(hierarchySort);
const createLayout = d3Partition<IcicleEventNode>().size([width, height]);
const layout = createLayout(root);

return layout;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF 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 { HierarchyNode, HierarchyRectangularNode, hierarchy as d3Hierarchy } from 'd3-hierarchy';
import { IcicleEventNode } from '../../types/IcicleEventNode';
import {
findDepth,
hierarchySort,
createPartitionAndLayout,
} from '../../src/utils/IcicleEventTreeHelpers';

const ROOT_NODE: IcicleEventNode = {
id: 'root',
event: 'root',
name: 'Root',
value: 1,
};

const NODE_A: IcicleEventNode = {
id: 'a-0',
event: 'a',
name: 'A',
value: 1,
};

const NODE_B: IcicleEventNode = {
id: 'b-0',
event: 'b',
name: 'B',
value: 2,
};

const BALANCED_TREE: IcicleEventNode = {
id: 'root',
event: 'root',
name: 'Root',
value: 3,
children: [NODE_A, NODE_B],
};

const UNBALANCED_TREE: IcicleEventNode = {
id: 'root',
event: 'root',
name: 'Root',
value: 2,
children: [
{
id: 'a-1',
event: 'a',
name: 'A',
value: 2,
children: [NODE_B],
},
],
};

describe('findDepth', () => {
it('finds depth of tree with root node', () => {
expect(findDepth(ROOT_NODE)).toBe(0);
});

it('finds depth of a balanced tree', () => {
expect(findDepth(BALANCED_TREE)).toBe(1);
});

it('finds depth of an unbalanced tree', () => {
expect(findDepth(UNBALANCED_TREE)).toBe(2);
});
});

describe('hierarchySort', () => {
it('sorts D3 hierarchy nodes correctly', () => {
const root: HierarchyNode<IcicleEventNode> = d3Hierarchy(BALANCED_TREE).sort(hierarchySort);
expect(root.children).toHaveLength(2);
expect(root.children![0].data.id).toBe('b-0');
});
});

describe('createPartitionAndLayout', () => {
it('creates a D3 partition and returns the Hierarchy Rectangular Node correctly', () => {
const root: HierarchyRectangularNode<IcicleEventNode> = createPartitionAndLayout(
BALANCED_TREE,
100,
100,
);
expect(root).toHaveProperty('x0', 0);
expect(root).toHaveProperty('y0', 0);
expect(root).toHaveProperty('x1', 100);
expect(root).toHaveProperty('y1', 50);

expect(root.children).toHaveLength(2);

const child = root.children![0];
expect(child).toHaveProperty('x0', 0);
expect(child).toHaveProperty('y0', 50);
expect(child).toHaveProperty('x1');
// 2/3 since NODE_B has a value of 2 & sibling has value of 1
expect(child.x1).toBeCloseTo(100 * (2 / 3), 5);
expect(child).toHaveProperty('y1', 100);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ export type IcicleEventNode = {
event: string;
name?: string;
value: number;
children?: [IcicleEventNode]
children?: IcicleEventNode[]
}

0 comments on commit 50e9df5

Please sign in to comment.