diff --git a/api/charts.api.md b/api/charts.api.md index 3352d7c837..5b94e93bb9 100644 --- a/api/charts.api.md +++ b/api/charts.api.md @@ -1388,6 +1388,7 @@ export const PartitionLayout: Readonly<{ treemap: "treemap"; icicle: "icicle"; flame: "flame"; + mosaic: "mosaic"; }>; // @public (undocumented) @@ -2188,8 +2189,8 @@ export type YDomainRange = YDomainBase & DomainRange & LogScaleOptions; // src/chart_types/heatmap/layout/types/config_types.ts:31: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:63: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:64: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:148: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:149: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:149: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:150:5 - (ae-forgotten-export) The symbol "AnimKeyframe" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-mosaic-alpha-other-slices-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-mosaic-alpha-other-slices-visually-looks-correct-1-snap.png new file mode 100644 index 0000000000..046a394514 Binary files /dev/null and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-mosaic-alpha-other-slices-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-mosaic-alpha-simple-mosaic-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-mosaic-alpha-simple-mosaic-visually-looks-correct-1-snap.png new file mode 100644 index 0000000000..9ebee939a7 Binary files /dev/null and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-mosaic-alpha-simple-mosaic-visually-looks-correct-1-snap.png differ diff --git a/src/chart_types/partition_chart/layout/types/config_types.ts b/src/chart_types/partition_chart/layout/types/config_types.ts index be9abbf696..25ce2804f2 100644 --- a/src/chart_types/partition_chart/layout/types/config_types.ts +++ b/src/chart_types/partition_chart/layout/types/config_types.ts @@ -30,6 +30,7 @@ export const PartitionLayout = Object.freeze({ treemap: 'treemap' as const, icicle: 'icicle' as const, flame: 'flame' as const, + mosaic: 'mosaic' as const, }); /** @public */ diff --git a/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.ts b/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.ts index 088a6f5ec2..9e5f5043db 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.ts @@ -35,7 +35,7 @@ import { NodeSorter, Sorter, } from '../utils/group_by_rollup'; -import { isSunburst, isTreemap } from './viewmodel'; +import { isMosaic, isSunburst, isTreemap } from './viewmodel'; function aggregateComparator(accessor: (v: any) => any, sorter: Sorter): NodeSorter { return (a, b) => sorter(accessor(a), accessor(b)); @@ -50,6 +50,7 @@ const childOrders = { }; const descendingValueNodes = aggregateComparator(mapEntryValue, childOrders.descending); +const ascendingValueNodes = aggregateComparator(mapEntryValue, childOrders.ascending); /** * @internal @@ -78,8 +79,15 @@ export function getHierarchyOfArrays( return mapsToArrays(groupByRollup(groupByRollupAccessors, valueAccessor, aggregator, facts), sortSpecs); } -const sorter = (layout: PartitionLayout) => ({ sortPredicate }: Layer) => - sortPredicate || (isTreemap(layout) || isSunburst(layout) ? descendingValueNodes : null); +const sorter = (layout: PartitionLayout) => ({ sortPredicate }: Layer, i: number) => + sortPredicate || + (isTreemap(layout) || isSunburst(layout) + ? descendingValueNodes + : isMosaic(layout) + ? i === 2 + ? ascendingValueNodes + : descendingValueNodes + : null); /** @internal */ export function partitionTree( diff --git a/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts b/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts index 24e7fba21d..b1d7c293a0 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts @@ -60,7 +60,7 @@ import { pathAccessor, } from '../utils/group_by_rollup'; import { sunburst } from '../utils/sunburst'; -import { getTopPadding, treemap } from '../utils/treemap'; +import { getTopPadding, LayerLayout, treemap } from '../utils/treemap'; import { fillTextLayout, getRectangleRowGeometry, @@ -70,6 +70,9 @@ import { } from './fill_text_layout'; import { linkTextLayout } from './link_text_layout'; +/** @internal */ +export const isMosaic = (p: PartitionLayout | undefined) => p === PartitionLayout.mosaic; + /** @internal */ export const isTreemap = (p: PartitionLayout | undefined) => p === PartitionLayout.treemap; @@ -236,7 +239,8 @@ const rawChildNodes = ( return sunburst(tree, sunburstAreaAccessor, { x0: 0, y0: -1 }, clockwiseSectors, specialFirstInnermostSector); case PartitionLayout.treemap: - const treemapInnerArea = isTreemap(partitionLayout) ? width * height : 1; // assuming 1 x 1 unit square + case PartitionLayout.mosaic: + const treemapInnerArea = width * height; // assuming 1 x 1 unit square const treemapValueToAreaScale = treemapInnerArea / totalValue; const treemapAreaAccessor = (e: ArrayEntry) => treemapValueToAreaScale * mapEntryValue(e); return treemap( @@ -250,7 +254,7 @@ const rawChildNodes = ( width, height, }, - [], + isMosaic(partitionLayout) ? [LayerLayout.vertical, LayerLayout.horizontal] : [], ); case PartitionLayout.icicle: @@ -313,6 +317,7 @@ export function shapeViewModel( const { marginLeftPx, marginTopPx, panelInnerWidth, panelInnerHeight } = panel; const treemapLayout = isTreemap(partitionLayout); + const mosaicLayout = isMosaic(partitionLayout); const sunburstLayout = isSunburst(partitionLayout); const icicleLayout = isIcicle(partitionLayout); const flameLayout = isFlame(partitionLayout); @@ -400,7 +405,7 @@ export function shapeViewModel( : simpleLinear ? () => [] // no multirow layout needed for simpleLinear partitions : fillTextLayout( - rectangleConstruction(treeHeight, treemapLayout ? topGroove : null), + rectangleConstruction(treeHeight, treemapLayout || mosaicLayout ? topGroove : null), getRectangleRowGeometry, () => 0, ); @@ -414,7 +419,7 @@ export function shapeViewModel( layers, textFillOrigins, !sunburstLayout, - !treemapLayout, + !(treemapLayout || mosaicLayout), ); // whiskers (ie. just lines, no text) for fill text outside the outer radius @@ -424,7 +429,7 @@ export function shapeViewModel( const currentY = [-height, -height, -height, -height]; const nodesWithoutRoom = - fillOutside || treemapLayout || icicleLayout || flameLayout + fillOutside || treemapLayout || mosaicLayout || icicleLayout || flameLayout ? [] // outsideFillNodes and linkLabels are in inherent conflict due to very likely overlaps : quadViewModel.filter((n: ShapeTreeNode) => { const id = nodeId(n); diff --git a/src/chart_types/partition_chart/renderer/dom/highlighter.tsx b/src/chart_types/partition_chart/renderer/dom/highlighter.tsx index 4d42bba231..9eb8f31e04 100644 --- a/src/chart_types/partition_chart/renderer/dom/highlighter.tsx +++ b/src/chart_types/partition_chart/renderer/dom/highlighter.tsx @@ -30,7 +30,7 @@ import { QuadViewModel, ShapeViewModel, } from '../../layout/types/viewmodel_types'; -import { isSunburst, isTreemap } from '../../layout/viewmodel/viewmodel'; +import { isSunburst, isTreemap, isMosaic } from '../../layout/viewmodel/viewmodel'; import { ContinuousDomainFocus, IndexedContinuousDomainFocus } from '../canvas/partition'; interface HighlightSet extends PartitionSmallMultiplesModel { @@ -123,7 +123,8 @@ function renderGeometries( ) { const maxDepth = geoms.reduce((acc, geom) => Math.max(acc, geom.depth), 0); // we should render only the deepest geometries of the tree to avoid overlaying highlighted geometries - const highlightedGeoms = isTreemap(partitionLayout) ? geoms.filter((g) => g.depth >= maxDepth) : geoms; + const highlightedGeoms = + isTreemap(partitionLayout) || isMosaic(partitionLayout) ? geoms.filter((g) => g.depth >= maxDepth) : geoms; const renderGeom = isSunburst(partitionLayout) ? renderSector : renderRectangles; return highlightedGeoms.map((geometry, index) => renderGeom( diff --git a/stories/mosaic/10_mosaic_simple.tsx b/stories/mosaic/10_mosaic_simple.tsx new file mode 100644 index 0000000000..8a0bf240d7 --- /dev/null +++ b/stories/mosaic/10_mosaic_simple.tsx @@ -0,0 +1,111 @@ +/* + * 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 { boolean } from '@storybook/addon-knobs'; +import React from 'react'; + +import { + AdditiveNumber, + ArrayEntry, + Chart, + Datum, + Partition, + PartitionLayout, + Settings, + ShapeTreeNode, +} from '../../src'; +import { config } from '../../src/chart_types/partition_chart/layout/config'; +import { mocks } from '../../src/mocks/hierarchical'; +import { keepDistinct } from '../../src/utils/common'; +import { countryLookup, colorBrewerCategoricalPastel12B, regionLookup } from '../utils/utils'; + +const productLookup: Record = { + '3': { label: 'Firefox', position: 1 }, + '5': { label: 'Edge (Chromium)', position: 4 }, + '6': { label: 'Safari', position: 2 }, + '7': { label: 'Chrome', position: 0 }, + '8': { label: 'Brave', position: 3 }, +}; + +const data = mocks.sunburst + .map((d) => (d.dest === 'chn' ? { ...d, dest: 'zaf' } : d)) + .filter( + (d: any) => + ['eu', 'na', 'as', 'af'].includes(countryLookup[d.dest].continentCountry.slice(0, 2)) && + ['3', '5', '6', '7', '8'].includes(d.sitc1), + ); + +const productPalette = colorBrewerCategoricalPastel12B.slice(2); + +const productToColor = new Map( + data + .map((d) => d.sitc1) + .filter(keepDistinct) + .sort() + .map((sitc1, i) => [sitc1, `rgba(${productPalette[i % productPalette.length].join(',')}, 0.7)`]), +); + +export const Example = () => { + return ( + + + d.exportVal as AdditiveNumber} + valueFormatter={(d: number) => `${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}`} + layers={[ + { + groupByRollup: (d: Datum) => countryLookup[d.dest].continentCountry.slice(0, 2), + nodeLabel: (name: any) => regionLookup[name].regionName, + fillLabel: { + fontWeight: 400, + }, + shape: { + fillColor: () => 'white', + }, + }, + { + groupByRollup: (d: Datum) => d.sitc1, + nodeLabel: (d: any) => String(productLookup[d]?.label), + shape: { + fillColor: (d: ShapeTreeNode) => productToColor.get(d.dataName)!, + }, + sortPredicate: ([name1]: ArrayEntry, [name2]: ArrayEntry) => { + const position1 = Number(productLookup[name1]?.position); + const position2 = Number(productLookup[name2]?.position); + return position2 - position1; + }, + fillLabel: { + fontWeight: 200, + minFontSize: 6, + maxFontSize: 16, + maximizeFontSize: true, + fontFamily: 'Helvetica Neue', + valueFormatter: () => '', + }, + }, + ]} + config={{ + partitionLayout: PartitionLayout.mosaic, + }} + /> + + ); +}; diff --git a/stories/mosaic/20_mosaic_with_other.tsx b/stories/mosaic/20_mosaic_with_other.tsx new file mode 100644 index 0000000000..a6d099c35d --- /dev/null +++ b/stories/mosaic/20_mosaic_with_other.tsx @@ -0,0 +1,100 @@ +/* + * 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 React from 'react'; + +import { + AdditiveNumber, + ArrayEntry, + Chart, + Datum, + MODEL_KEY, + Partition, + PartitionLayout, + ShapeTreeNode, +} from '../../src'; +import { config } from '../../src/chart_types/partition_chart/layout/config'; +import { countryLookup } from '../utils/utils'; + +const categoricalColors = ['rgb(110,110,110)', 'rgb(123,123,123)', 'darkgrey', 'lightgrey']; + +const data = [ + { region: 'Americas', dest: 'usa', other: false, exportVal: 553359100104 }, + { region: 'Americas', dest: 'Other', other: true, exportVal: 753359100104 }, + { region: 'Asia', dest: 'chn', other: false, exportVal: 392617281424 }, + { region: 'Asia', dest: 'jpn', other: false, exportVal: 177490158520 }, + { region: 'Asia', dest: 'kor', other: false, exportVal: 177421375512 }, + { region: 'Asia', dest: 'Other', other: true, exportVal: 277421375512 }, + { region: 'Europe', dest: 'deu', other: false, exportVal: 253250650864 }, + { region: 'Europe', dest: 'smr', other: false, exportVal: 135443006088 }, + { region: 'Europe', dest: 'Other', other: true, exportVal: 205443006088 }, + { region: 'Africa', dest: 'Other', other: true, exportVal: 305443006088 }, +]; + +export const Example = () => { + return ( + + d.exportVal as AdditiveNumber} + valueFormatter={(d: number) => `${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}`} + layers={[ + { + groupByRollup: (d: Datum) => d.region, + nodeLabel: (d) => String(d).toUpperCase(), + fillLabel: { + valueFormatter: () => ``, + fontWeight: 600, + }, + shape: { + fillColor: () => 'white', + }, + }, + { + groupByRollup: (d: Datum) => d.dest, + nodeLabel: (d: any) => countryLookup[d]?.name ?? d, + sortPredicate: ([name1, node1]: ArrayEntry, [name2, node2]: ArrayEntry) => { + if (name1 === 'Other' && name2 !== 'Other') return -1; + if (name2 === 'Other' && name1 !== 'Other') return 1; + + // otherwise, use the increasing value order + return node1.value - node2.value; + }, + fillLabel: { + fontWeight: 100, + maxFontSize: 16, + valueFont: { + fontFamily: 'Menlo', + fontStyle: 'normal', + fontWeight: 100, + }, + }, + shape: { + fillColor: (d: ShapeTreeNode) => categoricalColors.slice(0)[d[MODEL_KEY].sortIndex], + }, + }, + ]} + config={{ + partitionLayout: PartitionLayout.mosaic, + }} + /> + + ); +}; diff --git a/stories/mosaic/mosaic.stories.tsx b/stories/mosaic/mosaic.stories.tsx new file mode 100644 index 0000000000..6c5a18652f --- /dev/null +++ b/stories/mosaic/mosaic.stories.tsx @@ -0,0 +1,30 @@ +/* + * 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 { SB_SOURCE_PANEL } from '../utils/storybook'; + +export default { + title: 'Mosaic (@alpha)', + parameters: { + options: { selectedPanel: SB_SOURCE_PANEL }, + }, +}; + +export { Example as simpleMosaic } from './10_mosaic_simple'; +export { Example as otherSlices } from './20_mosaic_with_other';