diff --git a/packages/charts/src/_reset.scss b/packages/charts/src/_reset.scss index b82f6aa906..45b27b7950 100644 --- a/packages/charts/src/_reset.scss +++ b/packages/charts/src/_reset.scss @@ -9,3 +9,9 @@ svg text { letter-spacing: normal !important; } + + +html, body { + // font-family: 'Inter UI', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol' + font-family: 'Inter UI', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol' !important; +} \ No newline at end of file diff --git a/packages/charts/src/chart_types/bullet_graph/chart_state.tsx b/packages/charts/src/chart_types/bullet_graph/chart_state.tsx new file mode 100644 index 0000000000..8c5fa24af1 --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/chart_state.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { RefObject } from 'react'; + +import { BulletGraphRenderer } from './renderer/canvas'; +import { ChartType } from '../..'; +import { DEFAULT_CSS_CURSOR } from '../../common/constants'; +import { LegendItem } from '../../common/legend'; +import { BackwardRef, InternalChartState } from '../../state/chart_state'; +import { InitStatus } from '../../state/selectors/get_internal_is_intialized'; +import { LegendItemLabel } from '../../state/selectors/get_legend_items_labels'; + +const EMPTY_MAP = new Map(); +const EMPTY_LEGEND_LIST: LegendItem[] = []; +const EMPTY_LEGEND_ITEM_LIST: LegendItemLabel[] = []; + +/** @internal */ +export class BulletGraphState implements InternalChartState { + chartType = ChartType.BulletGraph; + getChartTypeDescription = () => 'Bullet Graph'; + chartRenderer = (backwordRef: BackwardRef, forwardStageRef: RefObject) => ( + + ); + + isInitialized = () => InitStatus.Initialized; + isBrushAvailable = () => false; + isBrushing = () => false; + isChartEmpty = () => false; + getLegendItems = () => EMPTY_LEGEND_LIST; + getLegendItemsLabels = () => EMPTY_LEGEND_ITEM_LIST; + getLegendExtraValues = () => EMPTY_MAP; + getPointerCursor = () => DEFAULT_CSS_CURSOR; + isTooltipVisible = () => ({ + visible: false, + isExternal: false, + displayOnly: false, + isPinnable: false, + }); + + getTooltipInfo = () => undefined; + getTooltipAnchor = () => null; + eventCallbacks = () => {}; + getProjectionContainerArea = () => ({ width: 0, height: 0, top: 0, left: 0 }); + getMainProjectionArea = () => ({ width: 0, height: 0, top: 0, left: 0 }); + getBrushArea = () => null; + getDebugState = () => ({}); + getSmallMultiplesDomains() { + return { + smHDomain: [], + smVDomain: [], + }; + } +} diff --git a/packages/charts/src/chart_types/bullet_graph/renderer/canvas/bullet_graph.ts b/packages/charts/src/chart_types/bullet_graph/renderer/canvas/bullet_graph.ts new file mode 100644 index 0000000000..71015e1d83 --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/renderer/canvas/bullet_graph.ts @@ -0,0 +1,367 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { scaleLinear } from 'd3-scale'; + +import { Color } from '../../../../common/colors'; +import { Ratio } from '../../../../common/geometry'; +import { cssFontShorthand } from '../../../../common/text_utils'; +import { withContext, clearCanvas } from '../../../../renderers/canvas'; +import { A11ySettings } from '../../../../state/selectors/get_accessibility_config'; +import { measureText } from '../../../../utils/bbox/canvas_text_bbox_calculator'; +import { clamp, isFiniteNumber } from '../../../../utils/common'; +import { Size } from '../../../../utils/dimensions'; +import { Point } from '../../../../utils/point'; +import { wrapText } from '../../../../utils/text/wrap'; +import { BulletGraphLayout, layout } from '../../selectors/layout'; +import { BulletDatum, BulletGraphSpec, BulletGraphSubtype } from '../../spec'; +import { + BulletGraphStyle, + GRAPH_PADDING, + HEADER_PADDING, + SUBTITLE_FONT, + SUBTITLE_FONT_SIZE, + SUBTITLE_LINE_HEIGHT, + TARGET_FONT, + TARGET_FONT_SIZE, + TICK_FONT, + TICK_FONT_SIZE, + TITLE_FONT, + TITLE_FONT_SIZE, + TITLE_LINE_HEIGHT, + VALUE_FONT, + VALUE_FONT_SIZE, + VALUE_LINE_HEIGHT, +} from '../../theme'; + +/** @internal */ +export function renderBulletGraph( + ctx: CanvasRenderingContext2D, + dpr: Ratio, + props: { + spec: BulletGraphSpec | undefined; + a11y: A11ySettings; + size: Size; + layout: BulletGraphLayout; + style: BulletGraphStyle; + bandColors: [string, string]; + }, +) { + const { style, layout, spec, bandColors } = props; + withContext(ctx, (ctx) => { + ctx.scale(dpr, dpr); + clearCanvas(ctx, props.style.background); + + // clear only if need to render metric or no spec available + if (!spec || layout.shouldRenderMetric) { + return; + } + + // render each Small multiple + ctx.fillStyle = props.style.background; + //@ts-expect-error + ctx.letterSpacing = 'normal'; + + layout.headerLayout.forEach((row, rowIndex) => + row.forEach((bulletGraph, columnIndex) => { + if (!bulletGraph) return; + const { panel, multiline } = bulletGraph; + withContext(ctx, (ctx) => { + const verticalAlignment = layout.layoutAlignment[rowIndex]!; + const panelY = panel.height * rowIndex; + const panelX = panel.width * columnIndex; + + // ctx.strokeRect(panelX, panelY, panel.width, panel.height); + + // move into the panel position + ctx.translate(panelX, panelY); + + // paint right border + // TODO: check paddings + ctx.strokeStyle = style.border; + if (row.length > 1 && columnIndex < row.length - 1) { + ctx.beginPath(); + ctx.moveTo(panel.width, 0); + ctx.lineTo(panel.width, panel.height); + ctx.stroke(); + } + + if (layout.headerLayout.length > 1 && columnIndex < layout.headerLayout.length) { + ctx.beginPath(); + ctx.moveTo(0, panel.height); + ctx.lineTo(panel.width, panel.height); + ctx.stroke(); + } + + // this helps render the header without considering paddings + ctx.translate(HEADER_PADDING.left, HEADER_PADDING.top); + + // TITLE + ctx.fillStyle = props.style.textColor; + ctx.textBaseline = 'top'; + ctx.textAlign = 'start'; + ctx.font = cssFontShorthand(TITLE_FONT, TITLE_FONT_SIZE); + bulletGraph.title.forEach((titleLine, lineIndex) => { + const y = lineIndex * TITLE_LINE_HEIGHT; + ctx.fillText(titleLine, 0, y); + }); + + // SUBTITLE + if (bulletGraph.subtitle) { + const y = verticalAlignment.maxTitleRows * TITLE_LINE_HEIGHT; + ctx.font = cssFontShorthand(SUBTITLE_FONT, SUBTITLE_FONT_SIZE); + ctx.fillText(bulletGraph.subtitle, 0, y); + } + + // VALUE + ctx.textBaseline = 'alphabetic'; + ctx.font = cssFontShorthand(VALUE_FONT, VALUE_FONT_SIZE); + if (!multiline) ctx.textAlign = 'end'; + { + const y = + verticalAlignment.maxTitleRows * TITLE_LINE_HEIGHT + + verticalAlignment.maxSubtitleRows * SUBTITLE_LINE_HEIGHT + + (multiline ? TARGET_FONT_SIZE : 0); + const x = multiline ? 0 : bulletGraph.header.width - bulletGraph.targetWidth; + ctx.fillText(bulletGraph.value, x, y); + } + + // TARGET + ctx.font = cssFontShorthand(TARGET_FONT, TARGET_FONT_SIZE); + if (!multiline) ctx.textAlign = 'end'; + { + const x = multiline ? bulletGraph.valueWidth : bulletGraph.header.width; + const y = + verticalAlignment.maxTitleRows * TITLE_LINE_HEIGHT + + verticalAlignment.maxSubtitleRows * SUBTITLE_LINE_HEIGHT + + (multiline ? TARGET_FONT_SIZE : 0); + ctx.fillText(bulletGraph.target, x, y); + } + + const graphSize = { + width: panel.width, + height: + panel.height - + HEADER_PADDING.top - + verticalAlignment.maxTitleRows * TITLE_LINE_HEIGHT - + verticalAlignment.maxSubtitleRows * SUBTITLE_LINE_HEIGHT - + (multiline ? VALUE_LINE_HEIGHT : 0) - + HEADER_PADDING.bottom, + }; + const graphOrigin = { + x: 0, + y: panel.height - graphSize.height, + }; + ctx.translate(-HEADER_PADDING.left, -HEADER_PADDING.top); + + if (spec.subtype === 'vertical') { + ctx.strokeStyle = style.border; + ctx.beginPath(); + ctx.moveTo(HEADER_PADDING.left, graphOrigin.y); + ctx.lineTo(panel.width - HEADER_PADDING.right, graphOrigin.y); + ctx.stroke(); + } + + withContext(ctx, (ctx) => { + ctx.translate(graphOrigin.x, graphOrigin.y); + + //DEBUG + //ctx.strokeRect(0, 0, graphSize.width, graphSize.height); + + if (spec.subtype === 'horizontal') { + horizontalBullet(ctx, bulletGraph.datum, graphOrigin, graphSize, style, bandColors); + } else { + verticalBullet(ctx, bulletGraph.datum, graphOrigin, graphSize, style, bandColors); + } + }); + }); + }), + ); + }); +} + +///////////////////////////// + +const TARGET_SIZE = 40; +const BULLET_SIZE = 32; +const BAR_SIZE = 12; + +function horizontalBullet( + ctx: CanvasRenderingContext2D, + + datum: BulletDatum, + origin: Point, + graphSize: Size, + style: BulletGraphStyle, + bandColors: [string, string], +) { + ctx.translate(GRAPH_PADDING.left, 0); + // TODO: add BASE + // const base = datum.domain.min < 0 && datum.domain.max > 0 ? 0 : NaN; + const paddedWidth = graphSize.width - GRAPH_PADDING.left - GRAPH_PADDING.right; + const scale = scaleLinear().domain([datum.domain.min, datum.domain.max]).range([0, paddedWidth]); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const colorScale = scaleLinear().domain([datum.domain.min, datum.domain.max]).range(bandColors); + const maxTicks = maxHorizontalTick(graphSize.width); + const colorTicks = scale.ticks(maxTicks - 1); + const colorBandSize = paddedWidth / colorTicks.length; + const { colors } = colorTicks.reduce<{ + last: number; + colors: Array<{ color: Color; size: number; position: number }>; + }>( + (acc, tick) => { + return { + last: acc.last + colorBandSize, + colors: [ + ...acc.colors, + { + color: `${colorScale(tick)}`, + size: colorBandSize, + position: acc.last, + }, + ], + }; + }, + { last: 0, colors: [] }, + ); + + // color bands + const verticalAlignment = TARGET_SIZE / 2; + colors.forEach((band) => { + ctx.fillStyle = band.color; + ctx.fillRect(band.position, verticalAlignment - BULLET_SIZE / 2, band.size, BULLET_SIZE); + }); + + // Ticks + ctx.beginPath(); + ctx.strokeStyle = style.background; + colorTicks + .filter((tick) => tick > datum.domain.min && tick < datum.domain.max) + .forEach((tick) => { + console.log(tick); + ctx.moveTo(scale(tick), verticalAlignment - BULLET_SIZE / 2); + ctx.lineTo(scale(tick), verticalAlignment + BULLET_SIZE / 2); + }); + ctx.stroke(); + + // Bar + ctx.fillStyle = style.barBackground; + ctx.fillRect(0, verticalAlignment - BAR_SIZE / 2, scale(clamp(datum.value, datum.domain.min, datum.domain.max)), 12); + + // TARGET + if (isFiniteNumber(datum.target) && (datum.target <= datum.domain.max || datum.target >= datum.domain.min)) { + ctx.fillRect(scale(datum.target) - 1.5, verticalAlignment - TARGET_SIZE / 2, 3, TARGET_SIZE); + } + // Tick labels + // TODO: add text measurement + ctx.fillStyle = style.textColor; + ctx.textBaseline = 'top'; + ctx.font = cssFontShorthand(TICK_FONT, TICK_FONT_SIZE); + colorTicks.forEach((tick, i) => { + ctx.textAlign = i === colorTicks.length - 1 ? 'end' : 'start'; + ctx.fillText(datum.tickFormatter(tick), scale(tick), verticalAlignment + TARGET_SIZE / 2); + }); +} + +function verticalBullet( + ctx: CanvasRenderingContext2D, + datum: BulletDatum, + origin: Point, + graphSize: Size, + style: BulletGraphStyle, + bandColors: [string, string], +) { + ctx.translate(0, GRAPH_PADDING.top); + const graphPaddedHeight = graphSize.height - GRAPH_PADDING.bottom - GRAPH_PADDING.top; + // TODO: add BASE + // const base = datum.domain.min < 0 && datum.domain.max > 0 ? 0 : NaN; + const scale = scaleLinear().domain([datum.domain.min, datum.domain.max]).range([0, graphPaddedHeight]).clamp(true); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + //#6092C0, #3F4E61 + + const colorScale = scaleLinear().domain([datum.domain.min, datum.domain.max]).range(bandColors); + const maxTicks = 4; + const colorTicks = scale.ticks(maxTicks - 1); + const colorBandSize = graphPaddedHeight / colorTicks.length; + const { colors } = colorTicks.reduce<{ + last: number; + colors: Array<{ color: Color; size: number; position: number }>; + }>( + (acc, tick) => { + return { + last: acc.last + colorBandSize, + colors: [ + ...acc.colors, + { + color: `${colorScale(tick)}`, + size: colorBandSize, + position: acc.last, + }, + ], + }; + }, + { last: 0, colors: [] }, + ); + + // color bands + + colors.forEach((band, index) => { + ctx.fillStyle = band.color; + ctx.fillRect( + graphSize.width / 2 - BULLET_SIZE / 2, + graphPaddedHeight - band.position - band.size, + BULLET_SIZE, + band.size, + ); + }); + + // Ticks + ctx.beginPath(); + ctx.strokeStyle = style.background; + colorTicks + .filter((tick) => tick > datum.domain.min && tick < datum.domain.max) + .forEach((tick) => { + ctx.moveTo(graphSize.width / 2 - BULLET_SIZE / 2, graphPaddedHeight - scale(tick)); + ctx.lineTo(graphSize.width / 2 + BULLET_SIZE / 2, graphPaddedHeight - scale(tick)); + }); + ctx.stroke(); + + // Bar + ctx.fillStyle = style.barBackground; + ctx.fillRect( + graphSize.width / 2 - BAR_SIZE / 2, + graphPaddedHeight - scale(datum.value), + BAR_SIZE, + scale(datum.value), + ); + + // target + if (isFiniteNumber(datum.target)) { + ctx.fillRect(graphSize.width / 2 - TARGET_SIZE / 2, graphPaddedHeight - scale(datum.target) - 1.5, TARGET_SIZE, 3); + } + + // Tick labels + // TODO: add text measurement + ctx.textBaseline = 'top'; + ctx.fillStyle = style.textColor; + ctx.font = cssFontShorthand(TICK_FONT, TICK_FONT_SIZE); + colorTicks.forEach((tick, i) => { + ctx.textAlign = 'end'; + ctx.textBaseline = i === colorTicks.length - 1 ? 'hanging' : 'bottom'; + ctx.fillText(datum.tickFormatter(tick), graphSize.width / 2 - TARGET_SIZE / 2 - 6, graphPaddedHeight - scale(tick)); + }); +} + +function maxHorizontalTick(panelWidth: number) { + return panelWidth > 250 ? 4 : 3; +} +function maxVerticalTick(panelHeight: number) { + return panelHeight > 200 ? 4 : 3; +} diff --git a/packages/charts/src/chart_types/bullet_graph/renderer/canvas/index.tsx b/packages/charts/src/chart_types/bullet_graph/renderer/canvas/index.tsx new file mode 100644 index 0000000000..d670205d24 --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/renderer/canvas/index.tsx @@ -0,0 +1,238 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable-next-line eslint-comments/disable-enable-pair */ +/* eslint-disable react/no-array-index-key */ + +import { scaleLinear } from 'd3-scale'; +import React, { RefObject } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators, Dispatch } from 'redux'; + +import { renderBulletGraph } from './bullet_graph'; +import { AlignedGrid } from '../../../../components/grid/aligned_grid'; +import { ElementClickListener, BasicListener, ElementOverListener } from '../../../../specs'; +import { onChartRendered } from '../../../../state/actions/chart'; +import { GlobalChartState } from '../../../../state/chart_state'; +import { + A11ySettings, + DEFAULT_A11Y_SETTINGS, + getA11ySettingsSelector, +} from '../../../../state/selectors/get_accessibility_config'; +import { getChartThemeSelector } from '../../../../state/selectors/get_chart_theme'; +import { getInternalIsInitializedSelector, InitStatus } from '../../../../state/selectors/get_internal_is_intialized'; +import { Size } from '../../../../utils/dimensions'; +import { deepEqual } from '../../../../utils/fast_deep_equal'; +import { Metric } from '../../../metric/renderer/dom/metric'; +import { getBulletGraphSpec, chartSize } from '../../selectors/chart_size'; +import { BulletGraphLayout, layout } from '../../selectors/layout'; +import { BulletDatum, BulletGraphSpec } from '../../spec'; +import { BulletGraphStyle, LIGHT_THEME_BULLET_STYLE } from '../../theme'; + +interface StateProps { + initialized: boolean; + chartId: string; + spec: BulletGraphSpec | undefined; + a11y: A11ySettings; + size: Size; + layout: BulletGraphLayout; + style: BulletGraphStyle; + bandColors: [string, string]; + onElementClick?: ElementClickListener; + onElementOut?: BasicListener; + onElementOver?: ElementOverListener; +} + +interface DispatchProps { + onChartRendered: typeof onChartRendered; +} + +interface OwnProps { + forwardStageRef: RefObject; +} + +type Props = DispatchProps & StateProps & OwnProps; + +class Component extends React.Component { + static displayName = 'BulletGraph'; + private ctx: CanvasRenderingContext2D | null; + private readonly devicePixelRatio: number; + + constructor(props: Readonly) { + super(props); + this.ctx = null; + this.devicePixelRatio = window.devicePixelRatio; + } + + componentDidMount() { + this.tryCanvasContext(); + if (this.props.initialized) { + this.drawCanvas(); + this.props.onChartRendered(); + } + } + + shouldComponentUpdate(nextProps: Props) { + return !deepEqual(this.props, nextProps); + } + + componentDidUpdate() { + if (!this.ctx) { + this.tryCanvasContext(); + } + if (this.props.initialized) { + this.drawCanvas(); + this.props.onChartRendered(); + } + } + + private tryCanvasContext() { + const canvas = this.props.forwardStageRef.current; + this.ctx = canvas && canvas.getContext('2d'); + } + + private drawCanvas() { + if (this.ctx) { + renderBulletGraph(this.ctx, this.devicePixelRatio, this.props); + } + } + + // eslint-disable-next-line @typescript-eslint/member-ordering + render() { + const { initialized, size, forwardStageRef, a11y, layout, spec, style } = this.props; + if (!initialized || size.width === 0 || size.height === 0 || !spec) { + return null; + } + + return ( +
+ + {layout.shouldRenderMetric && ( +
+ + data={spec.data} + headerComponent={({ datum, stats }) => { + return null; + }} + contentComponent={({ datum, stats }) => { + const colorScale = scaleLinear() + .domain([datum.domain.min, datum.domain.max]) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .range(this.props.bandColors); + return ( + + target: {datum.valueFormatter(datum.target)} + + ) : undefined, + }} + totalRows={stats.rows} + totalColumns={stats.columns} + columnIndex={stats.columnIndex} + rowIndex={stats.rowIndex} + style={{ + background: style.background, + barBackground: `${colorScale(datum.value)}`, + border: 'gray', + minHeight: 0, + text: { + lightColor: 'white', + darkColor: 'black', + }, + nonFiniteText: 'N/A', + }} + panel={{ width: size.width / stats.columns, height: size.height / stats.rows }} + /> + ); + }} + /> + ); +
+ )} +
+ ); + } +} + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => + bindActionCreators( + { + onChartRendered, + }, + dispatch, + ); + +const DEFAULT_PROPS: StateProps = { + initialized: false, + chartId: '', + spec: undefined, + size: { + width: 0, + height: 0, + }, + a11y: DEFAULT_A11Y_SETTINGS, + + layout: { + headerLayout: [], + layoutAlignment: [], + shouldRenderMetric: false, + }, + style: LIGHT_THEME_BULLET_STYLE, + bandColors: ['#D9C6EF', '#AA87D1'], +}; + +const mapStateToProps = (state: GlobalChartState): StateProps => { + if (getInternalIsInitializedSelector(state) !== InitStatus.Initialized) { + return DEFAULT_PROPS; + } + const theme = getChartThemeSelector(state); + + // const { onElementClick, onElementOut, onElementOver } = getSettingsSpecSelector(state); + return { + initialized: true, + chartId: state.chartId, + spec: getBulletGraphSpec(state)[0], + size: chartSize(state), + a11y: getA11ySettingsSelector(state), + layout: layout(state), + style: theme.bulletGraph, + bandColors: theme.background.fallbackColor === 'black' ? ['#6092C0', '#3F4E61'] : ['#D9C6EF', '#AA87D1'], //['#6092C0', '#3F4E61'] + //.range(['#D9C6EF', '#AA87D1']); + // onElementClick, + // onElementOver, + // onElementOut, + }; +}; + +/** @internal */ +export const BulletGraphRenderer = connect(mapStateToProps, mapDispatchToProps)(Component); diff --git a/packages/charts/src/chart_types/bullet_graph/renderer/dom/_header.scss b/packages/charts/src/chart_types/bullet_graph/renderer/dom/_header.scss new file mode 100644 index 0000000000..93bb5461f3 --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/renderer/dom/_header.scss @@ -0,0 +1,70 @@ +.echBulletGraphHeader { + position: relative; + padding: 8px 8px 0 8px; + + &__title { + font-weight: bold; + font-size: 16px; + line-height: 20px; + color: $euiTextColor; + } + &__subtitle { + font-weight: normal; + font-size: 14px; + line-height: 17px; + color: $euiColorDarkShade; + } + display: flex; + flex-direction: column; + flex-wrap: nowrap; + &--vertical { + border-bottom: 1px solid ; + } +} + +.echBulletGraphHeader--single { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-end; + width: 100%; + overflow: hidden; + .echBulletGraphHeader__title { + @include lineClamp(2); + } + //padding-bottom: 4px; +} + +.echBulletGraphHeader--multi { + display: flex; + flex-direction: column; + flex-wrap: wrap; + justify-content: space-between; + height: 100%; + .echBulletGraphHeader__title { + width: 100%; + @include lineClamp(2); + } + .echBulletGraphHeader__subtitle { + @include lineClamp(1); + flex-grow: 1; + flex-shrink: 1; + } + +} + +.echBulletHeader__valueContainer { + white-space: nowrap; + flex-grow: 0; + flex-shrink: 0; + align-items: baseline; +} +.echBulletHeader__value { + font-weight: 700; + font-size: 22px; +} +.echBulletHeader__target { + font-weight: 500; + font-size: 16px; +} diff --git a/packages/charts/src/chart_types/bullet_graph/renderer/dom/_index.scss b/packages/charts/src/chart_types/bullet_graph/renderer/dom/_index.scss new file mode 100644 index 0000000000..beb0a932ae --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/renderer/dom/_index.scss @@ -0,0 +1,59 @@ +@import 'header'; + +.echBulletGraphContainer { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + align-items: stretch; + svg { + overflow: visible; + rect { + stroke: none; + } + } +} + +.echBulletGraphSVG--container { + flex: 1; + padding: 10px; + height: 100%; + overflow: visible; + position: relative; +} +.echBulletGraphSVG--container svg { + overflow: visible; +} +.echBulletGraph { + .echBullet--tickLabel { + font-size: 10px; + fill: $euiTextColor; + } + + .echBullet--tickLabel { + font-size: 10px; + fill: $euiTextColor; + } + .echBullet--target { + fill: $euiTextColor; + } + .echBullet--bar { + fill: #333333;//$euiTextColor; + } + .echBullet--tick { + stroke: $euiPageBackgroundColor; + } + .echBullet--angularTick { + fill: $euiPageBackgroundColor; + } + + .echBullet--angularBar { + stroke: $euiTextColor; + } + .echBullet--angularTick { + stroke: $euiTextColor; + } + .echBullet--angularTickLabel { + fill: $euiTextColor; + } +} diff --git a/packages/charts/src/chart_types/bullet_graph/renderer/dom/bullet.tsx b/packages/charts/src/chart_types/bullet_graph/renderer/dom/bullet.tsx new file mode 100644 index 0000000000..d60fb9b885 --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/renderer/dom/bullet.tsx @@ -0,0 +1,351 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { clamp } from 'lodash'; +import React, { memo, useCallback, useLayoutEffect, useRef, useState } from 'react'; + +import { Color } from '../../../../common/colors'; +import { Ratio } from '../../../../common/geometry'; +import { Size } from '../../../../utils/dimensions'; +import { BulletDatum } from '../../spec'; + +/** @internal */ +export interface BulletProps { + datum: BulletDatum; + colorBands: Array<{ color: Color; height: number; y: number }>; + + value: Ratio; + // base: Ratio; + target: Ratio; + + ticks: Ratio[]; + labels: Array<{ text: string; position: Ratio }>; + size: Size; +} + +const ratioToPercent = (value: Ratio) => `${Number(clamp(value * 100, 0, 100).toFixed(3))}%`; + +const TARGET_SIZE = 40; +const BULLET_SIZE = 32; +const BAR_SIZE = 12; + +function VerticalBulletComp(props: BulletProps) { + return ( +
+ + + {props.colorBands.map((band, index) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + {/* {Number.isFinite(props.base) && ( */} + {/* */} + {/* */} + {/* */} + {/* )} */} + + {props.ticks + .filter((tick) => tick > 0 && tick < 1) + .map((tick) => ( + + ))} + + + {Number.isFinite(props.target) && ( + + )} + + {props.labels.map((label) => { + return ( + + {label.text} + + ); + })} + + +
+ ); +} + +/** @internal */ +export const VerticalBullet = memo(VerticalBulletComp); + +/** @internal */ +export function HorizontalBulletComp(props: BulletProps) { + return ( +
+ + + {props.colorBands.map((band, index) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + {/* {Number.isFinite(props.base) && ( */} + {/* */} + {/* */} + {/* */} + {/* )} */} + + {props.ticks + .filter((tick) => tick > 0 && tick < 1) + .map((tick) => ( + + ))} + + + + {Number.isFinite(props.target) && ( + + )} + + {props.labels.map((label) => { + return ( + + {label.text} + + ); + })} + + +
+ ); +} + +/** @internal */ +export const HorizontalBullet = memo(HorizontalBulletComp); + +function polarToCartesian(centerX: number, centerY: number, radius: number, angleInDegrees: number) { + const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0; + + return { + x: centerX + radius * Math.cos(angleInRadians), + y: centerY + radius * Math.sin(angleInRadians), + }; +} + +function describeArc(x: number, y: number, radius: number, startAngle: number, endAngle: number) { + const start = polarToCartesian(x, y, radius, endAngle); + const end = polarToCartesian(x, y, radius, startAngle); + + const arcSweep = endAngle - startAngle <= 180 ? '0' : '1'; + + return ['M', start.x, start.y, 'A', radius, radius, 0, arcSweep, 0, end.x, end.y].join(' '); +} + +/** @internal */ +export function AngularBulletComp(props: BulletProps) { + // const ref = useRef(null); + const [fontSize, setFontSize] = useState(10); + + const onResize = useCallback((target, entry) => { + // Handle the resize event + const minSize = Math.min(entry.contentRect.width, entry.contentRect.height); + setFontSize(minSize / 100); + console.log(minSize); + }, []); + + const ref = useResizeObserver(onResize); + + return ( +
+ + + {props.colorBands.map((band, index) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + {props.ticks + .filter((tick) => tick > 0 && tick < 1) + .map((tick) => ( + + ))} + + + + {Number.isFinite(props.target) && ( + + )} + {props.labels.map((label) => { + const coord = polarToCartesian(50, 10, 35, 240 * label.position - 120); + return ( + 0.5 ? 'end' : 'start'} + > + {label.text} + + ); + })} + + + {/* */} + {/* */} + {/* {props.labels.map((label) => { */} + {/* const coord = polarToCartesian(50, 10, 45, 240 * label.position - 120); */} + {/* return ( */} + {/* 0.5 ? 'end' : 'start'} */} + {/* > */} + {/* {label.text} */} + {/* */} + {/* ); */} + {/* })} */} + {/* */} + {/* */} +
+ ); +} + +/** @internal */ +export const AngularBullet = memo(AngularBulletComp); + +function useResizeObserver(callback: (target: T, entry: ResizeObserverEntry) => void) { + const ref = useRef(null); + + useLayoutEffect(() => { + const element = ref?.current; + + if (!element) { + return; + } + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + callback(element, entry); + } + }); + + observer.observe(element); + return () => { + observer.disconnect(); + }; + }, [callback, ref]); + + return ref; +} diff --git a/packages/charts/src/chart_types/bullet_graph/renderer/dom/header.tsx b/packages/charts/src/chart_types/bullet_graph/renderer/dom/header.tsx new file mode 100644 index 0000000000..f514eba433 --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/renderer/dom/header.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +/** @internal */ +export function Header(props: { title: string; subtitle: string; value: string; target?: string }) { + return props.subtitle.trim().length > 0 ? ( +
+

{props.title}

+
+

{props.subtitle}

+

+ {props.value}K + {props.target ? / {props.target}M : ''} +

+
+
+ ) : ( +
+

{props.title}

+

+ {props.value}K + {props.target ? / {props.target}M : ''} +

+
+ ); +} diff --git a/packages/charts/src/chart_types/bullet_graph/renderer/dom/headertest.html b/packages/charts/src/chart_types/bullet_graph/renderer/dom/headertest.html new file mode 100644 index 0000000000..ac9a2fd8ba --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/renderer/dom/headertest.html @@ -0,0 +1,121 @@ + + + + + Title + + + +
+

