diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-heatmap-alpha-categorical-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-heatmap-alpha-categorical-visually-looks-correct-1-snap.png index 79c8e3e8ea..d31db379b2 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-heatmap-alpha-categorical-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-heatmap-alpha-categorical-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/heatmap-stories-test-ts-heatmap-stories-should-maximize-the-label-font-size-1-snap.png b/integration/tests/__image_snapshots__/heatmap-stories-test-ts-heatmap-stories-should-maximize-the-label-font-size-1-snap.png new file mode 100644 index 0000000000..8c4ef8b3c7 Binary files /dev/null and b/integration/tests/__image_snapshots__/heatmap-stories-test-ts-heatmap-stories-should-maximize-the-label-font-size-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/heatmap-stories-test-ts-heatmap-stories-should-maximize-the-label-with-an-unique-font-size-1-snap.png b/integration/tests/__image_snapshots__/heatmap-stories-test-ts-heatmap-stories-should-maximize-the-label-with-an-unique-font-size-1-snap.png new file mode 100644 index 0000000000..273dc38808 Binary files /dev/null and b/integration/tests/__image_snapshots__/heatmap-stories-test-ts-heatmap-stories-should-maximize-the-label-with-an-unique-font-size-1-snap.png differ diff --git a/integration/tests/heatmap_stories.test.ts b/integration/tests/heatmap_stories.test.ts index 706b6e2686..aa5251825d 100644 --- a/integration/tests/heatmap_stories.test.ts +++ b/integration/tests/heatmap_stories.test.ts @@ -16,4 +16,14 @@ describe('Heatmap stories', () => { { left: 300, top: 300 }, ); }); + it('should maximize the label with an unique fontSize', async () => { + await page.setViewport({ width: 450, height: 600 }); + await common.expectChartAtUrlToMatchScreenshot('http://localhost:9001/?path=/story/heatmap-alpha--categorical'); + }); + it('should maximize the label fontSize', async () => { + await page.setViewport({ width: 420, height: 600 }); + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/heatmap-alpha--categorical&knob-use global min fontSize_labels=false', + ); + }); }); diff --git a/packages/charts/api/charts.api.md b/packages/charts/api/charts.api.md index 56ebff7924..0fe04c2d48 100644 --- a/packages/charts/api/charts.api.md +++ b/packages/charts/api/charts.api.md @@ -356,6 +356,8 @@ export interface Cell { // (undocumented) fill: Fill; // (undocumented) + fontSize: Pixels; + // (undocumented) formatted: string; // (undocumented) height: number; @@ -958,10 +960,10 @@ export interface HeatmapConfig { maxHeight: Pixels | 'fill'; align: 'center'; label: Font & { - fontSize: Pixels; + minFontSize: Pixels; + maxFontSize: Pixels; + useGlobalMinFontSize: boolean; maxWidth: Pixels | 'fill'; - align: TextAlign; - baseline: TextBaseline; visible: boolean; }; border: { @@ -1007,8 +1009,6 @@ export interface HeatmapConfig { onBrushEnd?: (brushArea: HeatmapBrushEvent) => void; // (undocumented) timeZone: string; - // Warning: (ae-forgotten-export) The symbol "Pixels" needs to be exported by the entry point index.d.ts - // // (undocumented) width: Pixels; // Warning: (ae-forgotten-export) The symbol "Font" needs to be exported by the entry point index.d.ts @@ -1506,6 +1506,9 @@ export const PATH_KEY = "path"; // @public (undocumented) export function pathAccessor(n: ArrayEntry): LegendPath; +// @public (undocumented) +export type Pixels = number; + // @public export const Placement: Readonly<{ Top: "top"; diff --git a/packages/charts/src/chart_types/heatmap/layout/config/config.ts b/packages/charts/src/chart_types/heatmap/layout/config/config.ts index 6957f4f208..256171ea62 100644 --- a/packages/charts/src/chart_types/heatmap/layout/config/config.ts +++ b/packages/charts/src/chart_types/heatmap/layout/config/config.ts @@ -89,15 +89,15 @@ export const config: Config = { label: { visible: true, maxWidth: 'fill', - fontSize: 10, + minFontSize: 8, + maxFontSize: 12, fontFamily: 'Sans-Serif', fontStyle: 'normal', textColor: 'black', fontVariant: 'normal', fontWeight: 'normal', textOpacity: 1, - align: 'center' as CanvasTextAlign, - baseline: 'verticalAlign' as CanvasTextBaseline, + useGlobalMinFontSize: true, }, border: { strokeWidth: 1, diff --git a/packages/charts/src/chart_types/heatmap/layout/types/config_types.ts b/packages/charts/src/chart_types/heatmap/layout/types/config_types.ts index a1b7a3fef2..77e600c134 100644 --- a/packages/charts/src/chart_types/heatmap/layout/types/config_types.ts +++ b/packages/charts/src/chart_types/heatmap/layout/types/config_types.ts @@ -82,10 +82,10 @@ export interface Config { maxHeight: Pixels | 'fill'; align: 'center'; label: Font & { - fontSize: Pixels; + minFontSize: Pixels; + maxFontSize: Pixels; + useGlobalMinFontSize: boolean; maxWidth: Pixels | 'fill'; - align: TextAlign; - baseline: TextBaseline; visible: boolean; }; border: { diff --git a/packages/charts/src/chart_types/heatmap/layout/types/viewmodel_types.ts b/packages/charts/src/chart_types/heatmap/layout/types/viewmodel_types.ts index 5210767e7e..03d1755932 100644 --- a/packages/charts/src/chart_types/heatmap/layout/types/viewmodel_types.ts +++ b/packages/charts/src/chart_types/heatmap/layout/types/viewmodel_types.ts @@ -36,6 +36,7 @@ export interface Cell { formatted: string; visible: boolean; datum: HeatmapCellDatum; + fontSize: Pixels; } /** @internal */ @@ -57,6 +58,7 @@ export interface HeatmapViewModel { stroke: Stroke; }; cells: Cell[]; + cellFontSize: (c: Cell) => Pixels; xValues: Array; yValues: Array; pageSize: number; @@ -116,6 +118,7 @@ export const nullHeatmapViewModel: HeatmapViewModel = { xValues: [], yValues: [], pageSize: 0, + cellFontSize: () => 0, }; /** @internal */ diff --git a/packages/charts/src/chart_types/heatmap/layout/viewmodel/viewmodel.ts b/packages/charts/src/chart_types/heatmap/layout/viewmodel/viewmodel.ts index bbeb135675..60064d78d1 100644 --- a/packages/charts/src/chart_types/heatmap/layout/viewmodel/viewmodel.ts +++ b/packages/charts/src/chart_types/heatmap/layout/viewmodel/viewmodel.ts @@ -11,7 +11,7 @@ import { scaleBand, scaleQuantize } from 'd3-scale'; import { stringToRGB } from '../../../../common/color_library_wrappers'; import { Pixels } from '../../../../common/geometry'; -import { Box, TextMeasure } from '../../../../common/text_utils'; +import { Box, maximiseFontSize, TextMeasure } from '../../../../common/text_utils'; import { ScaleContinuous } from '../../../../scales'; import { ScaleType } from '../../../../scales/constants'; import { SettingsSpec } from '../../../../specs'; @@ -177,6 +177,9 @@ export function shapeViewModel( }; }); + const cellWidthInner = cellWidth - gridStrokeWidth * 2; + const cellHeightInner = cellHeight - gridStrokeWidth * 2; + // compute each available cell position, color and value const cellMap = table.reduce>((acc, d) => { const x = xScale(String(d.x)); @@ -187,13 +190,27 @@ export function shapeViewModel( return acc; } const cellKey = getCellKey(d.x, d.y); + + const formattedValue = spec.valueFormatter(d.value); + + const fontSize = maximiseFontSize( + textMeasure, + formattedValue, + config.cell.label, + config.cell.label.minFontSize, + config.cell.label.maxFontSize, + // adding 3px padding per side to avoid that text touches the edges + cellWidthInner - 6, + cellHeightInner - 6, + ); + acc[cellKey] = { x: (config.cell.maxWidth !== 'fill' ? x + xScale.bandwidth() / 2 - config.cell.maxWidth / 2 : x) + gridStrokeWidth, y, yIndex, - width: cellWidth - gridStrokeWidth * 2, - height: cellHeight - gridStrokeWidth * 2, + width: cellWidthInner, + height: cellHeightInner, datum: d, fill: { color: stringToRGB(color), @@ -204,7 +221,8 @@ export function shapeViewModel( }, value: d.value, visible: !isValueHidden(d.value, bandsToHide), - formatted: spec.valueFormatter(d.value), + formatted: formattedValue, + fontSize, }; return acc; }, {}); @@ -355,6 +373,9 @@ export function shapeViewModel( yLines.push({ x1: chartDimensions.left, y1: y, x2: chartDimensions.width + chartDimensions.left, y2: y }); } + const cells = Object.values(cellMap); + const tableMinFontSize = cells.reduce((acc, { fontSize }) => Math.min(acc, fontSize), Infinity); + return { config, heatmapViewModel: { @@ -371,7 +392,8 @@ export function shapeViewModel( }, }, pageSize, - cells: Object.values(cellMap), + cells, + cellFontSize: (cell: Cell) => (config.cell.label.useGlobalMinFontSize ? tableMinFontSize : cell.fontSize), xValues: textXValues, yValues: textYValues, }, diff --git a/packages/charts/src/chart_types/heatmap/renderer/canvas/canvas_renderers.ts b/packages/charts/src/chart_types/heatmap/renderer/canvas/canvas_renderers.ts index 9f5b7e392c..a8a0bc1cd8 100644 --- a/packages/charts/src/chart_types/heatmap/renderer/canvas/canvas_renderers.ts +++ b/packages/charts/src/chart_types/heatmap/renderer/canvas/canvas_renderers.ts @@ -72,13 +72,14 @@ export function renderCanvas2d( const { x, y } = heatmapViewModel.gridOrigin; ctx.translate(x, y); filteredCells.forEach((cell) => { - if (cell.visible) - renderText( - ctx, - { x: cell.x + cell.width / 2, y: cell.y + cell.height / 2 }, - cell.formatted, - config.cell.label, - ); + const fontSize = heatmapViewModel.cellFontSize(cell); + if (cell.visible && Number.isFinite(fontSize)) + renderText(ctx, { x: cell.x + cell.width / 2, y: cell.y + cell.height / 2 }, cell.formatted, { + ...config.cell.label, + fontSize, + align: 'center', + baseline: 'middle', + }); }); }), diff --git a/packages/charts/src/common/text_utils.ts b/packages/charts/src/common/text_utils.ts index 6843b4c048..a9443237b9 100644 --- a/packages/charts/src/common/text_utils.ts +++ b/packages/charts/src/common/text_utils.ts @@ -134,3 +134,22 @@ export function fitText( const { width } = measure(fontSize, [{ ...font, text }])[0]; return { width, text }; } + +/** @internal */ +export function maximiseFontSize( + measure: TextMeasure, + text: string, + font: Font, + minFontSize: Pixels, + maxFontSize: Pixels, + boxWidth: Pixels, + boxHeight: Pixels, +): Pixels { + const response = (fontSize: number) => { + const [{ width }] = measure(fontSize, [{ text, ...font }]); + const widthDiff = boxWidth - width; + const heightDiff = boxHeight - fontSize; + return -Math.min(widthDiff, heightDiff); + }; + return monotonicHillClimb(response, maxFontSize, 0, integerSnap, minFontSize); +} diff --git a/packages/charts/src/index.ts b/packages/charts/src/index.ts index e828490076..c0db667fe5 100644 --- a/packages/charts/src/index.ts +++ b/packages/charts/src/index.ts @@ -92,6 +92,6 @@ export { DataGenerator } from './utils/data_generators/data_generator'; export * from './utils/themes/merge_utils'; export { MODEL_KEY } from './chart_types/partition_chart/layout/config'; export { LegendStrategy } from './chart_types/partition_chart/layout/utils/highlighted_geoms'; -export { Ratio } from './common/geometry'; +export { Pixels, Ratio } from './common/geometry'; export { AdditiveNumber } from './utils/accessor'; export { FontStyle, FONT_STYLES } from './common/text_utils'; diff --git a/storybook/main.js b/storybook/main.js index eb0cbd4a8a..ec94d12ad5 100644 --- a/storybook/main.js +++ b/storybook/main.js @@ -21,4 +21,7 @@ module.exports = { reactOptions: { fastRefresh: true, }, + typescript: { + reactDocgen: false, + }, }; diff --git a/storybook/stories/heatmap/2_categorical.story.tsx b/storybook/stories/heatmap/2_categorical.story.tsx index 9b8bc4170c..98138590b2 100644 --- a/storybook/stories/heatmap/2_categorical.story.tsx +++ b/storybook/stories/heatmap/2_categorical.story.tsx @@ -7,7 +7,7 @@ */ import { action } from '@storybook/addon-actions'; -import { boolean } from '@storybook/addon-knobs'; +import { boolean, number } from '@storybook/addon-knobs'; import React from 'react'; import { Chart, Heatmap, Settings } from '@elastic/charts'; @@ -16,8 +16,17 @@ import { BABYNAME_DATA } from '@elastic/charts/src/utils/data_samples/babynames' import { useBaseTheme } from '../../use_base_theme'; export const Example = () => { - const filterData = boolean('filter dataset', true); - const data = filterData ? BABYNAME_DATA.filter(([year]) => year > 1950 && year < 1960) : BABYNAME_DATA; + const data = boolean('filter dataset', true) + ? BABYNAME_DATA.filter(([year]) => year > 1950 && year < 1960) + : BABYNAME_DATA; + const showLabels = boolean('show', true, 'labels'); + const useGlobalMinFontSize = boolean('use global min fontSize', true, 'labels'); + + const minFontSize = number('min fontSize', 6, { step: 1, min: 4, max: 10, range: true }, 'labels'); + const maxFontSize = number('max fontSize', 12, { step: 1, min: 10, max: 64, range: true }, 'labels'); + + const minCellHeight = number('min cell height', 10, { step: 1, min: 3, max: 8, range: true }, 'grid'); + const maxCellHeight = number('max cell height', 30, { step: 1, min: 8, max: 45, range: true }, 'grid'); return ( @@ -33,11 +42,11 @@ export const Example = () => { colorScale={{ type: 'bands', bands: [ - { start: -Infinity, end: 1000, color: '#ffffcc' }, - { start: 1000, end: 5000, color: '#a1dab4' }, - { start: 5000, end: 10000, color: '#41b6c4' }, - { start: 10000, end: 50000, color: '#2c7fb8' }, - { start: 50000, end: Infinity, color: '#253494' }, + { start: -Infinity, end: 1000, color: '#AADC32' }, + { start: 1000, end: 5000, color: '#35B779' }, + { start: 5000, end: 10000, color: '#24868E' }, + { start: 10000, end: 50000, color: '#3B528B' }, + { start: 50000, end: Infinity, color: '#471164' }, ], }} data={data} @@ -51,12 +60,18 @@ export const Example = () => { stroke: { width: 0, }, + cellHeight: { + min: minCellHeight, + max: maxCellHeight, + }, }, cell: { maxWidth: 'fill', - maxHeight: 20, label: { - visible: true, + minFontSize, + maxFontSize, + visible: showLabels, + useGlobalMinFontSize, }, border: { stroke: 'transparent',