From 066b3606e817223316cde9da85fff4951ebe39f6 Mon Sep 17 00:00:00 2001 From: monfera Date: Thu, 23 Jan 2020 18:02:49 +0100 Subject: [PATCH 1/9] refactor: open potential for per-word font styling --- .../partition_chart/layout/config/config.ts | 7 +-- .../layout/types/config_types.ts | 50 +++++++++---------- .../partition_chart/layout/types/types.ts | 33 +++++++++++- .../layout/types/viewmodel_types.ts | 4 +- .../partition_chart/layout/utils/measure.ts | 11 ++-- .../layout/viewmodel/fill_text_layout.ts | 31 ++++++++---- .../layout/viewmodel/link_text_layout.ts | 4 +- .../renderer/canvas/canvas_renderers.ts | 4 +- .../partition_chart/specs/index.ts | 4 +- src/index.ts | 2 +- stories/sunburst.tsx | 2 +- stories/treemap.tsx | 8 +-- 12 files changed, 99 insertions(+), 61 deletions(-) diff --git a/src/chart_types/partition_chart/layout/config/config.ts b/src/chart_types/partition_chart/layout/config/config.ts index 1a3b7a51d5..d5bb32f75b 100644 --- a/src/chart_types/partition_chart/layout/config/config.ts +++ b/src/chart_types/partition_chart/layout/config/config.ts @@ -1,6 +1,7 @@ import { palettes } from '../../../../mocks/hierarchical/palettes'; import { Config, PartitionLayout, Numeric } from '../types/config_types'; import { GOLDEN_RATIO, TAU } from '../utils/math'; +import { FONT_STYLES, FONT_VARIANTS } from '../types/types'; const log10 = Math.log(10); function significantDigitCount(d: number): number { @@ -100,16 +101,16 @@ export const configMetadata = { values: { textColor: { dflt: '#000000', type: 'color' }, textInvertible: { dflt: false, type: 'boolean' }, - textWeight: { dflt: 400, min: 100, max: 900, type: 'number' }, + fontWeight: { dflt: 400, min: 100, max: 900, type: 'number' }, fontStyle: { dflt: 'normal', type: 'string', - values: ['normal', 'italic', 'oblique', 'inherit', 'initial', 'unset'], + values: FONT_STYLES, }, fontVariant: { dflt: 'normal', type: 'string', - values: ['normal', 'small-caps'], + values: FONT_VARIANTS, }, formatter: { dflt: defaultFormatter, 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 892900992c..ea2cb0d35b 100644 --- a/src/chart_types/partition_chart/layout/types/config_types.ts +++ b/src/chart_types/partition_chart/layout/types/config_types.ts @@ -1,5 +1,5 @@ import { Distance, Pixels, Radian, Radius, Ratio, SizeRatio, TimeMs } from './geometry_types'; -import { Color, FontWeight } from './types'; +import { Color, Font, FontFamily } from './types'; import { $Values as Values } from 'utility-types'; export const PartitionLayout = Object.freeze({ @@ -9,16 +9,28 @@ export const PartitionLayout = Object.freeze({ export type PartitionLayout = Values; // could use ValuesType -export interface FillLabel { +interface LabelConfig extends Font { textColor: Color; textInvertible: boolean; - textWeight: FontWeight; - fontStyle: string; - fontVariant: string; - fontFamily: string; + textOpacity: Ratio; formatter: (x: number) => string; } +export type FillLabelConfig = LabelConfig; + +export interface LinkLabelConfig extends LabelConfig { + fontSize: Pixels; // todo consider putting it in Font + maximumSection: Distance; // use linked labels below this limit + gap: Pixels; + spacing: Pixels; + minimumStemLength: Distance; + stemAngle: Radian; + horizontalStemLength: Distance; + radiusPadding: Distance; + lineWidth: Pixels; + maxCount: number; +} + // todo switch to `io-ts` style, generic way of combining static and runtime type info export interface StaticConfig { // shape geometry @@ -32,12 +44,12 @@ export interface StaticConfig { partitionLayout: PartitionLayout; // general text config - fontFamily: string; + fontFamily: FontFamily; // fill text config minFontSize: Pixels; maxFontSize: Pixels; - idealFontSizeJump: number; + idealFontSizeJump: Ratio; // fill text layout config circlePadding: Distance; @@ -47,26 +59,12 @@ export interface StaticConfig { maxRowCount: number; fillOutside: boolean; radiusOutside: Radius; - fillRectangleWidth: number; - fillRectangleHeight: number; - fillLabel: FillLabel; + fillRectangleWidth: Distance; + fillRectangleHeight: Distance; + fillLabel: FillLabelConfig; // linked labels (primarily: single-line) - linkLabel: { - maximumSection: number; // use linked labels below this limit - fontSize: Pixels; - gap: Pixels; - spacing: Pixels; - minimumStemLength: Distance; - stemAngle: Radian; - horizontalStemLength: Distance; - radiusPadding: Distance; - lineWidth: Pixels; - maxCount: number; - textColor: Color; - textInvertible: boolean; - textOpacity: number; - }; + linkLabel: LinkLabelConfig; // other backgroundColor: Color; diff --git a/src/chart_types/partition_chart/layout/types/types.ts b/src/chart_types/partition_chart/layout/types/types.ts index d808479f6a..eb76d4c194 100644 --- a/src/chart_types/partition_chart/layout/types/types.ts +++ b/src/chart_types/partition_chart/layout/types/types.ts @@ -2,9 +2,38 @@ import { ArrayEntry } from '../utils/group_by_rollup'; export type Color = string; // todo refine later (union type) -export type FontWeight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; // the aliases are now excluded: 'normal' | 'bold' | 'lighter' | 'bolder'; +export const FONT_VARIANTS = Object.freeze(['normal', 'small-caps'] as const); +export type FontVariant = typeof FONT_VARIANTS[number]; + +// prettier-ignore +export const FONT_WEIGHTS = Object.freeze([ + 100, 200, 300, 400, 500, 600, 700, 800, 900, + 'normal', 'bold', 'lighter', 'bolder', 'inherit', 'initial', 'unset', +] as const); +export type FontWeight = typeof FONT_WEIGHTS[number]; +export type NumericFontWeight = number & typeof FONT_WEIGHTS[number]; + +export const FONT_STYLES = Object.freeze(['normal', 'italic', 'oblique', 'inherit', 'initial', 'unset'] as const); +export type FontStyle = typeof FONT_STYLES[number]; + +/** todo consider doing tighter control for permissible font families, eg. as in Kibana Canvas - expression language + * - though the same applies for permissible (eg. known available or loaded) font weights, styles, variants... + */ +export type FontFamily = string; + +export interface Font { + fontStyle: FontStyle; + fontVariant: FontVariant; + fontWeight: FontWeight; + fontFamily: FontFamily; +} -export type TextMeasure = (font: string, texts: string[]) => TextMetrics[]; +export type PartialFont = Partial; + +export interface Box extends Font { + text: string; +} +export type TextMeasure = (fontSize: number, boxes: Box[]) => TextMetrics[]; /** * Part-to-whole visualizations such as treemap, sunburst, pie hinge on an aggregation diff --git a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts index 22e5961e68..eb598fc8bd 100644 --- a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts +++ b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts @@ -1,6 +1,6 @@ import { Config } from './config_types'; import { Coordinate, Distance, PointObject, PointTuple, Radian } from './geometry_types'; -import { Color, FontWeight } from './types'; +import { Color, NumericFontWeight } from './types'; import { config } from '../config/config'; export type LinkLabelVM = { @@ -38,7 +38,7 @@ export interface RowSet { id: string; rows: Array; fillTextColor: string; - fillTextWeight: FontWeight; + fillFontWeight: NumericFontWeight; fontFamily: string; fontStyle: string; fontVariant: string; diff --git a/src/chart_types/partition_chart/layout/utils/measure.ts b/src/chart_types/partition_chart/layout/utils/measure.ts index af4a20954e..df7a415243 100644 --- a/src/chart_types/partition_chart/layout/utils/measure.ts +++ b/src/chart_types/partition_chart/layout/utils/measure.ts @@ -1,8 +1,9 @@ -import { TextMeasure } from '../types/types'; +import { Box, TextMeasure } from '../types/types'; export function measureText(ctx: CanvasRenderingContext2D): TextMeasure { - return (font: string, texts: string[]): TextMetrics[] => { - ctx.font = font; - return texts.map((text) => ctx.measureText(text)); - }; + return (fontSize: number, boxes: Box[]): TextMetrics[] => + boxes.map(({ fontStyle, fontVariant, fontWeight, fontFamily, text }: Box) => { + ctx.font = `${fontStyle} ${fontVariant} ${fontWeight} ${fontSize}px ${fontFamily}`; + return ctx.measureText(text); + }); } diff --git a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts index 0dfbe8eeae..6791d04b3e 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts @@ -3,7 +3,7 @@ import { Coordinate, Distance, Pixels, Radian, Radius, RingSector } from '../typ import { Config } from '../types/config_types'; import { logarithm, TAU, trueBearingToStandardPositionAngle } from '../utils/math'; import { RawTextGetter, RowBox, RowSet, RowSpace, ShapeTreeNode } from '../types/viewmodel_types'; -import { FontWeight, TextMeasure } from '../types/types'; +import { Box, Font, NumericFontWeight, TextMeasure } from '../types/types'; import { AGGREGATE_KEY } from '../utils/group_by_rollup'; import { conjunctiveConstraint } from '../circline_geometry'; import { Layer } from '../../specs/index'; @@ -194,7 +194,7 @@ function identityRowSet(): RowSet { fontVariant: '', fontSize: NaN, fillTextColor: '', - fillTextWeight: 400, + fillFontWeight: 400, rotation: NaN, }; } @@ -202,11 +202,13 @@ function identityRowSet(): RowSet { function getAllBoxes( rawTextGetter: RawTextGetter, valueFormatter: (value: number) => string, + sizeInvariantFontShorthand: Font, node: ShapeTreeNode, -): string[] { +): Box[] { return rawTextGetter(node) .split(' ') - .concat(valueFormatter(node[AGGREGATE_KEY]).split(' ')); + .concat(valueFormatter(node[AGGREGATE_KEY]).split(' ')) + .map((text) => ({ text, ...sizeInvariantFontShorthand })); } function getWordSpacing(fontSize: number) { @@ -217,7 +219,7 @@ function fill( config: Config, layers: Layer[], fontSizes: string | any[], - measure: { (font: string, texts: string[]): TextMetrics[]; (arg0: string, arg1: any): any }, + measure: TextMeasure, rawTextGetter: RawTextGetter, valueFormatter: (value: number) => string, textFillOrigins: any[], @@ -231,14 +233,14 @@ function fill( const { textColor, textInvertible, - textWeight, fontStyle, fontVariant, fontFamily, + fontWeight, formatter, fillColor, } = Object.assign( - { fontFamily: config.fontFamily, fillColor: node.fill }, + { fontFamily: config.fontFamily, fontWeight: 'normal', fillColor: node.fill }, fillLabel, { formatter: valueFormatter }, layers[node.depth - 1] && layers[node.depth - 1].fillLabel, @@ -249,7 +251,13 @@ function fill( const shapeFillColor = typeof fillColor === 'function' ? fillColor(node, index, a) : fillColor; const { r: tr, g: tg, b: tb } = stringToRGB(textColor); let fontSizeIndex = fontSizes.length - 1; - const allBoxes = getAllBoxes(rawTextGetter, formatter, node); + const sizeInvariantFont: Font = { + fontStyle, + fontVariant, + fontWeight, + fontFamily, + }; + const allBoxes = getAllBoxes(rawTextGetter, formatter, sizeInvariantFont, node); let rowSet = identityRowSet(); let completed = false; const rotation = getRotation(node); @@ -261,13 +269,14 @@ function fill( const wordSpacing = getWordSpacing(fontSize); // model text pieces, obtaining their width at the current font size - const measurements = measure(fontSize + 'px ' + fontFamily, allBoxes); + const measurements = measure(fontSize, allBoxes); const allMeasuredBoxes: RowBox[] = measurements.map( ({ width, emHeightDescent, emHeightAscent }: TextMetrics, i: number) => ({ width, verticalOffset: -(emHeightDescent + emHeightAscent) / 2, // meaning, `middle`, - text: allBoxes[i], wordBeginning: NaN, + ...allBoxes[i], + fontSize, // iterated fontSize overrides a possible more global fontSize }), ); const linePitch = fontSize; @@ -290,7 +299,7 @@ function fill( // fontWeight must be a multiple of 100 for non-variable width fonts, otherwise weird things happen due to // https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#Fallback_weights - Fallback weights // todo factor out the discretization into a => FontWeight function - fillTextWeight: (Math.round(textWeight / 100) * 100) as FontWeight, + fillFontWeight: (Math.round(fontWeight / 100) * 100) as NumericFontWeight, fillTextColor: inverseForContrast ? `rgb(${255 - tr}, ${255 - tg}, ${255 - tb})` : textColor, rotation, rows: [...Array(targetRowCount)].map(() => ({ diff --git a/src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts b/src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts index 78a4175f2c..73f3b7e714 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts @@ -49,8 +49,8 @@ export function linkTextLayout( const stemToX = x + north * west * cy - west * relativeY; const stemToY = cy; const text = rawTextGetter(node); - const { width, emHeightAscent, emHeightDescent } = measure(linkLabel.fontSize + 'px ' + config.fontFamily, [ - text, + const { width, emHeightAscent, emHeightDescent } = measure(linkLabel.fontSize, [ + { fontFamily: config.fontFamily, ...linkLabel, text }, ])[0]; return { link: [ diff --git a/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts b/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts index e31fb0c052..14fc25c4b6 100644 --- a/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts +++ b/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts @@ -41,7 +41,7 @@ function clearCanvas( function renderTextRow( ctx: CanvasRenderingContext2D, - { fontFamily, fontSize, fillTextColor, fillTextWeight, fontStyle, fontVariant, rotation }: RowSet, + { fontFamily, fontSize, fillTextColor, fillFontWeight, fontStyle, fontVariant, rotation }: RowSet, ) { return (currentRow: TextRow) => { const crx = currentRow.rowCentroidX - (Math.cos(rotation) * currentRow.length) / 2; @@ -50,7 +50,7 @@ function renderTextRow( ctx.scale(1, -1); ctx.translate(crx, cry); ctx.rotate(-rotation); - ctx.font = fontStyle + ' ' + fontVariant + ' ' + fillTextWeight + ' ' + fontSize + 'px ' + fontFamily; + ctx.font = fontStyle + ' ' + fontVariant + ' ' + fillFontWeight + ' ' + fontSize + 'px ' + fontFamily; ctx.fillStyle = fillTextColor; currentRow.rowWords.forEach((box) => ctx.fillText(box.text, box.width / 2 + box.wordBeginning, 0)); }); diff --git a/src/chart_types/partition_chart/specs/index.ts b/src/chart_types/partition_chart/specs/index.ts index f8ac9902b7..12d0fa126d 100644 --- a/src/chart_types/partition_chart/specs/index.ts +++ b/src/chart_types/partition_chart/specs/index.ts @@ -4,14 +4,14 @@ import { FunctionComponent } from 'react'; import { getConnect, specComponentFactory } from '../../../state/spec_factory'; import { AccessorFn, IndexedAccessorFn } from '../../../utils/accessor'; import { Spec, SpecTypes } from '../../../specs/index'; -import { Config, FillLabel } from '../layout/types/config_types'; +import { Config, FillLabelConfig } from '../layout/types/config_types'; import { RecursivePartial } from '../../../utils/commons'; import { Datum } from '../../../utils/domain'; export interface Layer { groupByRollup: IndexedAccessorFn; nodeLabel?: (datum: Datum) => string; - fillLabel?: Partial; + fillLabel?: Partial; shape?: { fillColor: string | Function }; } diff --git a/src/index.ts b/src/index.ts index dc80a0e192..c2a2667cc9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,7 @@ export { AnnotationTooltipFormatter } from './chart_types/xy_chart/annotations/a export { GeometryValue } from './utils/geometry'; export { Config as PartitionConfig, - FillLabel as PartitionFillLabel, + FillLabelConfig as PartitionFillLabel, PartitionLayout, } from './chart_types/partition_chart/layout/types/config_types'; export { Layer as PartitionLayer } from './chart_types/partition_chart/specs/index'; diff --git a/stories/sunburst.tsx b/stories/sunburst.tsx index 6e35aff1b8..82421b2d76 100644 --- a/stories/sunburst.tsx +++ b/stories/sunburst.tsx @@ -332,7 +332,7 @@ export const TwoSlicesPieChart = () => ( ); TwoSlicesPieChart.story = { - name: 'Pie Chart with two slices', + name: 'Pie chart with two slices', }; export const LargeSmallPieChart = () => ( diff --git a/stories/treemap.tsx b/stories/treemap.tsx index 56f1bceb62..a3b0c1a768 100644 --- a/stories/treemap.tsx +++ b/stories/treemap.tsx @@ -89,7 +89,7 @@ export const MidTwoLayers = () => ( formatter: (d: number) => `${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, textColor: 'black', textInvertible: false, - textWeight: 200, + fontWeight: 200, fontStyle: 'normal', fontFamily: 'Helvetica', fontVariant: 'normal', @@ -146,7 +146,7 @@ export const TwoLayersStressTest = () => ( formatter: (d: number) => `${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, textColor: 'black', textInvertible: false, - textWeight: 200, + fontWeight: 200, fontStyle: 'normal', fontFamily: 'Helvetica', fontVariant: 'normal', @@ -203,7 +203,7 @@ export const MultiColor = () => ( formatter: (d: number) => `${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, textColor: 'rgb(60,60,60,1)', textInvertible: false, - textWeight: 100, + fontWeight: 100, fontStyle: 'normal', fontFamily: 'Din Condensed', fontVariant: 'normal', @@ -263,7 +263,7 @@ export const CustomStyle = () => ( formatter: (d: number) => `${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, textColor: 'rgb(60,60,60,1)', textInvertible: false, - textWeight: 600, + fontWeight: 600, fontStyle: 'normal', fontFamily: 'Courier New', fontVariant: 'normal', From 8de8e0eac5896c19db96df8991af2f0672180a80 Mon Sep 17 00:00:00 2001 From: monfera Date: Thu, 23 Jan 2020 18:17:49 +0100 Subject: [PATCH 2/9] chore: minor rename of formatter to valueFormatter --- .playground/playground.tsx | 2 +- .../partition_chart/layout/config/config.ts | 2 +- .../layout/types/config_types.ts | 2 +- .../layout/viewmodel/fill_text_layout.ts | 8 +-- stories/sunburst.tsx | 52 +++++++++---------- stories/treemap.tsx | 28 +++++----- 6 files changed, 47 insertions(+), 47 deletions(-) diff --git a/.playground/playground.tsx b/.playground/playground.tsx index b4edfad411..824d10b181 100644 --- a/.playground/playground.tsx +++ b/.playground/playground.tsx @@ -25,7 +25,7 @@ export class Playground extends React.Component<{}, { isSunburstShown: boolean } { groupByRollup: (d: Datum) => d.id, nodeLabel: (d: Datum) => d, - fillLabel: { formatter: (d: Datum) => `${d} pct` }, + fillLabel: { valueFormatter: (d: Datum) => `${d} pct` }, }, ]} /> diff --git a/src/chart_types/partition_chart/layout/config/config.ts b/src/chart_types/partition_chart/layout/config/config.ts index d5bb32f75b..c6a4e1afce 100644 --- a/src/chart_types/partition_chart/layout/config/config.ts +++ b/src/chart_types/partition_chart/layout/config/config.ts @@ -112,7 +112,7 @@ export const configMetadata = { type: 'string', values: FONT_VARIANTS, }, - formatter: { + valueFormatter: { dflt: defaultFormatter, type: 'function', }, 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 ea2cb0d35b..b8681c2014 100644 --- a/src/chart_types/partition_chart/layout/types/config_types.ts +++ b/src/chart_types/partition_chart/layout/types/config_types.ts @@ -13,7 +13,7 @@ interface LabelConfig extends Font { textColor: Color; textInvertible: boolean; textOpacity: Ratio; - formatter: (x: number) => string; + valueFormatter: (x: number) => string; } export type FillLabelConfig = LabelConfig; diff --git a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts index 6791d04b3e..1128fded46 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts @@ -221,7 +221,7 @@ function fill( fontSizes: string | any[], measure: TextMeasure, rawTextGetter: RawTextGetter, - valueFormatter: (value: number) => string, + formatter: (value: number) => string, textFillOrigins: any[], shapeConstructor: (n: ShapeTreeNode) => any, getShapeRowGeometry: (...args: any[]) => RowSpace, @@ -237,12 +237,12 @@ function fill( fontVariant, fontFamily, fontWeight, - formatter, + valueFormatter, fillColor, } = Object.assign( { fontFamily: config.fontFamily, fontWeight: 'normal', fillColor: node.fill }, fillLabel, - { formatter: valueFormatter }, + { valueFormatter: formatter }, layers[node.depth - 1] && layers[node.depth - 1].fillLabel, layers[node.depth - 1] && layers[node.depth - 1].shape, ); @@ -257,7 +257,7 @@ function fill( fontWeight, fontFamily, }; - const allBoxes = getAllBoxes(rawTextGetter, formatter, sizeInvariantFont, node); + const allBoxes = getAllBoxes(rawTextGetter, valueFormatter, sizeInvariantFont, node); let rowSet = identityRowSet(); let completed = false; const rotation = getRotation(node); diff --git a/stories/sunburst.tsx b/stories/sunburst.tsx index 82421b2d76..0c131a3a7a 100644 --- a/stories/sunburst.tsx +++ b/stories/sunburst.tsx @@ -32,7 +32,7 @@ export const SimplePieChart = () => ( id={'spec_' + getRandomNumber()} data={mocks.pie} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -59,7 +59,7 @@ export const PieChartWithFillLabels = () => ( id={'spec_' + getRandomNumber()} data={mocks.pie} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -78,7 +78,7 @@ export const PieChartWithFillLabels = () => ( }, fontFamily: 'Arial', fillLabel: { - formatter: (d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, + valueFormatter: (d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, fontStyle: 'italic', }, margin: { top: 0, bottom: 0, left: 0, right: 0 }, @@ -103,7 +103,7 @@ export const DonutChartWithFillLabels = () => ( id={'spec_' + getRandomNumber()} data={mocks.pie} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -122,7 +122,7 @@ export const DonutChartWithFillLabels = () => ( }, fontFamily: 'Arial', fillLabel: { - formatter: (d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, + valueFormatter: (d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, fontStyle: 'italic', }, margin: { top: 0, bottom: 0, left: 0.2, right: 0 }, @@ -149,7 +149,7 @@ export const PieChartLabels = () => ( { sitc1: 'Mineral fuels, lubricants and related materials', exportVal: 4 }, ]} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d))}`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d))}`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -177,7 +177,7 @@ export const SomeZeroValueSlice = () => ( .concat(mocks.pie.slice(2, 4).map((s) => ({ ...s, exportVal: 0 }))) .concat(mocks.pie.slice(4))} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -202,7 +202,7 @@ export const SunburstTwoLayers = () => ( id={'spec_' + getRandomNumber()} data={mocks.sunburst} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => countryLookup[d.dest].continentCountry.substr(0, 2), @@ -210,7 +210,7 @@ export const SunburstTwoLayers = () => ( fillLabel: { fontFamily: 'Impact', textInvertible: true, - formatter: (d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000000))}\xa0Tn`, + valueFormatter: (d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000000))}\xa0Tn`, }, shape: { fillColor: defaultFillColor(interpolatorCET2s), @@ -232,7 +232,7 @@ export const SunburstTwoLayers = () => ( }, fontFamily: 'Arial', fillLabel: { - formatter: (d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, + valueFormatter: (d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, fontStyle: 'italic', }, margin: { top: 0, bottom: 0, left: 0, right: 0 }, @@ -257,7 +257,7 @@ export const SunburstThreeLayers = () => ( id={'spec_' + getRandomNumber()} data={mocks.miniSunburst} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -292,7 +292,7 @@ export const SunburstThreeLayers = () => ( }, fontFamily: 'Arial', fillLabel: { - formatter: (d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, + valueFormatter: (d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, fontStyle: 'italic', }, margin: { top: 0, bottom: 0, left: 0, right: 0 }, @@ -316,7 +316,7 @@ export const TwoSlicesPieChart = () => ( id={'spec_' + getRandomNumber()} data={mocks.pie.slice(0, 2)} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -344,7 +344,7 @@ export const LargeSmallPieChart = () => ( { sitc1: 'Mineral fuels, lubricants and related materials', exportVal: 80 }, ]} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d))}`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d))}`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -376,7 +376,7 @@ export const VeryLargeSmallPieChart = () => ( { sitc1: 'Mineral fuels, lubricants and related materials', exportVal: 1 }, ]} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d))}`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d))}`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -404,7 +404,7 @@ export const BigEmptyPieChart = () => ( { sitc1: '3', exportVal: 1 }, ]} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d))}`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d))}`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -432,7 +432,7 @@ export const FullZeroSlicePieChart = () => ( { sitc1: '3', exportVal: 0 }, ]} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d))}`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d))}`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -456,7 +456,7 @@ export const SingleSlicePieChart = () => ( id={'spec_' + getRandomNumber()} data={mocks.pie.slice(0, 1)} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -480,7 +480,7 @@ export const NoSliceNoPie = () => ( id={'spec_' + getRandomNumber()} data={[]} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -507,7 +507,7 @@ export const NegativeNoPie = () => ( .concat(mocks.pie.slice(2, 3).map((s) => ({ ...s, exportVal: -0.1 }))) .concat(mocks.pie.slice(3))} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -531,7 +531,7 @@ export const TotalZeroNoPie = () => ( id={'spec_' + getRandomNumber()} data={mocks.pie.map((s) => ({ ...s, exportVal: 0 }))} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -556,7 +556,7 @@ export const HighNumberOfSlice = () => ( id={'spec_' + getRandomNumber()} data={mocks.manyPie} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.origin, @@ -584,7 +584,7 @@ export const CounterClockwiseSpecial = () => ( id={'spec_' + getRandomNumber()} data={mocks.pie} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -612,7 +612,7 @@ export const ClockwiseNoSpecial = () => ( id={'spec_' + getRandomNumber()} data={mocks.pie} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -640,7 +640,7 @@ export const LinkedLabelsOnly = () => ( id={'spec_' + getRandomNumber()} data={mocks.pie} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, @@ -667,7 +667,7 @@ export const NoLabels = () => ( id={'spec_' + getRandomNumber()} data={mocks.pie} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, diff --git a/stories/treemap.tsx b/stories/treemap.tsx index a3b0c1a768..257f1c7a87 100644 --- a/stories/treemap.tsx +++ b/stories/treemap.tsx @@ -32,14 +32,14 @@ export const OneLayer = () => ( id={'spec_' + getRandomNumber()} data={mocks.pie} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true, - formatter: (d: number) => `${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, + valueFormatter: (d: number) => `${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, }, shape: { fillColor: defaultFillColor(interpolatorCET2s), @@ -69,13 +69,13 @@ export const MidTwoLayers = () => ( id={'spec_' + getRandomNumber()} data={mocks.sunburst} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => countryLookup[d.dest].continentCountry.substr(0, 2), nodeLabel: (d: any) => regionLookup[d].regionName, fillLabel: { - formatter: (d: number) => `${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, + valueFormatter: (d: number) => `${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, fontFamily: 'Phosphate-Inline', textColor: 'yellow', textInvertible: false, @@ -86,7 +86,7 @@ export const MidTwoLayers = () => ( groupByRollup: (d: Datum) => d.dest, nodeLabel: (d: any) => countryLookup[d].name, fillLabel: { - formatter: (d: number) => `${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, + valueFormatter: (d: number) => `${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, textColor: 'black', textInvertible: false, fontWeight: 200, @@ -126,13 +126,13 @@ export const TwoLayersStressTest = () => ( id={'spec_' + getRandomNumber()} data={mocks.sunburst} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => d.sitc1, nodeLabel: (d: any) => productLookup[d].name.toUpperCase(), fillLabel: { - formatter: (d: number) => `${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, + valueFormatter: (d: number) => `${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, fontFamily: 'Phosphate-Inline', textColor: 'white', textInvertible: false, @@ -143,7 +143,7 @@ export const TwoLayersStressTest = () => ( groupByRollup: (d: Datum) => d.dest, nodeLabel: (d: any) => countryLookup[d].name, fillLabel: { - formatter: (d: number) => `${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, + valueFormatter: (d: number) => `${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, textColor: 'black', textInvertible: false, fontWeight: 200, @@ -184,13 +184,13 @@ export const MultiColor = () => ( id={'spec_' + getRandomNumber()} data={mocks.sunburst} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => countryLookup[d.dest].continentCountry.substr(0, 2), nodeLabel: () => '', fillLabel: { - formatter: () => '', + valueFormatter: () => '', }, shape: { fillColor: defaultFillColor(interpolatorCET2s), @@ -200,7 +200,7 @@ export const MultiColor = () => ( groupByRollup: (d: Datum) => d.dest, nodeLabel: (d: any) => countryLookup[d].name, fillLabel: { - formatter: (d: number) => `${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, + valueFormatter: (d: number) => `${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, textColor: 'rgb(60,60,60,1)', textInvertible: false, fontWeight: 100, @@ -241,13 +241,13 @@ export const CustomStyle = () => ( id={'spec_' + getRandomNumber()} data={mocks.sunburst} valueAccessor={(d: Datum) => d.exportVal as number} - valueFormatter={(d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} layers={[ { groupByRollup: (d: Datum) => countryLookup[d.dest].continentCountry.substr(0, 2), nodeLabel: () => '', fillLabel: { - formatter: () => '', + valueFormatter: () => '', }, shape: { fillColor: (d: any, i: any, a: any) => { @@ -260,7 +260,7 @@ export const CustomStyle = () => ( groupByRollup: (d: Datum) => d.dest, nodeLabel: (d: any) => countryLookup[d].name, fillLabel: { - formatter: (d: number) => `${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, + valueFormatter: (d: number) => `${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, textColor: 'rgb(60,60,60,1)', textInvertible: false, fontWeight: 600, From e43fa15c8279d4387423a784ae07225e6e798723 Mon Sep 17 00:00:00 2001 From: monfera Date: Thu, 23 Jan 2020 23:50:43 +0100 Subject: [PATCH 3/9] feat: valueFormatter --- .../partition_chart/layout/config/config.ts | 23 +++++++++++ .../layout/types/config_types.ts | 3 +- .../layout/types/viewmodel_types.ts | 8 +--- .../partition_chart/layout/utils/measure.ts | 13 +++++-- .../layout/viewmodel/fill_text_layout.ts | 34 +++++++++------- .../renderer/canvas/canvas_renderers.ts | 12 +++--- .../partition_chart/specs/index.ts | 4 +- stories/sunburst.tsx | 39 ++++++++++++++++++- 8 files changed, 103 insertions(+), 33 deletions(-) diff --git a/src/chart_types/partition_chart/layout/config/config.ts b/src/chart_types/partition_chart/layout/config/config.ts index c6a4e1afce..ae951beeef 100644 --- a/src/chart_types/partition_chart/layout/config/config.ts +++ b/src/chart_types/partition_chart/layout/config/config.ts @@ -25,6 +25,27 @@ function defaultFormatter(d: any): string { : String(d); } +const valueFont = { + type: 'group', + values: { + /* fontFamily: { + dflt: null, + type: 'string', + },*/ + fontWeight: { dflt: 400, min: 100, max: 900, type: 'number' }, + fontStyle: { + dflt: 'normal', + type: 'string', + values: FONT_STYLES, + }, + fontVariant: { + dflt: 'normal', + type: 'string', + values: FONT_VARIANTS, + }, + }, +}; + export const configMetadata = { // shape geometry width: { dflt: 300, min: 0, max: 1024, type: 'number', reconfigurable: false }, @@ -116,6 +137,7 @@ export const configMetadata = { dflt: defaultFormatter, type: 'function', }, + valueFont, }, }, @@ -161,6 +183,7 @@ export const configMetadata = { type: 'number', reconfigurable: false, // currently only tau / 8 is reliable }, + valueFont, }, }, 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 b8681c2014..52d0f6bad7 100644 --- a/src/chart_types/partition_chart/layout/types/config_types.ts +++ b/src/chart_types/partition_chart/layout/types/config_types.ts @@ -1,5 +1,5 @@ import { Distance, Pixels, Radian, Radius, Ratio, SizeRatio, TimeMs } from './geometry_types'; -import { Color, Font, FontFamily } from './types'; +import { Color, Font, FontFamily, PartialFont } from './types'; import { $Values as Values } from 'utility-types'; export const PartitionLayout = Object.freeze({ @@ -14,6 +14,7 @@ interface LabelConfig extends Font { textInvertible: boolean; textOpacity: Ratio; valueFormatter: (x: number) => string; + valueFont: PartialFont; } export type FillLabelConfig = LabelConfig; diff --git a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts index eb598fc8bd..1398a8fe32 100644 --- a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts +++ b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts @@ -1,6 +1,6 @@ import { Config } from './config_types'; import { Coordinate, Distance, PointObject, PointTuple, Radian } from './geometry_types'; -import { Color, NumericFontWeight } from './types'; +import { Color, Font } from './types'; import { config } from '../config/config'; export type LinkLabelVM = { @@ -12,7 +12,7 @@ export type LinkLabelVM = { verticalOffset: Distance; }; -export interface RowBox { +export interface RowBox extends Font { text: string; width: Distance; verticalOffset: Distance; @@ -38,10 +38,6 @@ export interface RowSet { id: string; rows: Array; fillTextColor: string; - fillFontWeight: NumericFontWeight; - fontFamily: string; - fontStyle: string; - fontVariant: string; fontSize: number; rotation: Radian; } diff --git a/src/chart_types/partition_chart/layout/utils/measure.ts b/src/chart_types/partition_chart/layout/utils/measure.ts index df7a415243..91d32c2e08 100644 --- a/src/chart_types/partition_chart/layout/utils/measure.ts +++ b/src/chart_types/partition_chart/layout/utils/measure.ts @@ -1,9 +1,14 @@ -import { Box, TextMeasure } from '../types/types'; +import { Box, Font, TextMeasure } from '../types/types'; +import { Pixels } from '../types/geometry_types'; + +export function cssFontShorthand({ fontStyle, fontVariant, fontWeight, fontFamily }: Font, fontSize: Pixels) { + return `${fontStyle} ${fontVariant} ${fontWeight} ${fontSize}px ${fontFamily}`; +} export function measureText(ctx: CanvasRenderingContext2D): TextMeasure { return (fontSize: number, boxes: Box[]): TextMetrics[] => - boxes.map(({ fontStyle, fontVariant, fontWeight, fontFamily, text }: Box) => { - ctx.font = `${fontStyle} ${fontVariant} ${fontWeight} ${fontSize}px ${fontFamily}`; - return ctx.measureText(text); + boxes.map((box: Box) => { + ctx.font = cssFontShorthand(box, fontSize); + return ctx.measureText(box.text); }); } diff --git a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts index 1128fded46..c1e83b1bfb 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts @@ -3,7 +3,7 @@ import { Coordinate, Distance, Pixels, Radian, Radius, RingSector } from '../typ import { Config } from '../types/config_types'; import { logarithm, TAU, trueBearingToStandardPositionAngle } from '../utils/math'; import { RawTextGetter, RowBox, RowSet, RowSpace, ShapeTreeNode } from '../types/viewmodel_types'; -import { Box, Font, NumericFontWeight, TextMeasure } from '../types/types'; +import { Box, Font, PartialFont, TextMeasure } from '../types/types'; import { AGGREGATE_KEY } from '../utils/group_by_rollup'; import { conjunctiveConstraint } from '../circline_geometry'; import { Layer } from '../../specs/index'; @@ -189,12 +189,8 @@ function identityRowSet(): RowSet { return { id: '', rows: [], - fontFamily: '', - fontStyle: '', - fontVariant: '', fontSize: NaN, fillTextColor: '', - fillFontWeight: 400, rotation: NaN, }; } @@ -203,12 +199,17 @@ function getAllBoxes( rawTextGetter: RawTextGetter, valueFormatter: (value: number) => string, sizeInvariantFontShorthand: Font, + valueFont: PartialFont, node: ShapeTreeNode, ): Box[] { return rawTextGetter(node) .split(' ') - .concat(valueFormatter(node[AGGREGATE_KEY]).split(' ')) - .map((text) => ({ text, ...sizeInvariantFontShorthand })); + .map((text) => ({ text, ...sizeInvariantFontShorthand })) + .concat( + valueFormatter(node[AGGREGATE_KEY]) + .split(' ') + .map((text) => ({ text, ...sizeInvariantFontShorthand, ...valueFont })), + ); } function getWordSpacing(fontSize: number) { @@ -230,6 +231,7 @@ function fill( return (node: ShapeTreeNode, index: number, a: ShapeTreeNode[]) => { const { maxRowCount, fillLabel } = config; + const layer = layers[node.depth - 1] || {}; const { textColor, textInvertible, @@ -243,8 +245,16 @@ function fill( { fontFamily: config.fontFamily, fontWeight: 'normal', fillColor: node.fill }, fillLabel, { valueFormatter: formatter }, - layers[node.depth - 1] && layers[node.depth - 1].fillLabel, - layers[node.depth - 1] && layers[node.depth - 1].shape, + layer.fillLabel, + layer.shape, + ); + + const valueFont = Object.assign( + { fontFamily: config.fontFamily, fontWeight: 'normal' }, + fillLabel, + fillLabel.valueFont, + layer.fillLabel, + layer.fillLabel && layer.fillLabel.valueFont, ); const specifiedTextColorIsDark = colorIsDark(textColor); @@ -257,7 +267,7 @@ function fill( fontWeight, fontFamily, }; - const allBoxes = getAllBoxes(rawTextGetter, valueFormatter, sizeInvariantFont, node); + const allBoxes = getAllBoxes(rawTextGetter, valueFormatter, sizeInvariantFont, valueFont, node); let rowSet = identityRowSet(); let completed = false; const rotation = getRotation(node); @@ -293,13 +303,9 @@ function fill( rowSet = { id: nodeId(node), fontSize, - fontFamily, - fontStyle, - fontVariant, // fontWeight must be a multiple of 100 for non-variable width fonts, otherwise weird things happen due to // https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#Fallback_weights - Fallback weights // todo factor out the discretization into a => FontWeight function - fillFontWeight: (Math.round(fontWeight / 100) * 100) as NumericFontWeight, fillTextColor: inverseForContrast ? `rgb(${255 - tr}, ${255 - tg}, ${255 - tb})` : textColor, rotation, rows: [...Array(targetRowCount)].map(() => ({ diff --git a/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts b/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts index 14fc25c4b6..28154989d4 100644 --- a/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts +++ b/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts @@ -10,6 +10,7 @@ import { } from '../../layout/types/viewmodel_types'; import { TAU } from '../../layout/utils/math'; import { PartitionLayout } from '../../layout/types/config_types'; +import { cssFontShorthand } from '../../layout/utils/measure'; // the burnout avoidance in the center of the pie const LINE_WIDTH_MULT = 10; // border can be a maximum 1/LINE_WIDTH_MULT - th of the sector angle, otherwise the border would dominate @@ -39,10 +40,7 @@ function clearCanvas( }); } -function renderTextRow( - ctx: CanvasRenderingContext2D, - { fontFamily, fontSize, fillTextColor, fillFontWeight, fontStyle, fontVariant, rotation }: RowSet, -) { +function renderTextRow(ctx: CanvasRenderingContext2D, { fontSize, fillTextColor, rotation }: RowSet) { return (currentRow: TextRow) => { const crx = currentRow.rowCentroidX - (Math.cos(rotation) * currentRow.length) / 2; const cry = -currentRow.rowCentroidY + (Math.sin(rotation) * currentRow.length) / 2; @@ -50,9 +48,11 @@ function renderTextRow( ctx.scale(1, -1); ctx.translate(crx, cry); ctx.rotate(-rotation); - ctx.font = fontStyle + ' ' + fontVariant + ' ' + fillFontWeight + ' ' + fontSize + 'px ' + fontFamily; ctx.fillStyle = fillTextColor; - currentRow.rowWords.forEach((box) => ctx.fillText(box.text, box.width / 2 + box.wordBeginning, 0)); + currentRow.rowWords.forEach((box) => { + ctx.font = cssFontShorthand(box, fontSize); + ctx.fillText(box.text, box.width / 2 + box.wordBeginning, 0); + }); }); }; } diff --git a/src/chart_types/partition_chart/specs/index.ts b/src/chart_types/partition_chart/specs/index.ts index 12d0fa126d..5a2bf655ad 100644 --- a/src/chart_types/partition_chart/specs/index.ts +++ b/src/chart_types/partition_chart/specs/index.ts @@ -8,11 +8,13 @@ import { Config, FillLabelConfig } from '../layout/types/config_types'; import { RecursivePartial } from '../../../utils/commons'; import { Datum } from '../../../utils/domain'; +type ColorAccessor = (d: Datum, index: number, array: Datum[]) => string; + export interface Layer { groupByRollup: IndexedAccessorFn; nodeLabel?: (datum: Datum) => string; fillLabel?: Partial; - shape?: { fillColor: string | Function }; + shape?: { fillColor: string | ColorAccessor }; } const defaultProps = { diff --git a/stories/sunburst.tsx b/stories/sunburst.tsx index 0c131a3a7a..a5b34c583b 100644 --- a/stories/sunburst.tsx +++ b/stories/sunburst.tsx @@ -47,7 +47,44 @@ export const SimplePieChart = () => ( ); SimplePieChart.story = { - name: 'Most basic PieChart', + name: 'Most basic pie chart', + info: { + source: false, + }, +}; + +export const ValueFormattedPieChart = () => ( + + d.exportVal as number} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} + layers={[ + { + groupByRollup: (d: Datum) => d.sitc1, + nodeLabel: (d: Datum) => productLookup[d].name, + fillLabel: { + textInvertible: true, + fontWeight: 100, + fontStyle: 'italic', + valueFont: { + fontFamily: 'Menlo', + fontStyle: 'normal', + fontWeight: 900, + }, + }, + shape: { + fillColor: defaultFillColor(interpolatorTurbo), + }, + }, + ]} + config={{ outerSizeRatio: 0.9 }} + /> + +); +ValueFormattedPieChart.story = { + name: 'Value formatted pie chart', info: { source: false, }, From a4f63529ae9259770216614b631bc085739a4fbe Mon Sep 17 00:00:00 2001 From: monfera Date: Fri, 24 Jan 2020 00:49:39 +0100 Subject: [PATCH 4/9] feat: opacity decrease example --- .../partition_chart/layout/config/config.ts | 6 +++--- .../layout/viewmodel/fill_text_layout.ts | 1 + stories/sunburst.tsx | 21 ++++++++++++------- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/chart_types/partition_chart/layout/config/config.ts b/src/chart_types/partition_chart/layout/config/config.ts index ae951beeef..af79564522 100644 --- a/src/chart_types/partition_chart/layout/config/config.ts +++ b/src/chart_types/partition_chart/layout/config/config.ts @@ -28,10 +28,10 @@ function defaultFormatter(d: any): string { const valueFont = { type: 'group', values: { - /* fontFamily: { - dflt: null, + fontFamily: { + dflt: undefined, type: 'string', - },*/ + }, fontWeight: { dflt: 400, min: 100, max: 900, type: 'number' }, fontStyle: { dflt: 'normal', diff --git a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts index c1e83b1bfb..3c07d6f8b4 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts @@ -251,6 +251,7 @@ function fill( const valueFont = Object.assign( { fontFamily: config.fontFamily, fontWeight: 'normal' }, + config.fillLabel && config.fillLabel.valueFont, fillLabel, fillLabel.valueFont, layer.fillLabel, diff --git a/stories/sunburst.tsx b/stories/sunburst.tsx index a5b34c583b..ccf3b6595c 100644 --- a/stories/sunburst.tsx +++ b/stories/sunburst.tsx @@ -26,6 +26,9 @@ const interpolatorTurbo = hueInterpolator(palettes.turbo.map(([r, g, b]) => [r, const defaultFillColor = (colorMaker: any) => (d: any, i: number, a: any[]) => colorMaker(i / (a.length + 1)); +const decreasingOpacityCET2 = (opacity: number) => (d: any, i: number, a: any[]) => + hueInterpolator(palettes.CET2s.map(([r, g, b]) => [r, g, b, opacity]))(i / (a.length + 1)); + export const SimplePieChart = () => ( ( - + ( { groupByRollup: (d: Datum) => d.sitc1, nodeLabel: (d: any) => productLookup[d].name, - fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: decreasingOpacityCET2(0.8), }, }, { groupByRollup: (d: Datum) => countryLookup[d.dest].continentCountry.substr(0, 2), nodeLabel: (d: any) => regionLookup[d].regionName, - fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: decreasingOpacityCET2(0.65), }, }, { groupByRollup: (d: Datum) => d.dest, nodeLabel: (d: any) => countryLookup[d].name, - fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: decreasingOpacityCET2(0.5), }, }, ]} @@ -331,6 +331,13 @@ export const SunburstThreeLayers = () => ( fillLabel: { valueFormatter: (d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, fontStyle: 'italic', + textInvertible: true, + fontWeight: 900, + valueFont: { + fontFamily: 'Menlo', + fontStyle: 'normal', + fontWeight: 100, + }, }, margin: { top: 0, bottom: 0, left: 0, right: 0 }, minFontSize: 1, From f4e063460952c6dcdfec3adab8b8d709d980c052 Mon Sep 17 00:00:00 2001 From: monfera Date: Fri, 24 Jan 2020 10:56:00 +0100 Subject: [PATCH 5/9] feat: convenient value formatting in linked labels --- .../partition_chart/layout/config/config.ts | 3 + .../layout/types/viewmodel_types.ts | 2 + .../layout/viewmodel/fill_text_layout.ts | 6 +- .../layout/viewmodel/link_text_layout.ts | 5 +- .../layout/viewmodel/viewmodel.ts | 2 + .../renderer/canvas/canvas_renderers.ts | 5 +- stories/sunburst.tsx | 56 +++++++++++++++++++ 7 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/chart_types/partition_chart/layout/config/config.ts b/src/chart_types/partition_chart/layout/config/config.ts index af79564522..ef031c89e9 100644 --- a/src/chart_types/partition_chart/layout/config/config.ts +++ b/src/chart_types/partition_chart/layout/config/config.ts @@ -28,10 +28,13 @@ function defaultFormatter(d: any): string { const valueFont = { type: 'group', values: { + /* + // Object.assign interprets the extant `undefined` as legit, so commenting it out till moving away from Object.assign in `const valueFont = ...` fontFamily: { dflt: undefined, type: 'string', }, + */ fontWeight: { dflt: 400, min: 100, max: 900, type: 'number' }, fontStyle: { dflt: 'normal', diff --git a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts index 1398a8fe32..87fd154fba 100644 --- a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts +++ b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts @@ -8,6 +8,7 @@ export type LinkLabelVM = { translate: [number, number]; textAlign: CanvasTextAlign; text: string; + valueText: string; width: Distance; verticalOffset: Distance; }; @@ -99,3 +100,4 @@ export interface ShapeTreeNode extends TreeNode, SectorGeomSpecY { } export type RawTextGetter = (node: ShapeTreeNode) => string; +export type ValueFormatter = (value: number) => string; diff --git a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts index 3c07d6f8b4..bf0f8ea60d 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts @@ -2,7 +2,7 @@ import { wrapToTau } from '../geometry'; import { Coordinate, Distance, Pixels, Radian, Radius, RingSector } from '../types/geometry_types'; import { Config } from '../types/config_types'; import { logarithm, TAU, trueBearingToStandardPositionAngle } from '../utils/math'; -import { RawTextGetter, RowBox, RowSet, RowSpace, ShapeTreeNode } from '../types/viewmodel_types'; +import { RawTextGetter, RowBox, RowSet, RowSpace, ShapeTreeNode, ValueFormatter } from '../types/viewmodel_types'; import { Box, Font, PartialFont, TextMeasure } from '../types/types'; import { AGGREGATE_KEY } from '../utils/group_by_rollup'; import { conjunctiveConstraint } from '../circline_geometry'; @@ -197,7 +197,7 @@ function identityRowSet(): RowSet { function getAllBoxes( rawTextGetter: RawTextGetter, - valueFormatter: (value: number) => string, + valueFormatter: ValueFormatter, sizeInvariantFontShorthand: Font, valueFont: PartialFont, node: ShapeTreeNode, @@ -373,7 +373,7 @@ function fill( } } } - rowSet.rows = rowSet.rows.filter((r) => !isNaN(r.length)); + rowSet.rows = rowSet.rows.filter((r) => completed && !isNaN(r.length)); return rowSet; }; } diff --git a/src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts b/src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts index 73f3b7e714..d6d7738226 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts @@ -1,9 +1,10 @@ import { Distance } from '../types/geometry_types'; import { Config } from '../types/config_types'; import { TAU, trueBearingToStandardPositionAngle } from '../utils/math'; -import { LinkLabelVM, ShapeTreeNode } from '../types/viewmodel_types'; +import { LinkLabelVM, ShapeTreeNode, ValueFormatter } from '../types/viewmodel_types'; import { meanAngle } from '../geometry'; import { TextMeasure } from '../types/types'; +import { AGGREGATE_KEY } from '../utils/group_by_rollup'; // todo modularize this large function export function linkTextLayout( @@ -13,6 +14,7 @@ export function linkTextLayout( currentY: Distance[], anchorRadius: Distance, rawTextGetter: Function, + valueFormatter: ValueFormatter, ): LinkLabelVM[] { const { linkLabel } = config; const maxDepth = nodesWithoutRoom.reduce((p: number, n: ShapeTreeNode) => Math.max(p, n.depth), 0); @@ -62,6 +64,7 @@ export function linkTextLayout( translate: [stemToX + west * (linkLabel.horizontalStemLength + linkLabel.gap), stemToY], textAlign: side ? 'left' : 'right', text, + valueText: valueFormatter(node[AGGREGATE_KEY]), width, verticalOffset: -(emHeightDescent + emHeightAscent) / 2, // meaning, `middle` }; diff --git a/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts b/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts index 11398bbe1d..8b604d1c98 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts @@ -244,6 +244,7 @@ export function shapeViewModel( treemapLayout ? () => 0 : inSectorRotation(config.horizontalTextEnforcer, config.horizontalTextAngleThreshold), ); + // whiskers (ie. just lines, no text) for fill text outside the outer radius const outsideLinksViewModel = makeOutsideLinksViewModel(outsideFillNodes, rowSets, linkLabel.radiusPadding); // linked text @@ -266,6 +267,7 @@ export function shapeViewModel( currentY, outerRadius, rawTextGetter, + valueFormatter, ); // combined viewModel diff --git a/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts b/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts index 28154989d4..50fc48cf64 100644 --- a/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts +++ b/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts @@ -183,7 +183,7 @@ function renderLinkLabels( ctx.strokeStyle = linkLabelTextColor; ctx.fillStyle = linkLabelTextColor; ctx.font = `${400} ${linkLabelFontSize}px ${fontFamily}`; - viewModels.forEach(({ link, translate, textAlign, text }: LinkLabelVM) => { + viewModels.forEach(({ link, translate, textAlign, text, valueText }: LinkLabelVM) => { ctx.beginPath(); ctx.moveTo(...link[0]); link.slice(1).forEach((point) => ctx.lineTo(...point)); @@ -192,7 +192,8 @@ function renderLinkLabels( ctx.translate(...translate); ctx.scale(1, -1); // flip for text rendering not to be upside down ctx.textAlign = textAlign; - ctx.fillText(text, 0, 0); + // only use a colon if both text and valueText are non-zero length strings + ctx.fillText(text + (text && valueText ? ': ' : '') + valueText, 0, 0); }); }); }); diff --git a/stories/sunburst.tsx b/stories/sunburst.tsx index ccf3b6595c..195523f13e 100644 --- a/stories/sunburst.tsx +++ b/stories/sunburst.tsx @@ -411,6 +411,7 @@ export const LargeSmallPieChart = () => ( LargeSmallPieChart.story = { name: 'Pie chart with one large and one small slice', }; + export const VeryLargeSmallPieChart = () => ( ( FullZeroSlicePieChart.story = { name: 'Pie chart with one full and one zero slice', }; + export const SingleSlicePieChart = () => ( ( SingleSlicePieChart.story = { name: 'Pie chart with a single slice', }; + +export const SingleSmallSlicePieChart = () => ( + + d.exportVal as number} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} + layers={[ + { + groupByRollup: (d: Datum) => d.sitc1, + nodeLabel: (d: Datum) => productLookup[d].name, + fillLabel: { textInvertible: true }, + shape: { + fillColor: defaultFillColor(interpolatorCET2s), + }, + }, + ]} + config={{ partitionLayout: PartitionLayout.sunburst, outerSizeRatio: 0.15 }} + /> + +); +SingleSmallSlicePieChart.story = { + name: 'Small pie chart with a single slice', +}; + +export const SingleVerySmallSlicePieChart = () => ( + + d.exportVal as number} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} + layers={[ + { + groupByRollup: (d: Datum) => d.sitc1, + nodeLabel: (d: Datum) => productLookup[d].name, + fillLabel: { textInvertible: true }, + shape: { + fillColor: defaultFillColor(interpolatorCET2s), + }, + }, + ]} + config={{ partitionLayout: PartitionLayout.sunburst, outerSizeRatio: 0.03 }} + /> + +); +SingleVerySmallSlicePieChart.story = { + name: 'Very small pie chart with a single slice', +}; + export const NoSliceNoPie = () => ( ( NoSliceNoPie.story = { name: 'No pie chart if no slices', }; + export const NegativeNoPie = () => ( ( NegativeNoPie.story = { name: 'No pie chart if some slices are negative', }; + export const TotalZeroNoPie = () => ( ( LinkedLabelsOnly.story = { name: 'Linked labels only', }; + export const NoLabels = () => ( Date: Fri, 24 Jan 2020 20:50:11 +0100 Subject: [PATCH 6/9] feat: hierarchical data made available for accessors - hierarchical data: parent, sortIndex - tighter types - font color inversion now considers rgba() opacity, if any - a couple of todo-s solved: - removed some code duplication (shape fill calculation) - eliminated the need to index-join childrenNodes and quadViewModel --- .../layout/types/viewmodel_types.ts | 7 +- .../layout/utils/group_by_rollup.ts | 47 +++++- .../partition_chart/layout/utils/sunburst.ts | 9 +- .../layout/viewmodel/fill_text_layout.ts | 37 ++--- .../layout/viewmodel/viewmodel.ts | 80 +++++----- stories/sunburst.tsx | 142 ++++++++++++------ stories/treemap.tsx | 83 ++++++++-- stories/utils/utils.ts | 34 +++++ 8 files changed, 305 insertions(+), 134 deletions(-) create mode 100644 stories/utils/utils.ts diff --git a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts index 87fd154fba..79ced549f9 100644 --- a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts +++ b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts @@ -2,6 +2,7 @@ import { Config } from './config_types'; import { Coordinate, Distance, PointObject, PointTuple, Radian } from './geometry_types'; import { Color, Font } from './types'; import { config } from '../config/config'; +import { ArrayNode } from '../utils/group_by_rollup'; export type LinkLabelVM = { link: [PointTuple, ...PointTuple[]]; // at least one point @@ -43,7 +44,7 @@ export interface RowSet { rotation: Radian; } -export interface QuadViewModel extends RingSectorGeometry { +export interface QuadViewModel extends ShapeTreeNode { strokeWidth: number; fillColor: string; } @@ -94,9 +95,11 @@ export interface RingSectorGeometry extends AngleFromTo, SectorGeomSpecY {} export interface ShapeTreeNode extends TreeNode, SectorGeomSpecY { yMidPx: Distance; depth: number; - inRingIndex: number; + sortIndex: number; dataName: any; value: number; + parent: ArrayNode; + children: ShapeTreeNode[]; } export type RawTextGetter = (node: ShapeTreeNode) => string; diff --git a/src/chart_types/partition_chart/layout/utils/group_by_rollup.ts b/src/chart_types/partition_chart/layout/utils/group_by_rollup.ts index 97b881a3cb..f081ee7101 100644 --- a/src/chart_types/partition_chart/layout/utils/group_by_rollup.ts +++ b/src/chart_types/partition_chart/layout/utils/group_by_rollup.ts @@ -3,6 +3,8 @@ import { Relation } from '../types/types'; export const AGGREGATE_KEY = 'value'; // todo later switch back to 'aggregate' export const DEPTH_KEY = 'depth'; export const CHILDREN_KEY = 'children'; +export const PARENT_KEY = 'parent'; +export const SORT_INDEX_KEY = 'sortIndex'; interface NodeDescriptor { [AGGREGATE_KEY]: number; @@ -11,13 +13,16 @@ interface NodeDescriptor { export type ArrayEntry = [Key, ArrayNode]; export type HierarchyOfArrays = Array; -interface ArrayNode extends NodeDescriptor { - [CHILDREN_KEY]?: HierarchyOfArrays; +export interface ArrayNode extends NodeDescriptor { + [CHILDREN_KEY]: HierarchyOfArrays; + [PARENT_KEY]: ArrayNode; + [SORT_INDEX_KEY]: number; } type HierarchyOfMaps = Map; interface MapNode extends NodeDescriptor { [CHILDREN_KEY]?: HierarchyOfMaps; + [PARENT_KEY]?: ArrayNode; } export type PrimitiveValue = string | number | null; // there could be more but sufficient for now @@ -31,12 +36,18 @@ export const entryValue = ([, value]: ArrayEntry) => value; export function depthAccessor(n: ArrayEntry) { return entryValue(n)[DEPTH_KEY]; } -export function aggregateAccessor(n: ArrayEntry) { +export function aggregateAccessor(n: ArrayEntry): number { return entryValue(n)[AGGREGATE_KEY]; } +export function parentAccessor(n: ArrayEntry): ArrayNode { + return entryValue(n)[PARENT_KEY]; +} export function childrenAccessor(n: ArrayEntry) { return entryValue(n)[CHILDREN_KEY]; } +export function sortIndexAccessor(n: ArrayEntry) { + return entryValue(n)[SORT_INDEX_KEY]; +} const ascending: Sorter = (a, b) => a - b; const descending: Sorter = (a, b) => b - a; @@ -78,21 +89,41 @@ export function groupByRollup( return reductionMap; } +function getRootArrayNode(): ArrayNode { + const children: HierarchyOfArrays = []; + const bootstrap = { [AGGREGATE_KEY]: NaN, [DEPTH_KEY]: NaN, [CHILDREN_KEY]: children }; + Object.assign(bootstrap, { [PARENT_KEY]: bootstrap }); + const result: ArrayNode = bootstrap as ArrayNode; + return result; +} + export function mapsToArrays(root: HierarchyOfMaps, sorter: NodeSorter): HierarchyOfArrays { - const groupByMap = (node: HierarchyOfMaps) => + const groupByMap = (node: HierarchyOfMaps, parent: ArrayNode) => Array.from( node, ([key, value]: [Key, MapNode]): ArrayEntry => { const valueElement = value[CHILDREN_KEY]; + const resultNode: ArrayNode = { + [AGGREGATE_KEY]: NaN, + [CHILDREN_KEY]: [], + [DEPTH_KEY]: NaN, + [SORT_INDEX_KEY]: NaN, + [PARENT_KEY]: parent, + }; const newValue: ArrayNode = Object.assign( - {}, + resultNode, value, - valueElement && { [CHILDREN_KEY]: groupByMap(valueElement) }, + valueElement && { [CHILDREN_KEY]: groupByMap(valueElement, resultNode) }, ); return [key, newValue]; }, - ).sort(sorter); // with the current algo, decreasing order is important - return groupByMap(root); + ) + .sort(sorter) + .map((n: ArrayEntry, i) => { + entryValue(n).sortIndex = i; + return n; + }); // with the current algo, decreasing order is important + return groupByMap(root, getRootArrayNode()); } export function mapEntryValue(entry: ArrayEntry) { diff --git a/src/chart_types/partition_chart/layout/utils/sunburst.ts b/src/chart_types/partition_chart/layout/utils/sunburst.ts index 9c953561fb..00effba28b 100644 --- a/src/chart_types/partition_chart/layout/utils/sunburst.ts +++ b/src/chart_types/partition_chart/layout/utils/sunburst.ts @@ -16,15 +16,14 @@ export function sunburst( const index = clockwiseSectors ? i : nodeCount - i - 1; const node = nodes[depth === 1 && specialFirstInnermostSector ? (index + 1) % nodeCount : index]; const area = areaAccessor(node); - const X0 = currentOffsetX; - currentOffsetX += area; - result.push({ node, x0: X0, y0, x1: X0 + area, y1: y0 + 1 }); + result.push({ node, x0: currentOffsetX, y0, x1: currentOffsetX + area, y1: y0 + 1 }); const children = childrenAccessor(node); if (children && children.length) { - laySubtree(children, { x0: X0, y0: y0 + 1 }, depth + 1); + laySubtree(children, { x0: currentOffsetX, y0: y0 + 1 }, depth + 1); } + currentOffsetX += area; } }; - laySubtree(nodes, { x0, y0 }, 1); + laySubtree(nodes, { x0, y0 }, 0); return result; } diff --git a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts index bf0f8ea60d..a919815b99 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts @@ -2,7 +2,15 @@ import { wrapToTau } from '../geometry'; import { Coordinate, Distance, Pixels, Radian, Radius, RingSector } from '../types/geometry_types'; import { Config } from '../types/config_types'; import { logarithm, TAU, trueBearingToStandardPositionAngle } from '../utils/math'; -import { RawTextGetter, RowBox, RowSet, RowSpace, ShapeTreeNode, ValueFormatter } from '../types/viewmodel_types'; +import { + QuadViewModel, + RawTextGetter, + RowBox, + RowSet, + RowSpace, + ShapeTreeNode, + ValueFormatter, +} from '../types/viewmodel_types'; import { Box, Font, PartialFont, TextMeasure } from '../types/types'; import { AGGREGATE_KEY } from '../utils/group_by_rollup'; import { conjunctiveConstraint } from '../circline_geometry'; @@ -228,21 +236,12 @@ function fill( getShapeRowGeometry: (...args: any[]) => RowSpace, getRotation: Function, ) { - return (node: ShapeTreeNode, index: number, a: ShapeTreeNode[]) => { + return (node: QuadViewModel, index: number) => { const { maxRowCount, fillLabel } = config; const layer = layers[node.depth - 1] || {}; - const { - textColor, - textInvertible, - fontStyle, - fontVariant, - fontFamily, - fontWeight, - valueFormatter, - fillColor, - } = Object.assign( - { fontFamily: config.fontFamily, fontWeight: 'normal', fillColor: node.fill }, + const { textColor, textInvertible, fontStyle, fontVariant, fontFamily, fontWeight, valueFormatter } = Object.assign( + { fontFamily: config.fontFamily, fontWeight: 'normal' }, fillLabel, { valueFormatter: formatter }, layer.fillLabel, @@ -259,8 +258,8 @@ function fill( ); const specifiedTextColorIsDark = colorIsDark(textColor); - const shapeFillColor = typeof fillColor === 'function' ? fillColor(node, index, a) : fillColor; - const { r: tr, g: tg, b: tb } = stringToRGB(textColor); + const shapeFillColor = node.fillColor; + const { r: tr, g: tg, b: tb, opacity: to } = stringToRGB(textColor); let fontSizeIndex = fontSizes.length - 1; const sizeInvariantFont: Font = { fontStyle, @@ -307,7 +306,11 @@ function fill( // fontWeight must be a multiple of 100 for non-variable width fonts, otherwise weird things happen due to // https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#Fallback_weights - Fallback weights // todo factor out the discretization into a => FontWeight function - fillTextColor: inverseForContrast ? `rgb(${255 - tr}, ${255 - tg}, ${255 - tb})` : textColor, + fillTextColor: inverseForContrast + ? to === undefined + ? `rgb(${255 - tr}, ${255 - tg}, ${255 - tb})` + : `rgba(${255 - tr}, ${255 - tg}, ${255 - tb}, ${to})` + : textColor, rotation, rows: [...Array(targetRowCount)].map(() => ({ rowWords: [], @@ -392,7 +395,7 @@ export function fillTextLayout( measure: TextMeasure, rawTextGetter: RawTextGetter, valueFormatter: (value: number) => string, - childNodes: ShapeTreeNode[], + childNodes: QuadViewModel[], config: Config, layers: Layer[], textFillOrigins: [number, number][], diff --git a/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts b/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts index 8b604d1c98..ba80f5827d 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts @@ -10,11 +10,11 @@ import { AccessorFn, IndexedAccessorFn } from '../../../../utils/accessor'; import { argsToRGBString, stringToRGB } from '../utils/d3_utils'; import { OutsideLinksViewModel, - ShapeTreeNode, QuadViewModel, + RawTextGetter, RowSet, + ShapeTreeNode, ShapeViewModel, - RawTextGetter, } from '../types/viewmodel_types'; import { Layer } from '../../specs/index'; import { @@ -32,12 +32,15 @@ import { aggregators, ArrayEntry, childOrders, + childrenAccessor, depthAccessor, entryKey, entryValue, groupByRollup, mapEntryValue, mapsToArrays, + parentAccessor, + sortIndexAccessor, } from '../utils/group_by_rollup'; function paddingAccessor(n: ArrayEntry) { @@ -75,17 +78,16 @@ export function makeQuadViewModel( layers: Layer[], sectorLineWidth: Pixels, ): Array { - return childNodes.map((node, index, a) => { + return childNodes.map((node) => { const opacityMultiplier = 1; // could alter in the future, eg. in response to interactions const layer = layers[node.depth - 1]; const fillColorSpec = layer && layer.shape && layer.shape.fillColor; const fill = fillColorSpec || 'rgba(128,0,0,0.5)'; - const shapeFillColor = typeof fill === 'function' ? fill(node, index, a) : fill; + const shapeFillColor = typeof fill === 'function' ? fill(node, node.sortIndex, node.parent.children) : fill; const { r, g, b, opacity } = stringToRGB(shapeFillColor); const fillColor = argsToRGBString(r, g, b, opacity * opacityMultiplier); const strokeWidth = sectorLineWidth; - const { x0, x1, y0px, y1px } = node; - return { strokeWidth, fillColor, x0, x1, y0px, y1px }; + return { strokeWidth, fillColor, ...node }; }); } @@ -170,22 +172,23 @@ export function shapeViewModel( // 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 - const hierarchyMap = groupByRollup(groupByRollupAccessors, valueAccessor, aggregator, facts); - const tree = mapsToArrays(hierarchyMap, aggregateComparator(mapEntryValue, childOrders.descending)); + const tree = mapsToArrays( + groupByRollup(groupByRollupAccessors, valueAccessor, aggregator, facts), + aggregateComparator(mapEntryValue, childOrders.descending), + ); const totalValue = tree.reduce((p: number, n: ArrayEntry): number => p + mapEntryValue(n), 0); const sunburstValueToAreaScale = TAU / totalValue; const sunburstAreaAccessor = (e: ArrayEntry) => sunburstValueToAreaScale * mapEntryValue(e); - const children = entryValue(tree[0]).children || []; const treemapLayout = partitionLayout === PartitionLayout.treemap; const treemapInnerArea = treemapLayout ? width * height : 1; // assuming 1 x 1 unit square const treemapValueToAreaScale = treemapInnerArea / totalValue; const treemapAreaAccessor = (e: ArrayEntry) => treemapValueToAreaScale * mapEntryValue(e); const rawChildNodes: Array = treemapLayout - ? treemap(tree, treemapAreaAccessor, paddingAccessor, { x0: -width / 2, y0: -height / 2, width, height }).slice(1) - : sunburst(children, sunburstAreaAccessor, { x0: 0, y0: 0 }, clockwiseSectors, specialFirstInnermostSector); + ? treemap(tree, treemapAreaAccessor, paddingAccessor, { x0: -width / 2, y0: -height / 2, width, height }) + : sunburst(tree, sunburstAreaAccessor, { x0: 0, y0: -1 }, clockwiseSectors, specialFirstInnermostSector); // use the smaller of the two sizes, as a circle fits into a square const circleMaximumSize = Math.min(innerWidth, innerHeight); @@ -194,24 +197,30 @@ export function shapeViewModel( const treeHeight = rawChildNodes.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 childNodes = rawChildNodes.map((n: any, index: number) => { - return { - dataName: entryKey(n.node), - depth: depthAccessor(n.node), - value: aggregateAccessor(n.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, - inRingIndex: index, - }; - }); - - // ring sector paths - const quadViewModel = makeQuadViewModel(childNodes, layers, config.sectorLineWidth); + const quadViewModel = makeQuadViewModel( + rawChildNodes.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), + children: [], + 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, + }; + }, + ), + layers, + config.sectorLineWidth, + ); // fill text const roomCondition = (n: ShapeTreeNode) => { @@ -221,7 +230,7 @@ export function shapeViewModel( : (diff < 0 ? TAU + diff : diff) * ringSectorMiddleRadius(n) > Math.max(minFontSize, linkLabel.maximumSection); }; - const nodesWithRoom = childNodes.filter(roomCondition); + const nodesWithRoom = quadViewModel.filter(roomCondition); const outsideFillNodes = fillOutside && !treemapLayout ? nodesWithRoom : []; const textFillOrigins = nodesWithRoom.map(treemapLayout ? rectangleFillOrigins : sectorFillOrigins(fillOutside)); @@ -230,12 +239,7 @@ export function shapeViewModel( textMeasure, rawTextGetter, valueFormatter, - nodesWithRoom.map((n: ShapeTreeNode) => - Object.assign({}, n, { - y0: n.y0, - fill: quadViewModel[n.inRingIndex].fillColor, // todo roll a proper join, as this current thing assumes 1:1 between sectors and sector VMs (in the future we may elide small, invisible sector VMs( - }), - ), + nodesWithRoom, config, layers, textFillOrigins, @@ -253,7 +257,7 @@ export function shapeViewModel( const nodesWithoutRoom = fillOutside || treemapLayout ? [] // outsideFillNodes and linkLabels are in inherent conflict due to very likely overlaps - : childNodes.filter((n: ShapeTreeNode) => { + : quadViewModel.filter((n: ShapeTreeNode) => { const id = nodeId(n); const foundInFillText = rowSets.find((r: RowSet) => r.id === id); // successful text render if found, and has some row(s) @@ -274,7 +278,7 @@ export function shapeViewModel( return { config, diskCenter, - quadViewModel: quadViewModel, + quadViewModel, rowSets, linkLabelViewModels, outsideLinksViewModel, diff --git a/stories/sunburst.tsx b/stories/sunburst.tsx index 195523f13e..503ed5db2c 100644 --- a/stories/sunburst.tsx +++ b/stories/sunburst.tsx @@ -1,15 +1,20 @@ import { Chart, Datum, Partition, PartitionLayout } from '../src'; import { mocks } from '../src/mocks/hierarchical/index'; import { config } from '../src/chart_types/partition_chart/layout/config/config'; -import { arrayToLookup, hueInterpolator } from '../src/chart_types/partition_chart/layout/utils/calcs'; -import { productDimension, regionDimension, countryDimension } from '../src/mocks/hierarchical/dimension_codes'; import { getRandomNumber } from '../src/mocks/utils'; -import { palettes } from '../src/mocks/hierarchical/palettes'; import React from 'react'; - -const productLookup = arrayToLookup((d: Datum) => d.sitc1, productDimension); -const regionLookup = arrayToLookup((d: Datum) => d.region, regionDimension); -const countryLookup = arrayToLookup((d: Datum) => d.country, countryDimension); +import { ShapeTreeNode } from '../src/chart_types/partition_chart/layout/types/viewmodel_types'; +import { + categoricalFillColor, + colorBrewerCategoricalPastel12, + colorBrewerCategoricalStark9, + countryLookup, + indexInterpolatedFillColor, + interpolatorCET2s, + interpolatorTurbo, + productLookup, + regionLookup, +} from './utils/utils'; export default { title: 'Sunburst', @@ -20,15 +25,6 @@ export default { }, }; -// style calcs -const interpolatorCET2s = hueInterpolator(palettes.CET2s.map(([r, g, b]) => [r, g, b, 0.8])); -const interpolatorTurbo = hueInterpolator(palettes.turbo.map(([r, g, b]) => [r, g, b, 0.8])); - -const defaultFillColor = (colorMaker: any) => (d: any, i: number, a: any[]) => colorMaker(i / (a.length + 1)); - -const decreasingOpacityCET2 = (opacity: number) => (d: any, i: number, a: any[]) => - hueInterpolator(palettes.CET2s.map(([r, g, b]) => [r, g, b, opacity]))(i / (a.length + 1)); - export const SimplePieChart = () => ( ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorTurbo), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -78,7 +74,7 @@ export const ValueFormattedPieChart = () => ( }, }, shape: { - fillColor: defaultFillColor(interpolatorTurbo), + fillColor: indexInterpolatedFillColor(interpolatorTurbo), }, }, ]} @@ -93,6 +89,43 @@ ValueFormattedPieChart.story = { }, }; +export const ValueFormattedPieChart2 = () => ( + + d.exportVal as number} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} + layers={[ + { + groupByRollup: (d: Datum) => d.sitc1, + nodeLabel: (d: Datum) => productLookup[d].name, + fillLabel: { + textInvertible: true, + fontWeight: 100, + fontStyle: 'italic', + valueFont: { + fontFamily: 'Menlo', + fontStyle: 'normal', + fontWeight: 900, + }, + }, + shape: { + fillColor: (d: ShapeTreeNode) => categoricalFillColor(colorBrewerCategoricalPastel12)(d.sortIndex), + }, + }, + ]} + config={{ outerSizeRatio: 0.9 }} + /> + +); +ValueFormattedPieChart2.story = { + name: 'Value formatted pie chart with categorical color palette', + info: { + source: false, + }, +}; + export const PieChartWithFillLabels = () => ( ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -150,7 +183,7 @@ export const DonutChartWithFillLabels = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -196,7 +229,7 @@ export const PieChartLabels = () => ( // nodeLabel: (d: Datum) => d, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -224,7 +257,7 @@ export const SomeZeroValueSlice = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -233,7 +266,7 @@ export const SomeZeroValueSlice = () => ( ); SomeZeroValueSlice.story = { - name: 'Some slices has a zero value', + name: 'Some slices have a zero value', }; export const SunburstTwoLayers = () => ( @@ -249,18 +282,23 @@ export const SunburstTwoLayers = () => ( nodeLabel: (d: any) => regionLookup[d].regionName, fillLabel: { fontFamily: 'Impact', - textInvertible: true, valueFormatter: (d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000000))}\xa0Tn`, }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + 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), []); + }, }, }, { groupByRollup: (d: Datum) => d.dest, nodeLabel: (d: any) => countryLookup[d].name, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: (d) => { + // pick color from color palette based on mean angle - related yet distinct colors in the outer ring + return indexInterpolatedFillColor(interpolatorCET2s)(d, (d.x0 + d.x1) / 2 / (2 * Math.PI), []); + }, }, }, ]} @@ -272,13 +310,14 @@ export const SunburstTwoLayers = () => ( }, fontFamily: 'Arial', fillLabel: { + textInvertible: true, valueFormatter: (d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, fontStyle: 'italic', }, margin: { top: 0, bottom: 0, left: 0, right: 0 }, minFontSize: 1, idealFontSizeJump: 1.1, - outerSizeRatio: 1, + outerSizeRatio: 0.95, emptySizeRatio: 0, circlePadding: 4, backgroundColor: 'rgba(229,229,229,1)', @@ -286,9 +325,8 @@ export const SunburstTwoLayers = () => ( /> ); - SunburstTwoLayers.story = { - name: 'Sunburst with two layers', + name: 'Sunburst with two layers, angle color', }; export const SunburstThreeLayers = () => ( @@ -303,21 +341,27 @@ export const SunburstThreeLayers = () => ( groupByRollup: (d: Datum) => d.sitc1, nodeLabel: (d: any) => productLookup[d].name, shape: { - fillColor: decreasingOpacityCET2(0.8), + fillColor: (d: ShapeTreeNode) => { + return categoricalFillColor(colorBrewerCategoricalStark9, 0.7)(d.sortIndex); + }, }, }, { groupByRollup: (d: Datum) => countryLookup[d.dest].continentCountry.substr(0, 2), nodeLabel: (d: any) => regionLookup[d].regionName, shape: { - fillColor: decreasingOpacityCET2(0.65), + fillColor: (d: ShapeTreeNode) => { + return categoricalFillColor(colorBrewerCategoricalStark9, 0.5)(d.parent.sortIndex); + }, }, }, { groupByRollup: (d: Datum) => d.dest, nodeLabel: (d: any) => countryLookup[d].name, shape: { - fillColor: decreasingOpacityCET2(0.5), + fillColor: (d: ShapeTreeNode) => { + return categoricalFillColor(colorBrewerCategoricalStark9, 0.3)(d.parent.parent.sortIndex); + }, }, }, ]} @@ -351,7 +395,7 @@ export const SunburstThreeLayers = () => ( ); SunburstThreeLayers.story = { - name: 'Sunburst with three layers', + name: 'Sunburst with three layers, ColorBrewer, fade', }; export const TwoSlicesPieChart = () => ( @@ -367,7 +411,7 @@ export const TwoSlicesPieChart = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -395,7 +439,7 @@ export const LargeSmallPieChart = () => ( nodeLabel: (d: Datum) => d, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -428,7 +472,7 @@ export const VeryLargeSmallPieChart = () => ( nodeLabel: (d: Datum) => d, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -456,7 +500,7 @@ export const BigEmptyPieChart = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -484,7 +528,7 @@ export const FullZeroSlicePieChart = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -509,7 +553,7 @@ export const SingleSlicePieChart = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -534,7 +578,7 @@ export const SingleSmallSlicePieChart = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -559,7 +603,7 @@ export const SingleVerySmallSlicePieChart = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -584,7 +628,7 @@ export const NoSliceNoPie = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -612,7 +656,7 @@ export const NegativeNoPie = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -637,7 +681,7 @@ export const TotalZeroNoPie = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -662,7 +706,7 @@ export const HighNumberOfSlice = () => ( nodeLabel: (d: Datum) => countryLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -690,7 +734,7 @@ export const CounterClockwiseSpecial = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -718,7 +762,7 @@ export const ClockwiseNoSpecial = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -746,7 +790,7 @@ export const LinkedLabelsOnly = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -774,7 +818,7 @@ export const NoLabels = () => ( nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} diff --git a/stories/treemap.tsx b/stories/treemap.tsx index 257f1c7a87..92d1066d41 100644 --- a/stories/treemap.tsx +++ b/stories/treemap.tsx @@ -6,6 +6,8 @@ import { countryDimension, productDimension, regionDimension } from '../src/mock import { getRandomNumber } from '../src/mocks/utils'; import { palettes } from '../src/mocks/hierarchical/palettes'; import React from 'react'; +import { ShapeTreeNode } from '../src/chart_types/partition_chart/layout/types/viewmodel_types'; +import { categoricalFillColor, colorBrewerCategoricalPastel12 } from './utils/utils'; const productLookup = arrayToLookup((d: Datum) => d.sitc1, productDimension); const regionLookup = arrayToLookup((d: Datum) => d.region, regionDimension); @@ -56,6 +58,39 @@ OneLayer.story = { name: 'One-layer, resizing treemap', }; +export const OneLayer2 = () => ( + + d.exportVal as number} + valueFormatter={(d: number) => `$${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`} + layers={[ + { + groupByRollup: (d: Datum) => d.sitc1, + nodeLabel: (d: Datum) => productLookup[d].name, + fillLabel: { + textInvertible: true, + valueFormatter: (d: number) => `${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, + valueFont: { + fontWeight: 100, + }, + }, + shape: { + fillColor: (d: ShapeTreeNode) => categoricalFillColor(colorBrewerCategoricalPastel12)(d.sortIndex), + }, + }, + ]} + config={{ + partitionLayout: PartitionLayout.treemap, + }} + /> + +); +OneLayer2.story = { + name: 'One-layer, ColorBrewer treemap', +}; + export const MidTwoLayers = () => ( ( textColor: 'yellow', textInvertible: false, }, - shape: { fillColor: 'rgba(255, 229, 180,0.25)' }, + shape: { fillColor: 'rgba(0,0,0,0)' }, }, { groupByRollup: (d: Datum) => d.dest, @@ -92,10 +127,16 @@ export const MidTwoLayers = () => ( fontWeight: 200, fontStyle: 'normal', fontFamily: 'Helvetica', - fontVariant: 'normal', + fontVariant: 'small-caps', + valueFont: { fontWeight: 400, fontStyle: 'italic' }, }, shape: { - fillColor: defaultFillColor(interpolatorTurbo), + fillColor: (d: ShapeTreeNode) => { + // primarily, pick color based on parent's index, but then perturb by the index within the parent + return interpolatorTurbo( + (d.parent.sortIndex + d.sortIndex / d.parent.children.length) / (d.parent.parent.children.length + 1), + ); + }, }, }, ]} @@ -113,14 +154,13 @@ export const MidTwoLayers = () => ( MidTwoLayers.story = { name: 'Midsize two-layer treemap', }; + export const TwoLayersStressTest = () => ( ( groupByRollup: (d: Datum) => d.sitc1, nodeLabel: (d: any) => productLookup[d].name.toUpperCase(), fillLabel: { - valueFormatter: (d: number) => `${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, + valueFormatter: () => '', fontFamily: 'Phosphate-Inline', - textColor: 'white', - textInvertible: false, + textColor: 'rgba(255,255,0, 0.6)', + textInvertible: true, + }, + shape: { + fillColor: (d: ShapeTreeNode) => { + // primarily, pick color based on parent's index, but then perturb by the index within the parent + return interpolatorTurbo(d.sortIndex / (d.parent.children.length + 1)); + }, }, - shape: { fillColor: 'rgba(255, 229, 180,0.25)' }, }, { groupByRollup: (d: Datum) => d.dest, @@ -145,14 +190,22 @@ export const TwoLayersStressTest = () => ( fillLabel: { valueFormatter: (d: number) => `${config.fillLabel.valueFormatter(Math.round(d / 1000000000))}\xa0Bn`, textColor: 'black', - textInvertible: false, - fontWeight: 200, + textInvertible: true, + fontWeight: 900, fontStyle: 'normal', fontFamily: 'Helvetica', fontVariant: 'normal', + valueFont: { + fontWeight: 100, + }, }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: (d: ShapeTreeNode) => { + // primarily, pick color based on parent's index, but then perturb by the index within the parent + return interpolatorTurbo( + (d.parent.sortIndex + d.sortIndex / d.parent.children.length) / (d.parent.parent.children.length + 1), + ); + }, }, }, ]} diff --git a/stories/utils/utils.ts b/stories/utils/utils.ts new file mode 100644 index 0000000000..1d45847672 --- /dev/null +++ b/stories/utils/utils.ts @@ -0,0 +1,34 @@ +import { arrayToLookup, hueInterpolator } from '../../src/chart_types/partition_chart/layout/utils/calcs'; +import { palettes } from '../../src/mocks/hierarchical/palettes'; +import { Datum } from '../../src/utils/domain'; +import { countryDimension, productDimension, regionDimension } from '../../src/mocks/hierarchical/dimension_codes'; + +export const productLookup = arrayToLookup((d: Datum) => d.sitc1, productDimension); +export const regionLookup = arrayToLookup((d: Datum) => d.region, regionDimension); +export const countryLookup = arrayToLookup((d: Datum) => d.country, countryDimension); + +// interpolation based, cyclical color example +export const interpolatorCET2s = hueInterpolator(palettes.CET2s.map(([r, g, b]) => [r, g, b, 0.8])); +export const interpolatorTurbo = hueInterpolator(palettes.turbo.map(([r, g, b]) => [r, g, b, 0.8])); +export const indexInterpolatedFillColor = (colorMaker: any) => (d: any, i: number, a: any[]) => + colorMaker(i / (a.length + 1)); + +// colorbrewer2.org based, categorical color example +type RGBStrings = [string, string, string][]; +const colorBrewerExportMatcher = /rgb\((\d{1,3}),(\d{1,3}),(\d{1,3})\)/; +const colorStringToTuple = (s: string) => (colorBrewerExportMatcher.exec(s) as string[]).slice(1); + +// prettier-ignore +export const colorBrewerCategorical12: RGBStrings = ['rgb(166,206,227)', 'rgb(31,120,180)', 'rgb(178,223,138)', 'rgb(51,160,44)', 'rgb(251,154,153)', 'rgb(227,26,28)', 'rgb(253,191,111)', 'rgb(255,127,0)', 'rgb(202,178,214)', 'rgb(106,61,154)', 'rgb(255,255,153)', 'rgb(177,89,40)'].map(colorStringToTuple) as RGBStrings; + +// prettier-ignore +export const colorBrewerCategoricalPastel12: RGBStrings = ['rgb(166,206,227)', 'rgb(31,120,180)', 'rgb(178,223,138)', 'rgb(51,160,44)', 'rgb(251,154,153)', 'rgb(227,26,28)', 'rgb(253,191,111)', 'rgb(255,127,0)', 'rgb(202,178,214)', 'rgb(106,61,154)', 'rgb(255,255,153)', 'rgb(177,89,40)'].map(colorStringToTuple) as RGBStrings; + +// prettier-ignore +export const colorBrewerCategoricalStark9: RGBStrings = ['rgb(228,26,28)', 'rgb(55,126,184)', 'rgb(77,175,74)', 'rgb(152,78,163)', 'rgb(255,127,0)', 'rgb(255,255,51)', 'rgb(166,86,40)', 'rgb(247,129,191)', 'rgb(153,153,153)'].map(colorStringToTuple) as RGBStrings; + +export const categoricalFillColor = (categoricalColors: RGBStrings, opacity = 1) => (i: number) => + `rgba(${categoricalColors[i % categoricalColors.length].concat([opacity.toString()]).join(',')})`; + +export const decreasingOpacityCET2 = (opacity: number) => (d: any, i: number, a: any[]) => + hueInterpolator(palettes.CET2s.map(([r, g, b]) => [r, g, b, opacity]))(i / (a.length + 1)); From e66dfd0e9c54d4c76eefd84df7f9212c41e13f95 Mon Sep 17 00:00:00 2001 From: monfera Date: Fri, 24 Jan 2020 22:50:06 +0100 Subject: [PATCH 7/9] chore: type simplifications --- .../partition_chart/layout/types/viewmodel_types.ts | 3 --- src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts | 2 -- 2 files changed, 5 deletions(-) diff --git a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts index 79ced549f9..31f64dc984 100644 --- a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts +++ b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts @@ -90,8 +90,6 @@ interface SectorGeomSpecY { y1px: Distance; } -export interface RingSectorGeometry extends AngleFromTo, SectorGeomSpecY {} - export interface ShapeTreeNode extends TreeNode, SectorGeomSpecY { yMidPx: Distance; depth: number; @@ -99,7 +97,6 @@ export interface ShapeTreeNode extends TreeNode, SectorGeomSpecY { dataName: any; value: number; parent: ArrayNode; - children: ShapeTreeNode[]; } export type RawTextGetter = (node: ShapeTreeNode) => string; diff --git a/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts b/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts index ba80f5827d..04884741e9 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts @@ -32,7 +32,6 @@ import { aggregators, ArrayEntry, childOrders, - childrenAccessor, depthAccessor, entryKey, entryValue, @@ -207,7 +206,6 @@ export function shapeViewModel( value: aggregateAccessor(node), parent: parentAccessor(node), sortIndex: sortIndexAccessor(node), - children: [], x0: n.x0, x1: n.x1, y0: n.y0, From d7a85443bf97bf83bb4b35d748a8fbaaab58eb42 Mon Sep 17 00:00:00 2001 From: monfera Date: Mon, 27 Jan 2020 11:23:21 +0100 Subject: [PATCH 8/9] test: checking in snapshots --- ...ie-chart-visually-looks-correct-1-snap.png | Bin 16526 -> 18926 bytes ...-special-visually-looks-correct-1-snap.png | Bin 43937 -> 52759 bytes ...-special-visually-looks-correct-1-snap.png | Bin 47898 -> 53656 bytes ...l-labels-visually-looks-correct-1-snap.png | Bin 64624 -> 63356 bytes ...ie-chart-visually-looks-correct-1-snap.png | Bin 16143 -> 18726 bytes ...of-slice-visually-looks-correct-1-snap.png | Bin 67262 -> 79707 bytes ...els-only-visually-looks-correct-1-snap.png | Bin 45691 -> 50025 bytes ...o-labels-visually-looks-correct-1-snap.png | Bin 15775 -> 15608 bytes ...t-labels-visually-looks-correct-1-snap.png | Bin 22955 -> 22997 bytes ...l-labels-visually-looks-correct-1-snap.png | Bin 69646 -> 68128 bytes ...ie-chart-visually-looks-correct-1-snap.png | Bin 48382 -> 53381 bytes ...ie-chart-visually-looks-correct-1-snap.png | Bin 0 -> 7875 bytes ...ie-chart-visually-looks-correct-1-snap.png | Bin 0 -> 6808 bytes ...ue-slice-visually-looks-correct-1-snap.png | Bin 40287 -> 44288 bytes ...e-layers-visually-looks-correct-1-snap.png | Bin 91274 -> 109231 bytes ...o-layers-visually-looks-correct-1-snap.png | Bin 96216 -> 86480 bytes ...ie-chart-visually-looks-correct-1-snap.png | Bin 24302 -> 24306 bytes ...-chart-2-visually-looks-correct-1-snap.png | Bin 0 -> 74622 bytes ...ie-chart-visually-looks-correct-1-snap.png | Bin 0 -> 75003 bytes ...ie-chart-visually-looks-correct-1-snap.png | Bin 19147 -> 19185 bytes ...om-style-visually-looks-correct-1-snap.png | Bin 38407 -> 38381 bytes ...o-layers-visually-looks-correct-1-snap.png | Bin 94820 -> 99700 bytes ...ti-color-visually-looks-correct-1-snap.png | Bin 72866 -> 72171 bytes ...-layer-2-visually-looks-correct-1-snap.png | Bin 0 -> 86021 bytes ...ess-test-visually-looks-correct-1-snap.png | Bin 156081 -> 111565 bytes 25 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-single-small-slice-pie-chart-visually-looks-correct-1-snap.png create mode 100644 integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-single-very-small-slice-pie-chart-visually-looks-correct-1-snap.png create mode 100644 integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-value-formatted-pie-chart-2-visually-looks-correct-1-snap.png create mode 100644 integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-value-formatted-pie-chart-visually-looks-correct-1-snap.png create mode 100644 integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-one-layer-2-visually-looks-correct-1-snap.png diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-big-empty-pie-chart-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-big-empty-pie-chart-visually-looks-correct-1-snap.png index bdef73d2fb5560724923295104308431a267cafb..cfe27a704b1045e28686714879b695430ff6790c 100644 GIT binary patch literal 18926 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