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/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 bdef73d2fb..cfe27a704b 100644 Binary files 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 and 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 differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-clockwise-no-special-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-clockwise-no-special-visually-looks-correct-1-snap.png index ccf1cf7c81..c2d8535468 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-clockwise-no-special-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-clockwise-no-special-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-counter-clockwise-special-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-counter-clockwise-special-visually-looks-correct-1-snap.png index 44e534beb7..261a4c45a7 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-counter-clockwise-special-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-counter-clockwise-special-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-donut-chart-with-fill-labels-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-donut-chart-with-fill-labels-visually-looks-correct-1-snap.png index 8bee3401f9..faa757070b 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-donut-chart-with-fill-labels-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-donut-chart-with-fill-labels-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-full-zero-slice-pie-chart-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-full-zero-slice-pie-chart-visually-looks-correct-1-snap.png index 77d2a36cdd..9817a2f75c 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-full-zero-slice-pie-chart-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-full-zero-slice-pie-chart-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-high-number-of-slice-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-high-number-of-slice-visually-looks-correct-1-snap.png index bc1ab22d11..6af238a9e3 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-high-number-of-slice-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-high-number-of-slice-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-linked-labels-only-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-linked-labels-only-visually-looks-correct-1-snap.png index 5008e3c29b..f76a32fc02 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-linked-labels-only-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-linked-labels-only-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-no-labels-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-no-labels-visually-looks-correct-1-snap.png index 96c5d23d1a..18f6550f12 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-no-labels-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-no-labels-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-pie-chart-labels-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-pie-chart-labels-visually-looks-correct-1-snap.png index 02b2a29168..6ee4671b16 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-pie-chart-labels-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-pie-chart-labels-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-pie-chart-with-fill-labels-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-pie-chart-with-fill-labels-visually-looks-correct-1-snap.png index e7bee44940..b48e2d814d 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-pie-chart-with-fill-labels-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-pie-chart-with-fill-labels-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-simple-pie-chart-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-simple-pie-chart-visually-looks-correct-1-snap.png index 1fb5f49655..32506856c3 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-simple-pie-chart-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-simple-pie-chart-visually-looks-correct-1-snap.png differ diff --git a/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 b/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 new file mode 100644 index 0000000000..886d829184 Binary files /dev/null and b/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 differ diff --git a/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 b/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 new file mode 100644 index 0000000000..619b1277a3 Binary files /dev/null and b/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 differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-some-zero-value-slice-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-some-zero-value-slice-visually-looks-correct-1-snap.png index 7a364473ba..2b686da9d8 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-some-zero-value-slice-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-some-zero-value-slice-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-sunburst-three-layers-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-sunburst-three-layers-visually-looks-correct-1-snap.png index 6d307a2ee9..4e8f2d92b9 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-sunburst-three-layers-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-sunburst-three-layers-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-sunburst-two-layers-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-sunburst-two-layers-visually-looks-correct-1-snap.png index 4ed8ea6a14..0ddea4c30d 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-sunburst-two-layers-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-sunburst-two-layers-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-two-slices-pie-chart-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-two-slices-pie-chart-visually-looks-correct-1-snap.png index 708f6a488d..c6404123fa 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-two-slices-pie-chart-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-two-slices-pie-chart-visually-looks-correct-1-snap.png differ diff --git a/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 b/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 new file mode 100644 index 0000000000..b7b8d7ad6c Binary files /dev/null and b/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 differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-value-formatted-pie-chart-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-value-formatted-pie-chart-visually-looks-correct-1-snap.png new file mode 100644 index 0000000000..acedfecdc6 Binary files /dev/null and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-value-formatted-pie-chart-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-very-large-small-pie-chart-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-very-large-small-pie-chart-visually-looks-correct-1-snap.png index 4f4dae70f3..b7bab2e012 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-very-large-small-pie-chart-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-sunburst-very-large-small-pie-chart-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-custom-style-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-custom-style-visually-looks-correct-1-snap.png index ddc6359dab..5a2587e846 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-custom-style-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-custom-style-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-mid-two-layers-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-mid-two-layers-visually-looks-correct-1-snap.png index ca031bc3bb..fdd399fe2e 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-mid-two-layers-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-mid-two-layers-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-multi-color-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-multi-color-visually-looks-correct-1-snap.png index c736966dec..7e9bb2efd4 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-multi-color-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-multi-color-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-one-layer-2-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-one-layer-2-visually-looks-correct-1-snap.png new file mode 100644 index 0000000000..222258bf6e Binary files /dev/null and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-one-layer-2-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-two-layers-stress-test-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-two-layers-stress-test-visually-looks-correct-1-snap.png index 830035f1e3..fe1ba504cc 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-two-layers-stress-test-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-treemap-two-layers-stress-test-visually-looks-correct-1-snap.png differ diff --git a/src/chart_types/partition_chart/layout/config/config.ts b/src/chart_types/partition_chart/layout/config/config.ts index 1a3b7a51d5..ef031c89e9 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 { @@ -24,6 +25,30 @@ function defaultFormatter(d: any): string { : String(d); } +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', + 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 }, @@ -100,21 +125,22 @@ 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: { + valueFormatter: { dflt: defaultFormatter, type: 'function', }, + valueFont, }, }, @@ -160,6 +186,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 892900992c..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, FontWeight } from './types'; +import { Color, Font, FontFamily, PartialFont } from './types'; import { $Values as Values } from 'utility-types'; export const PartitionLayout = Object.freeze({ @@ -9,14 +9,27 @@ 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; - formatter: (x: number) => string; + textOpacity: Ratio; + valueFormatter: (x: number) => string; + valueFont: PartialFont; +} + +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 @@ -32,12 +45,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 +60,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..31f64dc984 100644 --- a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts +++ b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts @@ -1,18 +1,20 @@ import { Config } from './config_types'; import { Coordinate, Distance, PointObject, PointTuple, Radian } from './geometry_types'; -import { Color, FontWeight } from './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 translate: [number, number]; textAlign: CanvasTextAlign; text: string; + valueText: string; width: Distance; verticalOffset: Distance; }; -export interface RowBox { +export interface RowBox extends Font { text: string; width: Distance; verticalOffset: Distance; @@ -38,15 +40,11 @@ export interface RowSet { id: string; rows: Array; fillTextColor: string; - fillTextWeight: FontWeight; - fontFamily: string; - fontStyle: string; - fontVariant: string; fontSize: number; rotation: Radian; } -export interface QuadViewModel extends RingSectorGeometry { +export interface QuadViewModel extends ShapeTreeNode { strokeWidth: number; fillColor: string; } @@ -92,14 +90,14 @@ interface SectorGeomSpecY { y1px: Distance; } -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; } export type RawTextGetter = (node: ShapeTreeNode) => string; +export type ValueFormatter = (value: number) => 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/measure.ts b/src/chart_types/partition_chart/layout/utils/measure.ts index af4a20954e..91d32c2e08 100644 --- a/src/chart_types/partition_chart/layout/utils/measure.ts +++ b/src/chart_types/partition_chart/layout/utils/measure.ts @@ -1,8 +1,14 @@ -import { 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 (font: string, texts: string[]): TextMetrics[] => { - ctx.font = font; - return texts.map((text) => ctx.measureText(text)); - }; + return (fontSize: number, boxes: Box[]): TextMetrics[] => + boxes.map((box: Box) => { + ctx.font = cssFontShorthand(box, fontSize); + return ctx.measureText(box.text); + }); } 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 0dfbe8eeae..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,8 +2,16 @@ 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 { FontWeight, TextMeasure } from '../types/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'; import { Layer } from '../../specs/index'; @@ -189,24 +197,27 @@ function identityRowSet(): RowSet { return { id: '', rows: [], - fontFamily: '', - fontStyle: '', - fontVariant: '', fontSize: NaN, fillTextColor: '', - fillTextWeight: 400, rotation: NaN, }; } function getAllBoxes( rawTextGetter: RawTextGetter, - valueFormatter: (value: number) => string, + valueFormatter: ValueFormatter, + sizeInvariantFontShorthand: Font, + valueFont: PartialFont, node: ShapeTreeNode, -): string[] { +): Box[] { return rawTextGetter(node) .split(' ') - .concat(valueFormatter(node[AGGREGATE_KEY]).split(' ')); + .map((text) => ({ text, ...sizeInvariantFontShorthand })) + .concat( + valueFormatter(node[AGGREGATE_KEY]) + .split(' ') + .map((text) => ({ text, ...sizeInvariantFontShorthand, ...valueFont })), + ); } function getWordSpacing(fontSize: number) { @@ -217,39 +228,46 @@ 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, + formatter: (value: number) => string, textFillOrigins: any[], shapeConstructor: (n: ShapeTreeNode) => any, getShapeRowGeometry: (...args: any[]) => RowSpace, getRotation: Function, ) { - return (node: ShapeTreeNode, index: number, a: ShapeTreeNode[]) => { + return (node: QuadViewModel, index: number) => { const { maxRowCount, fillLabel } = config; - const { - textColor, - textInvertible, - textWeight, - fontStyle, - fontVariant, - fontFamily, - formatter, - fillColor, - } = Object.assign( - { fontFamily: config.fontFamily, fillColor: node.fill }, + const layer = layers[node.depth - 1] || {}; + const { textColor, textInvertible, fontStyle, fontVariant, fontFamily, fontWeight, valueFormatter } = Object.assign( + { fontFamily: config.fontFamily, fontWeight: 'normal' }, + fillLabel, + { valueFormatter: formatter }, + layer.fillLabel, + layer.shape, + ); + + const valueFont = Object.assign( + { fontFamily: config.fontFamily, fontWeight: 'normal' }, + config.fillLabel && config.fillLabel.valueFont, fillLabel, - { formatter: valueFormatter }, - layers[node.depth - 1] && layers[node.depth - 1].fillLabel, - layers[node.depth - 1] && layers[node.depth - 1].shape, + fillLabel.valueFont, + layer.fillLabel, + layer.fillLabel && layer.fillLabel.valueFont, ); 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 allBoxes = getAllBoxes(rawTextGetter, formatter, node); + const sizeInvariantFont: Font = { + fontStyle, + fontVariant, + fontWeight, + fontFamily, + }; + const allBoxes = getAllBoxes(rawTextGetter, valueFormatter, sizeInvariantFont, valueFont, node); let rowSet = identityRowSet(); let completed = false; const rotation = getRotation(node); @@ -261,13 +279,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; @@ -284,14 +303,14 @@ 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 - fillTextWeight: (Math.round(textWeight / 100) * 100) as FontWeight, - 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: [], @@ -357,7 +376,7 @@ function fill( } } } - rowSet.rows = rowSet.rows.filter((r) => !isNaN(r.length)); + rowSet.rows = rowSet.rows.filter((r) => completed && !isNaN(r.length)); return rowSet; }; } @@ -376,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/link_text_layout.ts b/src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts index 78a4175f2c..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); @@ -49,8 +51,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: [ @@ -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..04884741e9 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 { @@ -38,6 +38,8 @@ import { groupByRollup, mapEntryValue, mapsToArrays, + parentAccessor, + sortIndexAccessor, } from '../utils/group_by_rollup'; function paddingAccessor(n: ArrayEntry) { @@ -75,17 +77,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 +171,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 +196,29 @@ 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), + 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 +228,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 +237,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, @@ -244,6 +246,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 @@ -252,7 +255,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) @@ -266,13 +269,14 @@ export function shapeViewModel( currentY, outerRadius, rawTextGetter, + valueFormatter, ); // combined viewModel return { config, diskCenter, - quadViewModel: quadViewModel, + quadViewModel, rowSets, linkLabelViewModels, outsideLinksViewModel, 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..50fc48cf64 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, fillTextWeight, 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 + ' ' + fillTextWeight + ' ' + 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); + }); }); }; } @@ -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/src/chart_types/partition_chart/specs/index.ts b/src/chart_types/partition_chart/specs/index.ts index f8ac9902b7..5a2bf655ad 100644 --- a/src/chart_types/partition_chart/specs/index.ts +++ b/src/chart_types/partition_chart/specs/index.ts @@ -4,15 +4,17 @@ 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'; +type ColorAccessor = (d: Datum, index: number, array: Datum[]) => string; + export interface Layer { groupByRollup: IndexedAccessorFn; nodeLabel?: (datum: Datum) => string; - fillLabel?: Partial; - shape?: { fillColor: string | Function }; + fillLabel?: Partial; + shape?: { fillColor: string | ColorAccessor }; } const defaultProps = { 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..3c6e1864d3 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,26 +25,20 @@ 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)); - export const SimplePieChart = () => ( 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 }, shape: { - fillColor: defaultFillColor(interpolatorTurbo), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -47,7 +46,81 @@ 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: indexInterpolatedFillColor(interpolatorTurbo), + }, + }, + ]} + config={{ outerSizeRatio: 0.9 }} + /> + +); +ValueFormattedPieChart.story = { + name: 'Value formatted pie chart', + info: { + source: false, + }, +}; + +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, }, @@ -59,14 +132,14 @@ 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, nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -78,7 +151,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,14 +176,14 @@ 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, nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -122,7 +195,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,14 +222,14 @@ 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, // nodeLabel: (d: Datum) => d, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -177,14 +250,14 @@ 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, nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -193,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 = () => ( @@ -202,25 +275,30 @@ 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), nodeLabel: (d: any) => regionLookup[d].regionName, 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), + 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), []); + }, }, }, ]} @@ -232,13 +310,14 @@ export const SunburstTwoLayers = () => ( }, fontFamily: 'Arial', fillLabel: { - formatter: (d: number) => `$${config.fillLabel.formatter(Math.round(d / 1000000000))}\xa0Bn`, + 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)', @@ -246,41 +325,43 @@ export const SunburstTwoLayers = () => ( /> ); - SunburstTwoLayers.story = { - name: 'Sunburst with two layers', + name: 'Sunburst with two layers, angle color', }; export const SunburstThreeLayers = () => ( - + 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, - fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + 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, - fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: (d: ShapeTreeNode) => { + return categoricalFillColor(colorBrewerCategoricalStark9, 0.5)(d.parent.sortIndex); + }, }, }, { groupByRollup: (d: Datum) => d.dest, nodeLabel: (d: any) => countryLookup[d].name, - fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: (d: ShapeTreeNode) => { + return categoricalFillColor(colorBrewerCategoricalStark9, 0.3)(d.parent.parent.sortIndex); + }, }, }, ]} @@ -292,8 +373,15 @@ 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', + textInvertible: true, + fontWeight: 900, + valueFont: { + fontFamily: 'Menlo', + fontStyle: 'normal', + fontWeight: 100, + }, }, margin: { top: 0, bottom: 0, left: 0, right: 0 }, minFontSize: 1, @@ -307,7 +395,7 @@ export const SunburstThreeLayers = () => ( ); SunburstThreeLayers.story = { - name: 'Sunburst with three layers', + name: 'Sunburst with three layers, ColorBrewer, fade', }; export const TwoSlicesPieChart = () => ( @@ -316,14 +404,14 @@ 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, nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -332,7 +420,7 @@ export const TwoSlicesPieChart = () => ( ); TwoSlicesPieChart.story = { - name: 'Pie Chart with two slices', + name: 'Pie chart with two slices', }; export const LargeSmallPieChart = () => ( @@ -344,14 +432,14 @@ 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, nodeLabel: (d: Datum) => d, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -367,6 +455,7 @@ export const LargeSmallPieChart = () => ( LargeSmallPieChart.story = { name: 'Pie chart with one large and one small slice', }; + 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, nodeLabel: (d: Datum) => d, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -404,14 +493,14 @@ 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, nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -432,14 +521,14 @@ 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, nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -450,20 +539,21 @@ export const FullZeroSlicePieChart = () => ( FullZeroSlicePieChart.story = { name: 'Pie chart with one full and one zero slice', }; + export const SingleSlicePieChart = () => ( 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 }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -474,20 +564,71 @@ 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: indexInterpolatedFillColor(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: indexInterpolatedFillColor(interpolatorCET2s), + }, + }, + ]} + config={{ partitionLayout: PartitionLayout.sunburst, outerSizeRatio: 0.03 }} + /> + +); +SingleVerySmallSlicePieChart.story = { + name: 'Very small pie chart with a single slice', +}; + export const NoSliceNoPie = () => ( 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 }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -498,6 +639,7 @@ export const NoSliceNoPie = () => ( NoSliceNoPie.story = { name: 'No pie chart if no slices', }; + 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, nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -525,20 +667,21 @@ export const NegativeNoPie = () => ( NegativeNoPie.story = { name: 'No pie chart if some slices are negative', }; + export const TotalZeroNoPie = () => ( ({ ...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, nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -556,14 +699,14 @@ 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, nodeLabel: (d: Datum) => countryLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -584,14 +727,14 @@ 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, nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -612,14 +755,14 @@ 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, nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -640,14 +783,14 @@ 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, nodeLabel: (d: Datum) => productLookup[d].name, fillLabel: { textInvertible: true }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} @@ -661,20 +804,21 @@ export const LinkedLabelsOnly = () => ( LinkedLabelsOnly.story = { name: 'Linked labels only', }; + export const NoLabels = () => ( 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 }, shape: { - fillColor: defaultFillColor(interpolatorCET2s), + fillColor: indexInterpolatedFillColor(interpolatorCET2s), }, }, ]} diff --git a/stories/treemap.tsx b/stories/treemap.tsx index 56f1bceb62..882c1c8b36 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); @@ -32,14 +34,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), @@ -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 = () => ( ( 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, }, - shape: { fillColor: 'rgba(255, 229, 180,0.25)' }, + shape: { fillColor: 'rgba(0,0,0,0)' }, }, { 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, - textWeight: 200, + 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,12 +154,15 @@ export const MidTwoLayers = () => ( MidTwoLayers.story = { name: 'Midsize two-layer treemap', }; + export const TwoLayersStressTest = () => ( @@ -126,33 +170,46 @@ 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: () => '', 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, 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, - textWeight: 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), + ); + }, }, }, ]} @@ -184,13 +241,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,10 +257,10 @@ 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, - textWeight: 100, + fontWeight: 100, fontStyle: 'normal', fontFamily: 'Din Condensed', fontVariant: 'normal', @@ -241,13 +298,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,10 +317,10 @@ 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, - textWeight: 600, + fontWeight: 600, fontStyle: 'normal', fontFamily: 'Courier New', fontVariant: 'normal', 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));