Some text, a header perhaps? But this is the next that will sit under the image, sometimes it's a p tag.

+ +

Some more random text that would appear in the messagebox this could go on for a few lines.

+
+ + diff --git a/packages/charts/src/chart_types/bullet_graph/renderer/dom/index.tsx b/packages/charts/src/chart_types/bullet_graph/renderer/dom/index.tsx new file mode 100644 index 0000000000..06f6d392a3 --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/renderer/dom/index.tsx @@ -0,0 +1,214 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable-next-line eslint-comments/disable-enable-pair */ +/* eslint-disable react/no-array-index-key */ + +import { scaleLinear } from 'd3-scale'; +import React from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators, Dispatch } from 'redux'; + +import { VerticalBullet, HorizontalBullet, AngularBullet } from './bullet'; +import { Header } from './header'; +import { Color } from '../../../../common/colors'; +import { AlignedGrid } from '../../../../components/grid/aligned_grid'; +import { ElementClickListener, BasicListener, ElementOverListener } from '../../../../specs'; +import { onChartRendered } from '../../../../state/actions/chart'; +import { GlobalChartState } from '../../../../state/chart_state'; +import { + A11ySettings, + DEFAULT_A11Y_SETTINGS, + getA11ySettingsSelector, +} from '../../../../state/selectors/get_accessibility_config'; +import { getInternalIsInitializedSelector, InitStatus } from '../../../../state/selectors/get_internal_is_intialized'; +import { Size } from '../../../../utils/dimensions'; +import { Metric } from '../../../metric/renderer/dom/metric'; +import { getBulletGraphSpec, chartSize } from '../../selectors/chart_size'; +import { BulletGraphSpec, BulletDatum, BulletGraphSubtype } from '../../spec'; + +interface StateProps { + initialized: boolean; + chartId: string; + specs: BulletGraphSpec[]; + a11y: A11ySettings; + size: Size; + onElementClick?: ElementClickListener; + onElementOut?: BasicListener; + onElementOver?: ElementOverListener; +} + +interface DispatchProps { + onChartRendered: typeof onChartRendered; +} + +const Component: React.FC = ({ + initialized, + size, + specs: [spec], // ignoring other specs +}) => { + if (!initialized || !spec) { + return null; + } + + const { data, subtype } = spec; + const BulletComponent = + subtype === 'horizontal' ? HorizontalBullet : subtype === 'vertical' ? VerticalBullet : AngularBullet; + + return ( + + data={data} + headerComponent={({ datum, stats }) => { + return switchToMetric(size, stats.rows, stats.columns, subtype) ? null : ( +
+ ); + }} + contentComponent={({ datum, stats }) => { + // TODO move to the bullet SVG + // const base = datum.domain.min < 0 && datum.domain.max > 0 ? 0 : NaN; + const scale = scaleLinear().domain([datum.domain.min, datum.domain.max]).range([0, 1]); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const colorScale = scaleLinear().domain([datum.domain.min, datum.domain.max]).range(['#D9C6EF', '#AA87D1']); + const maxTicks = + subtype === 'horizontal' + ? maxHorizontalTick(size.width, stats.columns) + : maxVerticalTick(size.height, stats.rows); + const colorTicks = scale.ticks(maxTicks - 1); + const colorBandSize = 1 / colorTicks.length; + const { colors } = colorTicks.reduce<{ + last: number; + colors: Array<{ color: Color; height: number; y: number }>; + }>( + (acc, tick) => { + return { + last: acc.last + colorBandSize, + colors: [ + ...acc.colors, + { + color: `${colorScale(tick)}`, + height: colorBandSize, + y: acc.last, + }, + ], + }; + }, + { last: 0, colors: [] }, + ); + + return switchToMetric(size, stats.rows, stats.columns, subtype) ? ( + + ) : ( + { + return { + text: datum.tickFormatter(tick), + position: scale(tick), + }; + })} + /> + ); + }} + /> + ); +}; + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => + bindActionCreators( + { + onChartRendered, + }, + dispatch, + ); + +const DEFAULT_PROPS: StateProps = { + initialized: false, + chartId: '', + specs: [], + size: { + width: 0, + height: 0, + }, + a11y: DEFAULT_A11Y_SETTINGS, +}; + +const mapStateToProps = (state: GlobalChartState): StateProps => { + if (getInternalIsInitializedSelector(state) !== InitStatus.Initialized) { + return DEFAULT_PROPS; + } + // const { onElementClick, onElementOut, onElementOver } = getSettingsSpecSelector(state); + return { + initialized: true, + chartId: state.chartId, + specs: getBulletGraphSpec(state), + size: chartSize(state), + a11y: getA11ySettingsSelector(state), + // onElementClick, + // onElementOver, + // onElementOut, + }; +}; + +/** @internal */ +export const BulletGraphRenderer = connect(mapStateToProps, mapDispatchToProps)(Component); + +function maxHorizontalTick(panelWidth: number, columns: number) { + return panelWidth / columns > 250 ? 4 : 3; +} +function maxVerticalTick(panelHeight: number, rows: number) { + return panelHeight / rows > 200 ? 4 : 3; +} + +function switchToMetric(size: Size, rows: number, columns: number, subtype: BulletGraphSubtype) { + switch (subtype) { + case BulletGraphSubtype.horizontal: + return size.width / columns < 200 || size.height / rows < 100; + case BulletGraphSubtype.vertical: + return size.width / columns < 150 || size.height / rows < 150; + case BulletGraphSubtype.angular: + return size.width / columns < 200 || size.height / rows < 200; + } +} diff --git a/packages/charts/src/chart_types/bullet_graph/renderer/dom/test.html b/packages/charts/src/chart_types/bullet_graph/renderer/dom/test.html new file mode 100644 index 0000000000..0afc023bec --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/renderer/dom/test.html @@ -0,0 +1,240 @@ + + + + + Canvas vs HTML rendering + + + + +
+ + + + + + + + + + + + + + + + +
font sizetexthtml sizecanvas textcanvas sizediff
+ + + + diff --git a/packages/charts/src/chart_types/bullet_graph/selectors/chart_size.ts b/packages/charts/src/chart_types/bullet_graph/selectors/chart_size.ts new file mode 100644 index 0000000000..59cf3829da --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/selectors/chart_size.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ChartType } from '../../../chart_types'; +import { BulletGraphSpec } from '../../../chart_types/bullet_graph/spec'; +import { SpecType } from '../../../specs'; +import { GlobalChartState } from '../../../state/chart_state'; +import { createCustomCachedSelector } from '../../../state/create_selector'; +import { getSpecsByType } from '../../../state/selectors/get_specs_by_type'; +import { Dimensions } from '../../../utils/dimensions'; + +const getParentDimension = (state: GlobalChartState) => state.parentDimensions; + +/** @internal */ +export const chartSize = createCustomCachedSelector([getParentDimension], (container): Dimensions => { + return { ...container }; +}); + +/** @internal */ +export const getBulletGraphSpec = getSpecsByType(ChartType.BulletGraph, SpecType.Series); diff --git a/packages/charts/src/chart_types/bullet_graph/selectors/layout.ts b/packages/charts/src/chart_types/bullet_graph/selectors/layout.ts new file mode 100644 index 0000000000..08b339942e --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/selectors/layout.ts @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { chartSize, getBulletGraphSpec } from './chart_size'; +import { BulletDatum } from '../../../chart_types/bullet_graph/spec'; +import { createCustomCachedSelector } from '../../../state/create_selector'; +import { withTextMeasure } from '../../../utils/bbox/canvas_text_bbox_calculator'; +import { Size } from '../../../utils/dimensions'; +import { wrapText } from '../../../utils/text/wrap'; +import { + HEADER_PADDING, + SUBTITLE_FONT, + SUBTITLE_FONT_SIZE, + SUBTITLE_LINE_HEIGHT, + TARGET_FONT, + TARGET_FONT_SIZE, + TITLE_FONT, + TITLE_FONT_SIZE, + TITLE_LINE_HEIGHT, + VALUE_FONT, + VALUE_FONT_SIZE, + VALUE_LINE_HEIGHT, +} from '../theme'; + +/** @internal */ +export interface BulletGraphLayout { + headerLayout: Array< + Array< + | { + panel: Size; + header: Size; + title: string[]; + subtitle: string | undefined; + value: string; + target: string; + multiline: boolean; + valueWidth: number; + targetWidth: number; + sizes: { title: number; subtitle: number; value: number; target: number }; + datum: BulletDatum; + } + | undefined + > + >; + layoutAlignment: Array<{ + maxTitleRows: number; + maxSubtitleRows: number; + multiline: boolean; + minHeight: number; + minWidth: number; + }>; + shouldRenderMetric: boolean; +} + +/** @internal */ +export const layout = createCustomCachedSelector([getBulletGraphSpec, chartSize], (specs, size): BulletGraphLayout => { + const spec = specs[0]!; + + const { data } = spec; + + const rows = data.length; + const columns = data.reduce((acc, row) => { + return Math.max(acc, row.length); + }, 0); + + const panel: Size = { width: size.width / columns, height: size.height / rows }; + const headerSize: Size = { + width: panel.width - HEADER_PADDING.left - HEADER_PADDING.right, + height: panel.height - HEADER_PADDING.top - HEADER_PADDING.bottom, + }; + + return withTextMeasure((textMeasurer) => { + // collect header elements title, subtitles and values + const header = data.map((row) => + row.map((cell) => { + if (!cell) { + return undefined; + } + const content = { + title: cell.title.trim(), + subtitle: cell.subtitle?.trim(), + value: cell.valueFormatter(cell.value), + target: cell.target ? `/ ${cell.valueFormatter(cell.target)}` : '', + datum: cell, + }; + const size = { + title: textMeasurer(content.title.trim(), TITLE_FONT, TITLE_FONT_SIZE).width, + subtitle: content.subtitle ? textMeasurer(content.subtitle, TITLE_FONT, TITLE_FONT_SIZE).width : 0, + value: textMeasurer(content.value, VALUE_FONT, VALUE_FONT_SIZE).width, + target: textMeasurer(content.target, TARGET_FONT, TARGET_FONT_SIZE).width, + }; + return { content, size }; + }), + ); + + const goesToMultiline = header.some((row) => { + const valueAlignedWithSubtitle = row.some((cell) => cell?.content.subtitle); + return row.some((cell) => { + if (!cell) return false; + const valuesWidth = cell.size.value + cell.size.target; + return valueAlignedWithSubtitle + ? cell.size.subtitle + valuesWidth > headerSize.width || cell.size.title > headerSize.width + : cell.size.title + valuesWidth > headerSize.width; + }); + }); + + const headerLayout = header.map((row) => { + return row.map((cell) => { + if (!cell) return undefined; + if (goesToMultiline) { + // wrap only title if necessary + return { + panel, + header: headerSize, + title: wrapText(cell.content.title, TITLE_FONT, TITLE_FONT_SIZE, headerSize.width, 2, textMeasurer), + subtitle: cell.content.subtitle + ? wrapText(cell.content.subtitle, SUBTITLE_FONT, SUBTITLE_FONT_SIZE, headerSize.width, 1, textMeasurer)[0] + : undefined, + value: cell.content.value, + target: cell.content.target, + multiline: true, + valueWidth: cell.size.value, + targetWidth: cell.size.target, + sizes: cell.size, + datum: cell.content.datum, + }; + } + // wrap only title if necessary + return { + panel, + header: headerSize, + title: [cell.content.title], + subtitle: cell.content.subtitle ? cell.content.subtitle : undefined, + value: cell.content.value, + target: cell.content.target, + multiline: false, + valueWidth: cell.size.value, + targetWidth: cell.size.target, + sizes: cell.size, + datum: cell.content.datum, + }; + }); + }); + const layoutAlignment = headerLayout.map((curr) => { + return curr.reduce( + (rowStats, cell) => { + const maxTitleRows = Math.max(rowStats.maxTitleRows, cell?.title.length ?? 0); + const maxSubtitleRows = Math.max(rowStats.maxSubtitleRows, cell?.subtitle ? 1 : 0); + return { + maxTitleRows, + maxSubtitleRows, + multiline: cell?.multiline ?? false, + minHeight: + maxTitleRows * TITLE_LINE_HEIGHT + + maxSubtitleRows * SUBTITLE_LINE_HEIGHT + + (cell?.multiline ? VALUE_LINE_HEIGHT : 0) + + HEADER_PADDING.top + + HEADER_PADDING.bottom + + (spec.subtype === 'horizontal' ? 50 : 100), // chart height + minWidth: spec.subtype === 'horizontal' ? 140 : 140, + }; + }, + { maxTitleRows: 0, maxSubtitleRows: 0, multiline: false, minHeight: 0, minWidth: 0 }, + ); + }); + + const totalHeight = layoutAlignment.reduce((acc, curr) => { + return acc + curr.minHeight; + }, 0); + + const totalWidth = layoutAlignment.reduce((acc, curr) => { + return Math.max(acc, curr.minWidth); + }, 0); + const shouldRenderMetric = size.height <= totalHeight || size.width <= totalWidth * columns; + + return { + headerLayout, + layoutAlignment, + shouldRenderMetric, + }; + }); +}); diff --git a/packages/charts/src/chart_types/bullet_graph/spec.ts b/packages/charts/src/chart_types/bullet_graph/spec.ts new file mode 100644 index 0000000000..295ed1e14c --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/spec.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ComponentProps } from 'react'; +import { $Values } from 'utility-types'; + +import { ChartType } from '../../chart_types/index'; +import { Spec } from '../../specs'; +import { SpecType } from '../../specs/constants'; +import { buildSFProps, SFProps, useSpecFactory } from '../../state/spec_factory'; +import { stripUndefined } from '../../utils/common'; + +/** @internal */ +export interface BulletDatum { + title: string; + subtitle?: string; + value: number; + valueFormatter: (value: number) => string; + target?: number; + domain: { min: number; max: number; nice: boolean }; + ticks: 'auto' | number[]; + tickFormatter: (value: number) => string; +} + +/** @public */ +export const BulletGraphSubtype = Object.freeze({ + vertical: 'vertical' as const, + horizontal: 'horizontal' as const, + angular: 'angular' as const, +}); +/** @public */ +export type BulletGraphSubtype = $Values; + +/** @alpha */ +export interface BulletGraphSpec extends Spec { + specType: typeof SpecType.Series; + chartType: typeof ChartType.BulletGraph; + data: (BulletDatum | undefined)[][]; + subtype: BulletGraphSubtype; +} + +const defaultBulletGraph = {}; + +const buildProps = buildSFProps()( + { + specType: SpecType.Series, + chartType: ChartType.BulletGraph, + }, + { + ...defaultBulletGraph, + }, +); + +/** + * Add Goal spec to chart + * @alpha + */ +export const BulletGraph = function ( + props: SFProps< + BulletGraphSpec, + keyof (typeof buildProps)['overrides'], + keyof (typeof buildProps)['defaults'], + keyof (typeof buildProps)['optionals'], + keyof (typeof buildProps)['requires'] + >, +) { + const { defaults, overrides } = buildProps; + // const angleStart = props.angleStart ?? defaults.angleStart; + // const angleEnd = props.angleEnd ?? defaults.angleEnd; + const constraints = {}; + // + // if (Math.abs(angleEnd - angleStart) > TAU) { + // constraints.angleEnd = angleStart + TAU * Math.sign(angleEnd - angleStart); + // + // Logger.warn(`The total angle of the goal chart must not exceed 2π radians.\ + // To prevent overlapping, the value of \`angleEnd\` will be replaced. + // + // original: ${angleEnd} (~${round(angleEnd / Math.PI, 3)}π) + // replaced: ${constraints.angleEnd} (~${round(constraints.angleEnd / Math.PI, 3)}π) + // `); + // } + + useSpecFactory({ + ...defaults, + ...stripUndefined(props), + ...overrides, + ...constraints, + }); + return null; +}; + +/** @public */ +export type BulletGraphProps = ComponentProps; diff --git a/packages/charts/src/chart_types/bullet_graph/theme.ts b/packages/charts/src/chart_types/bullet_graph/theme.ts new file mode 100644 index 0000000000..ee706ac687 --- /dev/null +++ b/packages/charts/src/chart_types/bullet_graph/theme.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Color } from '../../common/colors'; +import { DEFAULT_FONT_FAMILY } from '../../common/default_theme_attributes'; +import { Pixels } from '../../common/geometry'; +import { Font } from '../../common/text_utils'; +import { Padding } from '../../utils/dimensions'; + +/** @public */ +export interface BulletGraphStyle { + textColor: Color; + border: Color; + background: Color; + barBackground: Color; + nonFiniteText: string; + minHeight: Pixels; +} + +/** @internal */ +export const LIGHT_THEME_BULLET_STYLE: BulletGraphStyle = { + textColor: '#343741', + border: '#EDF0F5', + barBackground: '#343741', + background: '#FFFFFF', + nonFiniteText: 'N/A', + minHeight: 64, +}; + +/** @internal */ +export const DARK_THEME_BULLET_STYLE: BulletGraphStyle = { + textColor: '#E0E5EE', + border: '#343741', + barBackground: '#FFF', + background: '#1D1E23', + nonFiniteText: 'N/A', + minHeight: 64, +}; + +/** @internal */ +export const TITLE_FONT: Font = { + fontStyle: 'normal', + fontFamily: DEFAULT_FONT_FAMILY, + fontVariant: 'normal', + fontWeight: 'bold', + textColor: 'black', +}; +/** @internal */ +export const TITLE_FONT_SIZE = 16; +/** @internal */ +export const TITLE_LINE_HEIGHT = 19; + +/** @internal */ +export const SUBTITLE_FONT: Font = { + ...TITLE_FONT, + fontWeight: 'normal', +}; +/** @internal */ +export const SUBTITLE_FONT_SIZE = 14; +/** @internal */ +export const SUBTITLE_LINE_HEIGHT = 16; + +/** @internal */ +export const VALUE_FONT: Font = { + ...TITLE_FONT, +}; +/** @internal */ +export const VALUE_FONT_SIZE = 22; +/** @internal */ +export const VALUE_LINE_HEIGHT = 22; + +/** @internal */ +export const TARGET_FONT: Font = { + ...SUBTITLE_FONT, +}; +/** @internal */ +export const TARGET_FONT_SIZE = 16; +/** @internal */ +export const TARGET_LINE_HEIGHT = 16; + +/** @internal */ +export const TICK_FONT: Font = { + ...TITLE_FONT, + fontWeight: 'normal', +}; +/** @internal */ +export const TICK_FONT_SIZE = 10; + +/** @internal */ +export const HEADER_PADDING: Padding = { + top: 8, + bottom: 8, + left: 8, + right: 8, +}; +/** @internal */ +export const GRAPH_PADDING: Padding = { + top: 8, + bottom: 8, + left: 8, + right: 8, +}; diff --git a/packages/charts/src/chart_types/index.ts b/packages/charts/src/chart_types/index.ts index 2726dc7ce7..d53b31f064 100644 --- a/packages/charts/src/chart_types/index.ts +++ b/packages/charts/src/chart_types/index.ts @@ -22,6 +22,7 @@ export const ChartType = Object.freeze({ Heatmap: 'heatmap' as const, Wordcloud: 'wordcloud' as const, Metric: 'metric' as const, + BulletGraph: 'bullet_graph' as const, }); /** @public */ export type ChartType = $Values; diff --git a/packages/charts/src/chart_types/specs.ts b/packages/charts/src/chart_types/specs.ts index dc9df8b980..16279959b3 100644 --- a/packages/charts/src/chart_types/specs.ts +++ b/packages/charts/src/chart_types/specs.ts @@ -47,3 +47,5 @@ export { MetricTrendShape, MetricDatum, } from './metric/specs'; + +export { BulletGraph, BulletGraphSpec, BulletGraphSubtype } from './bullet_graph/spec'; diff --git a/packages/charts/src/common/default_theme_attributes.ts b/packages/charts/src/common/default_theme_attributes.ts index ac9a518571..09ba1feccf 100644 --- a/packages/charts/src/common/default_theme_attributes.ts +++ b/packages/charts/src/common/default_theme_attributes.ts @@ -7,5 +7,5 @@ */ /** @internal */ -export const DEFAULT_FONT_FAMILY = - '"Inter UI", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'; +export const DEFAULT_FONT_FAMILY = 'Inter UI, -apple-system, Segoe UI, Helvetica, Arial, sans-serif'; +//'"Inter UI", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'; diff --git a/packages/charts/src/components/_index.scss b/packages/charts/src/components/_index.scss index ce2091babf..3998c4b950 100644 --- a/packages/charts/src/components/_index.scss +++ b/packages/charts/src/components/_index.scss @@ -6,7 +6,10 @@ @import 'icons/index'; @import 'legend/index'; @import 'unavailable_chart'; +@import 'grid'; +@import 'grid/aligned_grid'; @import '../chart_types/xy_chart/renderer/index'; @import '../chart_types/partition_chart/renderer/index'; @import '../chart_types/metric/renderer/index'; +@import '../chart_types/bullet_graph/renderer/dom/index'; diff --git a/packages/charts/src/components/chart_resizer.tsx b/packages/charts/src/components/chart_resizer.tsx index b3c4ac8f0a..4c2ddd6df8 100644 --- a/packages/charts/src/components/chart_resizer.tsx +++ b/packages/charts/src/components/chart_resizer.tsx @@ -28,7 +28,7 @@ interface ResizerDispatchProps { type ResizerProps = ResizerStateProps & ResizerDispatchProps; -const DEFAULT_RESIZE_DEBOUNCE = 200; +const DEFAULT_RESIZE_DEBOUNCE = 0; class Resizer extends React.Component { private initialResizeComplete = false; diff --git a/packages/charts/src/components/grid/_aligned_grid.scss b/packages/charts/src/components/grid/_aligned_grid.scss new file mode 100644 index 0000000000..aa889cf353 --- /dev/null +++ b/packages/charts/src/components/grid/_aligned_grid.scss @@ -0,0 +1,25 @@ +.echAlignedGrid { + display: grid; + align-content: stretch; + width: 100%; + height: 100%; +} +.echAlignedGrid--header { + width: 100%; + height: 100%; + margin: 0; + padding: 0; +} +.echAlignedGrid__borderRight { + border-right: 1px solid #edf0f5; +} +.echAlignedGrid__borderBottom { + border-bottom: 1px solid #edf0f5; +} + +.echAlignedGrid--content { + width: 100%; + min-height: 0; + margin: 0; + padding: 0; +} diff --git a/packages/charts/src/components/grid/_index.scss b/packages/charts/src/components/grid/_index.scss new file mode 100644 index 0000000000..0829b28c31 --- /dev/null +++ b/packages/charts/src/components/grid/_index.scss @@ -0,0 +1,25 @@ +.echGridContainer { + display: grid; + width: 100%; + height: 100%; + align-content: start; + justify-content: stretch; + align-items: stretch; + user-select: text; +} + +.echGridCell { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + transition: background-color ease-in-out 0.1s; + + &--rightBorder { + border-right: 1px solid #343741; + } + + &--bottomBorder { + border-bottom: 1px solid #343741; + } +} diff --git a/packages/charts/src/components/grid/aligned_grid.tsx b/packages/charts/src/components/grid/aligned_grid.tsx new file mode 100644 index 0000000000..95b029c362 --- /dev/null +++ b/packages/charts/src/components/grid/aligned_grid.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import classNames from 'classnames'; +import React, { ComponentType, CSSProperties } from 'react'; + +interface AlignedGridProps { + data: Array>; + headerComponent: ComponentType<{ + datum: D; + stats: { rows: number; rowIndex: number; columns: number; columnIndex: number }; + }>; + contentComponent: ComponentType<{ + datum: D; + stats: { rows: number; rowIndex: number; columns: number; columnIndex: number }; + }>; +} + +/** @internal */ +export function AlignedGrid({ + data, + headerComponent: HeaderComponent, + contentComponent: ContentComponent, +}: AlignedGridProps) { + const rows = data.length; + const columns = data.reduce((acc, row) => { + return Math.max(acc, row.length); + }, 0); + + const gridStyle: CSSProperties = { + gridTemplateColumns: `repeat(${columns}, 1fr`, + gridTemplateRows: `repeat(${rows}, max-content 1fr)`, + }; + + return ( +
+ {data.map((row, rowIndex) => + row.map((cell, columnIndex) => { + const headerStyle: CSSProperties = { + gridRow: rowIndex * 2 + 1, + gridColumn: columnIndex + 1, + }; + const contentStyle: CSSProperties = { + gridRow: rowIndex * 2 + 2, + gridColumn: columnIndex + 1, + }; + const headerClassName = classNames('echAlignedGrid--header', { + echAlignedGrid__borderRight: columnIndex < columns - 1, + // echAlignedGrid__borderBottom: true, + }); + const contentClassName = classNames('echAlignedGrid--content', { + echAlignedGrid__borderRight: columnIndex < columns - 1, + echAlignedGrid__borderBottom: rowIndex < rows - 1, + }); + if (!cell) { + return ( + <> +
+
+ + ); + } + return ( + <> +
+ +
+
+ +
+ + ); + }), + )} +
+ ); +} diff --git a/packages/charts/src/components/grid/grid.html b/packages/charts/src/components/grid/grid.html new file mode 100644 index 0000000000..9be94c583d --- /dev/null +++ b/packages/charts/src/components/grid/grid.html @@ -0,0 +1,107 @@ + + + + + Title + + + + +
+

CPU percentage

+
+ +
Title for 2 chart
+
2 chart
+ +
Title for 3 chart
+
3 chart
+ +
Title for 4 chart
+
4 chart
+ +
Title for 5 chart longer then chart 4
+
5 chart
+
+ + diff --git a/packages/charts/src/components/grid/html_grid.tsx b/packages/charts/src/components/grid/html_grid.tsx new file mode 100644 index 0000000000..5235eac31a --- /dev/null +++ b/packages/charts/src/components/grid/html_grid.tsx @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import classNames from 'classnames'; +import React, { memo, ComponentType } from 'react'; + +import { highContrastColor } from '../../common/color_calcs'; +import { colorToRgba } from '../../common/color_library_wrappers'; +import { Color, Colors } from '../../common/colors'; +import { Pixels } from '../../common/geometry'; +import { A11ySettings } from '../../state/selectors/get_accessibility_config'; +import { Size } from '../../utils/dimensions'; + +/** @internal */ +export interface HTMLGridProps { + a11y: A11ySettings; + size: Size; + style: { + minHeight: Pixels; + border: Color; + background: Color; + text: { + lightColor: Color; + darkColor: Color; + }; + }; + data: Array>; + component: ComponentType<{ + rowCount: number; + columnCount: number; + datum: NonNullable; + rowIndex: number; + columnIndex: number; + panel: Size; + }>; + emptyComponent: ComponentType; +} + +/** @internal */ +export function HTMLGrid({ + size, + a11y, + style, + data, + component: Component, + emptyComponent: EmptyComponent, +}: HTMLGridProps) { + const rowCount = data.length; + const columnCount = data.reduce((acc, row) => { + return Math.max(acc, row.length); + }, 0); + const borderColor = + highContrastColor(colorToRgba(style.background)) === Colors.White.rgba + ? style.text.lightColor + : style.text.darkColor; + + const panel = { width: size.width / columnCount, height: size.height / rowCount }; + return ( + // eslint-disable-next-line jsx-a11y/no-redundant-roles +
    + {data.flatMap((columns, rowIndex) => { + return [ + ...columns.map((datum, columnIndex) => { + // fill undefined with empty panels + const emptyMetricClassName = classNames('echGridCell', { + 'echGridCell--rightBorder': columnIndex < columnCount - 1, + 'echGridCell--bottomBorder': rowIndex < rowCount - 1, + }); + const containerClassName = classNames('echGridCell', { + 'echGridCell--rightBorder': columnIndex < columnCount - 1, + 'echGridCell--bottomBorder': rowIndex < rowCount - 1, + }); + + return !datum ? ( +
  • +
    + +
    +
  • + ) : ( +
  • + +
  • + ); + }), + // fill the grid row with empty panels + ...Array.from({ length: columnCount - columns.length }, (_, zeroBasedColumnIndex) => { + const columnIndex = zeroBasedColumnIndex + columns.length; + const emptyMetricClassName = classNames('echGridCell', { + 'echGridCell--bottomBorder': rowIndex < rowCount - 1, + }); + return ( +
  • +
    + +
    +
  • + ); + }), + ]; + })} +
+ ); +} + +/** @internal */ +export const MemoizedGrid = memo(HTMLGrid); diff --git a/packages/charts/src/state/chart_state.ts b/packages/charts/src/state/chart_state.ts index b45bf7c06c..b1f38259ed 100644 --- a/packages/charts/src/state/chart_state.ts +++ b/packages/charts/src/state/chart_state.ts @@ -24,6 +24,7 @@ import { LegendItemLabel } from './selectors/get_legend_items_labels'; import { DebugState } from './types'; import { getInitialPointerState, getInitialTooltipState } from './utils'; import { ChartType } from '../chart_types'; +import { BulletGraphState } from '../chart_types/bullet_graph/chart_state'; import { FlameState } from '../chart_types/flame_chart/internal_chart_state'; import { GoalState } from '../chart_types/goal_chart/state/chart_state'; import { HeatmapState } from '../chart_types/heatmap/state/chart_state'; @@ -496,6 +497,7 @@ const constructors: Record InternalChartState | null> = { [ChartType.Heatmap]: () => new HeatmapState(), [ChartType.Wordcloud]: () => new WordcloudState(), [ChartType.Metric]: () => new MetricState(), + [ChartType.BulletGraph]: () => new BulletGraphState(), [ChartType.Global]: () => null, }; // with no default, TS signals if a new chart type isn't added here too diff --git a/packages/charts/src/utils/bbox/canvas_text_bbox_calculator.ts b/packages/charts/src/utils/bbox/canvas_text_bbox_calculator.ts index ae79846fa1..649e16193e 100644 --- a/packages/charts/src/utils/bbox/canvas_text_bbox_calculator.ts +++ b/packages/charts/src/utils/bbox/canvas_text_bbox_calculator.ts @@ -26,8 +26,8 @@ export function measureText(ctx: CanvasRenderingContext2D): TextMeasure { // TODO this is a temporary fix to make the multilayer time axis work return { width: 0, height: fontSize * lineHeight }; } - ctx.font = cssFontShorthand(font, fontSize); + ctx.font = cssFontShorthand(font, fontSize * 2); const { width } = ctx.measureText(text); - return { width, height: fontSize * lineHeight }; + return { width: width / 2, height: fontSize * lineHeight }; }; } diff --git a/packages/charts/src/utils/themes/dark_theme.ts b/packages/charts/src/utils/themes/dark_theme.ts index 3dfb1cd5fb..7d222d1397 100644 --- a/packages/charts/src/utils/themes/dark_theme.ts +++ b/packages/charts/src/utils/themes/dark_theme.ts @@ -14,6 +14,7 @@ import { DEFAULT_GEOMETRY_STYLES, DEFAULT_MISSING_COLOR, } from './theme_common'; +import { DARK_THEME_BULLET_STYLE } from '../../chart_types/bullet_graph/theme'; import { Colors } from '../../common/colors'; import { GOLDEN_RATIO, TAU } from '../../common/constants'; import { ColorVariant } from '../common'; @@ -414,6 +415,7 @@ export const DARK_THEME: Theme = { nonFiniteText: 'N/A', minHeight: 64, }, + bulletGraph: DARK_THEME_BULLET_STYLE, tooltip: { maxWidth: 260, maxTableHeight: 120, diff --git a/packages/charts/src/utils/themes/light_theme.ts b/packages/charts/src/utils/themes/light_theme.ts index 4e88e8b8d5..b3126b2b61 100644 --- a/packages/charts/src/utils/themes/light_theme.ts +++ b/packages/charts/src/utils/themes/light_theme.ts @@ -14,6 +14,7 @@ import { DEFAULT_GEOMETRY_STYLES, DEFAULT_MISSING_COLOR, } from './theme_common'; +import { LIGHT_THEME_BULLET_STYLE } from '../../chart_types/bullet_graph/theme'; import { Colors } from '../../common/colors'; import { GOLDEN_RATIO, TAU } from '../../common/constants'; import { ColorVariant } from '../common'; @@ -413,6 +414,7 @@ export const LIGHT_THEME: Theme = { nonFiniteText: 'N/A', minHeight: 64, }, + bulletGraph: LIGHT_THEME_BULLET_STYLE, tooltip: { maxWidth: 260, maxTableHeight: 120, diff --git a/packages/charts/src/utils/themes/theme.ts b/packages/charts/src/utils/themes/theme.ts index 23f844f75a..03a506b0da 100644 --- a/packages/charts/src/utils/themes/theme.ts +++ b/packages/charts/src/utils/themes/theme.ts @@ -17,6 +17,8 @@ import { ColorVariant, HorizontalAlignment, RecursivePartial, VerticalAlignment import { Margins, Padding, SimplePadding } from '../dimensions'; import { Point } from '../point'; +import { BulletGraphStyle } from '/@elastic/charts/src/chart_types/bullet_graph/theme'; + /** @public */ export interface Visible { visible: boolean; @@ -487,6 +489,11 @@ export interface Theme { * Theme styles for metric chart types */ metric: MetricStyle; + + /** + * Theme styles for bullet graph types + */ + bulletGraph: BulletGraphStyle; /** * Theme styles for tooltip */ diff --git a/storybook/stories/bullet_graph/1_simple.story.tsx b/storybook/stories/bullet_graph/1_simple.story.tsx new file mode 100644 index 0000000000..d0066480c8 --- /dev/null +++ b/storybook/stories/bullet_graph/1_simple.story.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { Chart, BulletGraph, BulletGraphSubtype, Settings } from '@elastic/charts'; + +import { useBaseTheme } from '../../use_base_theme'; +import { getKnobFromEnum } from '../utils/knobs/utils'; + +export const Example = () => { + const subtype = getKnobFromEnum('subtype', BulletGraphSubtype, BulletGraphSubtype.vertical); + return ( +
+ + + `${d}`, + tickFormatter: (d) => `${d}`, + }, + { + ticks: 'auto', + target: 67, + value: 123, + title: 'Network outbound', + subtitle: 'error rate (%)', + domain: { min: 0, max: 100, nice: false }, + valueFormatter: (d) => `${d}`, + tickFormatter: (d) => `${d}`, + }, + ], + [ + { + ticks: 'auto', + target: 50, + value: 11, + title: 'Number of requests', + subtitle: 'Requests per second', + domain: { min: 0, max: 100, nice: false }, + valueFormatter: (d) => `${d}`, + tickFormatter: (d) => `${d}`, + }, + { + ticks: 'auto', + target: 80, + value: 92, + title: 'Second row second column title', + subtitle: 'percentage', + domain: { min: 0, max: 100, nice: false }, + valueFormatter: (d) => `${d}`, + tickFormatter: (d) => `${d}`, + }, + ], + ]} + /> + +
+ ); +}; diff --git a/storybook/stories/bullet_graph/1_single.story.tsx b/storybook/stories/bullet_graph/1_single.story.tsx new file mode 100644 index 0000000000..5e3b9d8797 --- /dev/null +++ b/storybook/stories/bullet_graph/1_single.story.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { text, number } from '@storybook/addon-knobs'; +import React from 'react'; + +import { Chart, BulletGraph, BulletGraphSubtype, Settings } from '@elastic/charts'; + +import { useBaseTheme } from '../../use_base_theme'; +import { getKnobFromEnum } from '../utils/knobs/utils'; + +export const Example = () => { + const subtype = getKnobFromEnum('subtype', BulletGraphSubtype, BulletGraphSubtype.horizontal); + const title = text('title', 'Error rate'); + const subtitle = text('subtitle', ''); + const value = number('value', 56, { range: true, min: 0, max: 200 }); + const target = number('target', 75, { range: true, min: 0, max: 200 }); + const min = number('min', 0, { range: true, min: 0, max: 200 }); + const max = number('max', 100, { range: true, min: 0, max: 200 }); + + const postfix = text('postfix', ''); + return ( +
+ + + `${d}${postfix}`, + tickFormatter: (d) => `${d}${postfix}`, + }, + ], + ]} + /> + +
+ ); +}; diff --git a/storybook/stories/bullet_graph/2_horizontal.story.tsx b/storybook/stories/bullet_graph/2_horizontal.story.tsx new file mode 100644 index 0000000000..34302e0526 --- /dev/null +++ b/storybook/stories/bullet_graph/2_horizontal.story.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { Chart, BulletGraph, BulletGraphSubtype, Settings } from '@elastic/charts'; + +import { getKnobFromEnum } from '../utils/knobs/utils'; +import { useBaseTheme } from '../../use_base_theme'; + +export const Example = () => { + const subtype = getKnobFromEnum('subtype', BulletGraphSubtype, BulletGraphSubtype.vertical); + return ( +
+ + + `${d}%`, + tickFormatter: (d) => `${d}%`, + }, + { + ticks: 'auto', + target: 75, + value: 98, + title: 'Memory', + // subtitle: 'percent', + domain: { min: 0, max: 100, nice: false }, + valueFormatter: (d) => `${d}`, + tickFormatter: (d) => `${d}`, + }, + { + ticks: 'auto', + target: 25, + value: 35.5, + title: 'Network In', + subtitle: 'bandwidth', + domain: { min: 0, max: 100, nice: false }, + valueFormatter: (d) => `${d}`, + tickFormatter: (d) => `${d}`, + }, + { + ticks: 'auto', + target: 25, + value: 91, + title: 'Network out', + subtitle: 'available (percent)', + domain: { min: 0, max: 100, nice: false }, + valueFormatter: (d) => `${d}`, + tickFormatter: (d) => `${d}`, + }, + ], + ]} + /> + +
+ ); +}; diff --git a/storybook/stories/bullet_graph/3_vertical.story.tsx b/storybook/stories/bullet_graph/3_vertical.story.tsx new file mode 100644 index 0000000000..3e473681ae --- /dev/null +++ b/storybook/stories/bullet_graph/3_vertical.story.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { Chart, BulletGraph, BulletGraphSubtype, Settings } from '@elastic/charts'; + +import { useBaseTheme } from '../../use_base_theme'; +import { getKnobFromEnum } from '../utils/knobs/utils'; + +export const Example = () => { + const subtype = getKnobFromEnum('subtype', BulletGraphSubtype, BulletGraphSubtype.horizontal); + + return ( +
+ + + `${d}`, + tickFormatter: (d) => `${d}`, + }, + ], + [ + { + ticks: 'auto', + target: 150, + value: 483, + title: 'Erroring Request duration millis', + subtitle: '90th percentile', + domain: { min: 0, max: 500, nice: false }, + valueFormatter: (d) => `${d}`, + tickFormatter: (d) => `${d}`, + }, + ], + [ + { + ticks: 'auto', + value: 12, + title: 'Error rate', + subtitle: 'percentage', + domain: { min: 0, max: 100, nice: false }, + valueFormatter: (d) => `${d}%`, + tickFormatter: (d) => `${d}%`, + }, + ], + ]} + /> + +
+ ); +}; diff --git a/storybook/stories/bullet_graph/bullet_graph.stories.tsx b/storybook/stories/bullet_graph/bullet_graph.stories.tsx new file mode 100644 index 0000000000..d7432f29dd --- /dev/null +++ b/storybook/stories/bullet_graph/bullet_graph.stories.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export default { + title: 'Bullet Graph', +}; + +export { Example as single } from './1_single.story'; +export { Example as grid } from './1_simple.story'; +export { Example as singoleRow } from './2_horizontal.story'; +export { Example as singleColumn } from './3_vertical.story'; diff --git a/storybook/stories/goal/3_horizontal_bullet.story.tsx b/storybook/stories/goal/3_horizontal_bullet.story.tsx index b8ab902166..6841963ee3 100644 --- a/storybook/stories/goal/3_horizontal_bullet.story.tsx +++ b/storybook/stories/goal/3_horizontal_bullet.story.tsx @@ -29,23 +29,35 @@ const getBandFillColor = getBandFillColorFn({ }); export const Example: ChartsStory = (_, { title, description }) => ( - - - String(value)} - bandFillColor={getBandFillColor} - labelMajor="Revenue 2020 YTD " - labelMinor="(thousand USD) " - centralMajor="280" - centralMinor="target: 260" - /> - +
+ + + String(value)} + bandFillColor={getBandFillColor} + labelMajor="Revenue 2020 YTD " + labelMinor="(thousand USD) " + centralMajor="280" + centralMinor="target: 260" + /> + +
); diff --git a/storybook/stories/goal/4_vertical_bullet.story.tsx b/storybook/stories/goal/4_vertical_bullet.story.tsx index e46abcc9e8..fe9a5b9b9e 100644 --- a/storybook/stories/goal/4_vertical_bullet.story.tsx +++ b/storybook/stories/goal/4_vertical_bullet.story.tsx @@ -29,23 +29,35 @@ const getBandFillColor = getBandFillColorFn({ }); export const Example: ChartsStory = (_, { title, description }) => ( - - - String(value)} - bandFillColor={getBandFillColor} - labelMajor="Revenue 2020 YTD " - labelMinor="(thousand USD) " - centralMajor="280" - centralMinor="target: 260" - /> - +
+ + + String(value)} + bandFillColor={getBandFillColor} + labelMajor="Revenue 2020 YTD " + labelMinor="(thousand USD) " + centralMajor="280" + centralMinor="target: 260" + /> + +
); diff --git a/storybook/style.scss b/storybook/style.scss index 5f17506f4d..73e5659d7c 100644 --- a/storybook/style.scss +++ b/storybook/style.scss @@ -168,3 +168,10 @@ body { .euiPopover__anchor { width: 100%; } + +.resizable { + resize: both; + overflow: auto; + width: 500px; + height: 600px; +}