Skip to content

Commit

Permalink
feat(partition): waffle chart (#1255)
Browse files Browse the repository at this point in the history
  • Loading branch information
monfera committed Jul 28, 2021
1 parent 32e682a commit 156662a
Show file tree
Hide file tree
Showing 15 changed files with 277 additions and 39 deletions.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions packages/charts/api/charts.api.md
Expand Up @@ -1431,6 +1431,7 @@ export const PartitionLayout: Readonly<{
icicle: "icicle";
flame: "flame";
mosaic: "mosaic";
waffle: "waffle";
}>;

// @public (undocumented)
Expand Down Expand Up @@ -2363,8 +2364,8 @@ export type YDomainRange = YDomainBase & DomainRange & LogScaleOptions;
// src/chart_types/heatmap/layout/types/config_types.ts:20: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:52: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:53: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:138: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:139: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:139: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:140:5 - (ae-forgotten-export) The symbol "AnimKeyframe" needs to be exported by the entry point index.d.ts

// (No @packageDocumentation comment for this package)

Expand Down
Expand Up @@ -20,6 +20,7 @@ export const PartitionLayout = Object.freeze({
icicle: 'icicle' as const,
flame: 'flame' as const,
mosaic: 'mosaic' as const,
waffle: 'waffle' as const,
});

/** @public */
Expand Down
Expand Up @@ -12,6 +12,7 @@ import { LegendItem } from '../../../../common/legend';
import { LegendPositionConfig } from '../../../../specs/settings';
import { isHierarchicalLegend } from '../../../../utils/legend';
import { Layer } from '../../specs';
import { PartitionLayout } from '../types/config_types';
import { QuadViewModel } from '../types/viewmodel_types';

function makeKey(...keyParts: CategoryKey[]): string {
Expand Down Expand Up @@ -41,6 +42,7 @@ export function getLegendItems(
legendMaxDepth: number,
legendPosition: LegendPositionConfig,
quadViewModel: QuadViewModel[],
partitionLayout: PartitionLayout | undefined,
): LegendItem[] {
const uniqueNames = new Set(map(({ dataName, fillColor }) => makeKey(dataName, fillColor), quadViewModel));
const useHierarchicalLegend = isHierarchicalLegend(flatLegend, legendPosition);
Expand All @@ -56,6 +58,10 @@ export function getLegendItems(
return a < b ? -1 : a > b ? 1 : 0;
}

function descendingValues(aItem: QuadViewModel, bItem: QuadViewModel): number {
return aItem.depth - bItem.depth || bItem.value - aItem.value;
}

const excluded: Set<string> = new Set();
const items = quadViewModel.filter(({ depth, dataName, fillColor }) => {
if (legendMaxDepth !== null && depth > legendMaxDepth) {
Expand All @@ -71,7 +77,13 @@ export function getLegendItems(
return true;
});

items.sort(flatLegend ? compareNames : compareTreePaths);
items.sort(
partitionLayout === PartitionLayout.waffle // waffle has inherent top to bottom descending order
? descendingValues
: flatLegend
? compareNames
: compareTreePaths,
);

return items.map<LegendItem>((item) => {
const { dataName, fillColor, depth, path } = item;
Expand Down
@@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { Part } from '../../../../common/text_utils';
import { CHILDREN_KEY, HierarchyOfArrays } from './group_by_rollup';

// 10 x 10 grid for 100 cells ie. one cell is 1%
const rowCount = 10;
const columnCount = 10;

/** @internal */
export function waffle(
tree: HierarchyOfArrays,
totalValue: number,
{
x0: outerX0,
y0: outerY0,
width: outerWidth,
height: outerHeight,
}: { x0: number; y0: number; width: number; height: number },
): Array<Part> {
const size = Math.min(outerWidth, outerHeight);
const widthOffset = Math.max(0, outerWidth - size) / 2;
const heightOffset = Math.max(0, outerHeight - size) / 2;
const rowHeight = size / rowCount;
const columnWidth = size / columnCount;
const cellCount = rowCount * columnCount;
const valuePerCell = totalValue / cellCount;
let valueSoFar = 0;
let lastIndex = 0;
const root = tree[0];
return [
{ node: root, x0: 0, y0: 0, x1: size, y1: size },
...root[1][CHILDREN_KEY].flatMap((entry) => {
const [, { value }] = entry;
valueSoFar += value;
const toIndex = Math.round(valueSoFar / valuePerCell);
const cells = [];
for (let i = lastIndex; i < toIndex; i++) {
const columnIndex = i % columnCount;
const rowIndex = (i - columnIndex) / columnCount;
const x0 = outerX0 + widthOffset + columnIndex * columnWidth;
const y0 = outerY0 + heightOffset + rowIndex * rowHeight;
cells.push({
node: entry,
x0,
y0,
x1: x0 + columnWidth,
y1: y0 + rowHeight,
});
}
lastIndex = toIndex;
return cells;
}),
];
}
Expand Up @@ -50,6 +50,7 @@ import {
} from '../utils/group_by_rollup';
import { sunburst } from '../utils/sunburst';
import { getTopPadding, LayerLayout, treemap } from '../utils/treemap';
import { waffle } from '../utils/waffle';
import {
fillTextLayout,
getRectangleRowGeometry,
Expand All @@ -74,6 +75,9 @@ export const isIcicle = (p: PartitionLayout | undefined) => p === PartitionLayou
/** @internal */
export const isFlame = (p: PartitionLayout | undefined) => p === PartitionLayout.flame;

/** @internal */
export const isWaffle = (p: PartitionLayout | undefined) => p === PartitionLayout.waffle;

/** @internal */
export const isLinear = (p: PartitionLayout | undefined) => isFlame(p) || isIcicle(p);

Expand Down Expand Up @@ -246,6 +250,14 @@ const rawChildNodes = (
isMosaic(partitionLayout) ? [LayerLayout.vertical, LayerLayout.horizontal] : [],
);

case PartitionLayout.waffle:
return waffle(tree, totalValue, {
x0: 0,
y0: 0,
width,
height,
});

case PartitionLayout.icicle:
case PartitionLayout.flame:
const icicleLayout = isIcicle(partitionLayout);
Expand Down Expand Up @@ -311,6 +323,7 @@ export function shapeViewModel(
const icicleLayout = isIcicle(partitionLayout);
const flameLayout = isFlame(partitionLayout);
const simpleLinear = isSimpleLinear(config, layers);
const waffleLayout = isWaffle(partitionLayout);

const diskCenter = isSunburst(partitionLayout)
? {
Expand Down Expand Up @@ -391,13 +404,14 @@ export function shapeViewModel(
getSectorRowGeometry,
inSectorRotation(config.horizontalTextEnforcer, config.horizontalTextAngleThreshold),
)
: simpleLinear
? () => [] // no multirow layout needed for simpleLinear partitions
: simpleLinear || waffleLayout
? () => [] // no multirow layout needed for simpleLinear partitions; no text at all for waffles
: fillTextLayout(
rectangleConstruction(treeHeight, treemapLayout || mosaicLayout ? topGroove : null),
getRectangleRowGeometry,
() => 0,
);

const rowSets: RowSet[] = getRowSets(
textMeasure,
rawTextGetter,
Expand All @@ -418,7 +432,7 @@ export function shapeViewModel(
const currentY = [-height, -height, -height, -height];

const nodesWithoutRoom =
fillOutside || treemapLayout || mosaicLayout || icicleLayout || flameLayout
fillOutside || treemapLayout || mosaicLayout || icicleLayout || flameLayout || waffleLayout
? [] // outsideFillNodes and linkLabels are in inherent conflict due to very likely overlaps
: quadViewModel.filter((n: ShapeTreeNode) => {
const id = nodeId(n);
Expand Down
Expand Up @@ -6,17 +6,14 @@
* Side Public License, v 1.
*/

import { ChartId } from '../../../../state/chart_state';
import { ShapeViewModel } from '../../layout/types/viewmodel_types';
import { ContinuousDomainFocus } from './partition';
import { AnimationState, ContinuousDomainFocus } from './partition';

const linear = (x: number) => x;
const easeInOut = (alpha: number) => (x: number) => x ** alpha / (x ** alpha + (1 - x) ** alpha);

const MAX_PADDING_RATIO = 0.25;

const latestRafs: Map<ChartId, number> = new Map();

/** @internal */
export function renderLinearPartitionCanvas2d(
ctx: CanvasRenderingContext2D,
Expand All @@ -30,29 +27,23 @@ export function renderLinearPartitionCanvas2d(
layers,
}: ShapeViewModel,
{ currentFocusX0, currentFocusX1, prevFocusX0, prevFocusX1 }: ContinuousDomainFocus,
chartId: ChartId,
animationState: AnimationState,
) {
if (animation?.duration) {
const latestRaf = latestRafs.get(chartId);
if (latestRaf !== undefined) {
window.cancelAnimationFrame(latestRaf);
}
window.cancelAnimationFrame(animationState.rafId);
render(0);
const focusChanged = currentFocusX0 !== prevFocusX0 || currentFocusX1 !== prevFocusX1;
if (focusChanged) {
latestRafs.set(
chartId,
window.requestAnimationFrame((epochStartTime) => {
const anim = (t: number) => {
const unitNormalizedTime = Math.max(0, Math.min(1, (t - epochStartTime) / animation.duration));
render(unitNormalizedTime);
if (unitNormalizedTime < 1) {
latestRafs.set(chartId, window.requestAnimationFrame(anim));
}
};
latestRafs.set(chartId, window.requestAnimationFrame(anim));
}),
);
animationState.rafId = window.requestAnimationFrame((epochStartTime) => {
const anim = (t: number) => {
const unitNormalizedTime = Math.max(0, Math.min(1, (t - epochStartTime) / animation.duration));
render(unitNormalizedTime);
if (unitNormalizedTime < 1) {
animationState.rafId = window.requestAnimationFrame(anim);
}
};
animationState.rafId = window.requestAnimationFrame(anim);
});
}
} else {
render(1);
Expand Down Expand Up @@ -86,7 +77,9 @@ export function renderLinearPartitionCanvas2d(

if (fx1 < 0 || fx0 > width) return;

const formatter = layers[depth]?.nodeLabel ?? String;
const layer = layers[depth - 1]; // depth === 0 corresponds to root layer (above API `layers`)
const formatter = layer?.nodeLabel ?? String;

const label = formatter(dataName);
const fWidth = fx1 - fx0;
const fPadding = Math.min(padding, MAX_PADDING_RATIO * fWidth);
Expand Down
@@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { ShapeViewModel } from '../../layout/types/viewmodel_types';

const MAX_PADDING_RATIO = 0.25;

/** @internal */
export function renderWrappedPartitionCanvas2d(
ctx: CanvasRenderingContext2D,
dpr: number,
{
config: { sectorLineWidth: padding, width: containerWidth, height: containerHeight },
quadViewModel,
diskCenter,
width: panelWidth,
height: panelHeight,
}: ShapeViewModel,
) {
const width = containerWidth * panelWidth;
const height = containerHeight * panelHeight;
const cornerRatio = 0.2;

ctx.save();
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.scale(dpr, dpr);
ctx.translate(diskCenter.x, diskCenter.y);
ctx.clearRect(0, 0, width, height);

quadViewModel.forEach(({ fillColor, x0, x1, y0px: y0, y1px: y1 }) => {
if (y1 - y0 <= padding) return;

const fWidth = x1 - x0;
const fPadding = Math.min(padding, MAX_PADDING_RATIO * fWidth);
const paintedWidth = fWidth - fPadding;
const paintedHeight = y1 - y0 - padding;
const cornerRadius = 2 * cornerRatio * Math.min(paintedWidth, paintedHeight);
const halfRadius = cornerRadius / 2;

ctx.fillStyle = fillColor;
ctx.strokeStyle = fillColor;
ctx.lineWidth = cornerRadius;
ctx.beginPath();
ctx.rect(
x0 + fPadding + halfRadius,
y0 + padding / 2 + halfRadius,
paintedWidth - cornerRadius,
paintedHeight - cornerRadius,
);
ctx.fill();
ctx.stroke();
});

ctx.restore();
}

0 comments on commit 156662a

Please sign in to comment.