diff --git a/.eslintrc.js b/.eslintrc.js index 6111a98f31..9c47aa092f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -54,6 +54,7 @@ module.exports = { '@typescript-eslint/comma-spacing': 0, 'unicorn/no-nested-ternary': 0, '@typescript-eslint/lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], + 'no-extra-parens': 'off', // it was already off by default; this line addition is just for documentation purposes /** ***************************************** diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-full-circle-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-full-circle-visually-looks-correct-1-snap.png index 07afad234f..92e544b1e0 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-full-circle-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-full-circle-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-third-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-third-visually-looks-correct-1-snap.png index e41f0b07a5..0027c5f74d 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-third-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-third-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-two-thirds-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-two-thirds-visually-looks-correct-1-snap.png index 73c8dbd5ec..f20dec7cb9 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-two-thirds-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-two-thirds-visually-looks-correct-1-snap.png differ diff --git a/packages/charts/src/chart_types/goal_chart/layout/viewmodel/geoms.ts b/packages/charts/src/chart_types/goal_chart/layout/viewmodel/geoms.ts new file mode 100644 index 0000000000..5e045eaa52 --- /dev/null +++ b/packages/charts/src/chart_types/goal_chart/layout/viewmodel/geoms.ts @@ -0,0 +1,361 @@ +/* + * 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 { GOLDEN_RATIO } from '../../../../common/constants'; +import { PointObject } from '../../../../common/geometry'; +import { cssFontShorthand, Font } from '../../../../common/text_utils'; +import { GoalSubtype } from '../../specs/constants'; +import { Config } from '../types/config_types'; +import { BulletViewModel } from '../types/viewmodel_types'; + +const referenceCircularSizeCap = 360; // goal/gauge won't be bigger even if there's ample room: it'd be a waste of space +const referenceBulletSizeCap = 500; // goal/gauge won't be bigger even if there's ample room: it'd be a waste of space +const barThicknessMinSizeRatio = 1 / 10; // bar thickness is a maximum of this fraction of the smaller graph area size +const baselineArcThickness = 32; // bar is this thick if there's ample room; no need for greater thickness even if there's a large area +const baselineBarThickness = 32; // bar is this thick if there's ample room; no need for greater thickness even if there's a large area +const marginRatio = 0.05; // same ratio on each side +const maxTickFontSize = 24; +const maxLabelFontSize = 32; +const maxCentralFontSize = 38; + +/** @internal */ +export interface Mark { + render: (ctx: CanvasRenderingContext2D) => void; +} + +/** @internal */ +export class Section implements Mark { + protected readonly x: number; + protected readonly y: number; + protected readonly xTo: number; + protected readonly yTo: number; + protected readonly lineWidth: number; + protected readonly strokeStyle: string; + + constructor(x: number, y: number, xTo: number, yTo: number, lineWidth: number, strokeStyle: string) { + this.x = x; + this.y = y; + this.xTo = xTo; + this.yTo = yTo; + this.lineWidth = lineWidth; + this.strokeStyle = strokeStyle; + } + + render(ctx: CanvasRenderingContext2D) { + ctx.beginPath(); + ctx.lineWidth = this.lineWidth; + ctx.strokeStyle = this.strokeStyle; + ctx.moveTo(this.x, this.y); + ctx.lineTo(this.xTo, this.yTo); + ctx.stroke(); + } +} + +/** @internal */ +export class Arc implements Mark { + protected readonly x: number; + protected readonly y: number; + protected readonly radius: number; + protected readonly startAngle: number; + protected readonly endAngle: number; + protected readonly anticlockwise: boolean; + protected readonly lineWidth: number; + protected readonly strokeStyle: string; + + constructor( + x: number, + y: number, + radius: number, + startAngle: number, + endAngle: number, + anticlockwise: boolean, + lineWidth: number, + strokeStyle: string, + ) { + this.x = x; + this.y = y; + this.radius = radius; + this.startAngle = startAngle; + this.endAngle = endAngle; + this.anticlockwise = anticlockwise; + this.lineWidth = lineWidth; + this.strokeStyle = strokeStyle; + } + + render(ctx: CanvasRenderingContext2D) { + ctx.beginPath(); + ctx.lineWidth = this.lineWidth; + ctx.strokeStyle = this.strokeStyle; + ctx.arc(this.x, this.y, this.radius, this.startAngle, this.endAngle, this.anticlockwise); + ctx.stroke(); + } +} + +/** @internal */ +export class Text implements Mark { + protected readonly x: number; + protected readonly y: number; + protected readonly text: string; + protected readonly textAlign: CanvasTextAlign; + protected readonly textBaseline: CanvasTextBaseline; + protected readonly fontShape: Font; + protected readonly fontSize: number; + + constructor( + x: number, + y: number, + text: string, + textAlign: CanvasTextAlign, + textBaseline: CanvasTextBaseline, + fontShape: Font, + fontSize: number, + ) { + this.x = x; + this.y = y; + this.text = text; + this.textAlign = textAlign; + this.textBaseline = textBaseline; + this.fontShape = fontShape; + this.fontSize = fontSize; + } + + render(ctx: CanvasRenderingContext2D) { + ctx.beginPath(); + ctx.textAlign = this.textAlign; + ctx.textBaseline = this.textBaseline; + ctx.font = cssFontShorthand(this.fontShape, this.fontSize); + ctx.fillText(this.text, this.x, this.y); + } +} + +function get(o: { [k: string]: any }, name: string, dflt: T) { + return name in o ? o[name] || dflt : dflt; +} + +/** @internal */ +export function geoms(bulletViewModel: BulletViewModel, config: Config, chartCenter: PointObject): Mark[] { + const { + subtype, + lowestValue, + highestValue, + base, + target, + actual, + bands, + ticks, + labelMajor, + labelMinor, + centralMajor, + centralMinor, + } = bulletViewModel; + + const circular = subtype === GoalSubtype.Goal; + const vertical = subtype === GoalSubtype.VerticalBullet; + + const domain = [lowestValue, highestValue]; + const data = { + base: { value: base }, + ...Object.fromEntries(bands.map(({ value }, index) => [`qualitative_${index}`, { value }])), + target: { value: target }, + actual: { value: actual }, + labelMajor: { value: domain[circular || !vertical ? 0 : 1], text: labelMajor }, + labelMinor: { value: domain[circular || !vertical ? 0 : 1], text: labelMinor }, + ...Object.assign({}, ...ticks.map(({ value, text }, i) => ({ [`tick_${i}`]: { value, text } }))), + ...(circular + ? { + centralMajor: { value: 0, text: centralMajor }, + centralMinor: { value: 0, text: centralMinor }, + } + : {}), + }; + + const minSize = Math.min(config.width, config.height); + + const referenceSize = + Math.min( + circular ? referenceCircularSizeCap : referenceBulletSizeCap, + circular ? minSize : vertical ? config.height : config.width, + ) * + (1 - 2 * marginRatio); + + const barThickness = Math.min( + circular ? baselineArcThickness : baselineBarThickness, + referenceSize * barThicknessMinSizeRatio, + ); + + const tickLength = barThickness * Math.pow(1 / GOLDEN_RATIO, 3); + const tickOffset = -tickLength / 2 - barThickness / 2; + const tickFontSize = Math.min(maxTickFontSize, referenceSize / 25); + const labelFontSize = Math.min(maxLabelFontSize, referenceSize / 18); + const centralFontSize = Math.min(maxCentralFontSize, referenceSize / 14); + + const shape = circular ? 'arc' : 'line'; + + const abstractGeoms = [ + ...bulletViewModel.bands.map((b, i) => ({ + order: 0, + landmarks: { + from: i ? `qualitative_${i - 1}` : 'base', + to: `qualitative_${i}`, + }, + aes: { shape, fillColor: b.fillColor, lineWidth: barThickness }, + })), + { + order: 1, + landmarks: { from: 'base', to: 'actual' }, + aes: { shape, fillColor: 'black', lineWidth: tickLength }, + }, + { + order: 2, + landmarks: { at: 'target' }, + aes: { shape, fillColor: 'black', lineWidth: barThickness / GOLDEN_RATIO }, + }, + ...bulletViewModel.ticks.map((b, i) => ({ + order: 3, + landmarks: { at: `tick_${i}` }, + aes: { shape, fillColor: 'darkgrey', lineWidth: tickLength, axisNormalOffset: tickOffset }, + })), + ...bulletViewModel.ticks.map((b, i) => ({ + order: 4, + landmarks: { at: `tick_${i}` }, + aes: { + shape: 'text', + textAlign: vertical ? 'right' : 'center', + textBaseline: vertical ? 'middle' : 'top', + fillColor: 'black', + fontShape: { fontStyle: 'normal', fontVariant: 'normal', fontWeight: '500', fontFamily: 'sans-serif' }, + axisNormalOffset: -barThickness, + }, + })), + { + order: 5, + landmarks: { at: 'labelMajor' }, + aes: { + shape: 'text', + axisNormalOffset: 0, + axisTangentOffset: circular || !vertical ? 0 : 2 * labelFontSize, + textAlign: vertical ? 'center' : 'right', + textBaseline: 'bottom', + fillColor: 'black', + fontShape: { fontStyle: 'normal', fontVariant: 'normal', fontWeight: '900', fontFamily: 'sans-serif' }, + }, + }, + { + order: 5, + landmarks: { at: 'labelMinor' }, + aes: { + shape: 'text', + axisNormalOffset: 0, + axisTangentOffset: circular || !vertical ? 0 : 2 * labelFontSize, + textAlign: vertical ? 'center' : 'right', + textBaseline: 'top', + fillColor: 'black', + fontShape: { fontStyle: 'normal', fontVariant: 'normal', fontWeight: '300', fontFamily: 'sans-serif' }, + }, + }, + ...(circular + ? [ + { + order: 6, + landmarks: { at: 'centralMajor' }, + aes: { + shape: 'text', + textAlign: 'center', + textBaseline: 'bottom', + fillColor: 'black', + fontShape: { fontStyle: 'normal', fontVariant: 'normal', fontWeight: '900', fontFamily: 'sans-serif' }, + }, + }, + { + order: 6, + landmarks: { at: 'centralMinor' }, + aes: { + shape: 'text', + textAlign: 'center', + textBaseline: 'top', + fillColor: 'black', + fontShape: { fontStyle: 'normal', fontVariant: 'normal', fontWeight: '300', fontFamily: 'sans-serif' }, + }, + }, + ] + : []), + ]; + + const maxWidth = abstractGeoms.reduce((p, g) => Math.max(p, get(g.aes, 'lineWidth', 0)), 0); + const r = 0.5 * referenceSize - maxWidth / 2; + + const fullSize = referenceSize; + const labelSize = fullSize / 2; + const pxRangeFrom = -fullSize / 2 + (circular || vertical ? 0 : labelSize); + const pxRangeTo = fullSize / 2 + (!circular && vertical ? -2 * labelFontSize : 0); + const pxRangeMid = (pxRangeFrom + pxRangeTo) / 2; + const pxRange = pxRangeTo - pxRangeFrom; + + const domainExtent = domain[1] - domain[0]; + + const linearScale = (x: number) => pxRangeFrom + (pxRange * (x - domain[0])) / domainExtent; + + const { angleStart, angleEnd } = config; + const angleRange = angleEnd - angleStart; + const angleScale = (x: number) => angleStart + (angleRange * (x - domain[0])) / domainExtent; + const clockwise = angleStart > angleEnd; // todo refine this crude approach + + const geomObjects = abstractGeoms + .slice() + .sort((a, b) => a.order - b.order) + .map(({ landmarks, aes }) => { + const at = get(landmarks, 'at', ''); + const from = get(landmarks, 'from', ''); + const to = get(landmarks, 'to', ''); + const textAlign = circular ? 'center' : get(aes, 'textAlign', ''); + const fontShape = get(aes, 'fontShape', ''); + const axisNormalOffset = get(aes, 'axisNormalOffset', 0); + const axisTangentOffset = get(aes, 'axisTangentOffset', 0); + const lineWidth = get(aes, 'lineWidth', 0); + const strokeStyle = get(aes, 'fillColor', ''); + if (aes.shape === 'text') { + const { text } = data[at]; + const label = at.slice(0, 5) === 'label'; + const central = at.slice(0, 7) === 'central'; + const textBaseline = label || central || !circular ? get(aes, 'textBaseline', '') : 'middle'; + const fontSize = circular && label ? labelFontSize : circular && central ? centralFontSize : tickFontSize; + const scaledValue = circular ? angleScale(data[at].value) : data[at] && linearScale(data[at].value); + // prettier-ignore + const x = circular + ? (label || central ? 0 : (r - GOLDEN_RATIO * barThickness) * Math.cos(scaledValue)) + : (vertical ? axisNormalOffset : axisTangentOffset + scaledValue); + // prettier-ignore + const y = circular + ? (label ? r : central ? 0 : -(r - GOLDEN_RATIO * barThickness) * Math.sin(scaledValue)) + : (vertical ? -axisTangentOffset - scaledValue : -axisNormalOffset); + return new Text(x + chartCenter.x, y + chartCenter.y, text, textAlign, textBaseline, fontShape, fontSize); + } else if (aes.shape === 'arc') { + const cx = chartCenter.x + pxRangeMid; + const cy = chartCenter.y; + const radius = at ? r + axisNormalOffset : r; + const startAngle = at ? angleScale(data[at].value) + Math.PI / 360 : angleScale(data[from].value); + const endAngle = at ? angleScale(data[at].value) - Math.PI / 360 : angleScale(data[to].value); + // prettier-ignore + const anticlockwise = at || clockwise === (data[from].value < data[to].value); + return new Arc(cx, cy, radius, -startAngle, -endAngle, !anticlockwise, lineWidth, strokeStyle); + } else { + // if (aes.shape === 'line') + const translateX = chartCenter.x + (vertical ? axisNormalOffset : axisTangentOffset); + const translateY = chartCenter.y - (vertical ? axisTangentOffset : axisNormalOffset); + const atPx = data[at] && linearScale(data[at].value); + const fromPx = at ? atPx - 1 : linearScale(data[from].value); + const toPx = at ? atPx + 1 : linearScale(data[to].value); + const x0 = vertical ? translateX : translateX + fromPx; + const y0 = vertical ? translateY - fromPx : translateY; + const x1 = vertical ? translateX : translateX + toPx; + const y1 = vertical ? translateY - toPx : translateY; + return new Section(x0, y0, x1, y1, lineWidth, strokeStyle); + } + }); + return geomObjects; +} diff --git a/packages/charts/src/chart_types/goal_chart/renderer/canvas/canvas_renderers.ts b/packages/charts/src/chart_types/goal_chart/renderer/canvas/canvas_renderers.ts index 0233bc47ee..43bed0d66b 100644 --- a/packages/charts/src/chart_types/goal_chart/renderer/canvas/canvas_renderers.ts +++ b/packages/charts/src/chart_types/goal_chart/renderer/canvas/canvas_renderers.ts @@ -6,36 +6,11 @@ * Side Public License, v 1. */ -import { GOLDEN_RATIO } from '../../../../common/constants'; -import { cssFontShorthand } from '../../../../common/text_utils'; import { clearCanvas, renderLayers, withContext } from '../../../../renderers/canvas'; -import { ShapeViewModel } from '../../layout/types/viewmodel_types'; -import { GoalSubtype } from '../../specs/constants'; - -// fixme turn these into config, or capitalize as constants -const referenceCircularSizeCap = 360; // goal/gauge won't be bigger even if there's ample room: it'd be a waste of space -const referenceBulletSizeCap = 500; // goal/gauge won't be bigger even if there's ample room: it'd be a waste of space -const barThicknessMinSizeRatio = 1 / 10; // bar thickness is a maximum of this fraction of the smaller graph area size -const baselineArcThickness = 32; // bar is this thick if there's ample room; no need for greater thickness even if there's a large area -const baselineBarThickness = 32; // bar is this thick if there's ample room; no need for greater thickness even if there's a large area -const marginRatio = 0.05; // same ratio on each side -const maxTickFontSize = 24; -const maxLabelFontSize = 32; -const maxCentralFontSize = 38; - -function get(o: { [k: string]: any }, name: string, dflt: T) { - return name in o ? o[name] || dflt : dflt; -} +import { Mark } from '../../layout/viewmodel/geoms'; /** @internal */ -export function renderCanvas2d( - ctx: CanvasRenderingContext2D, - dpr: number, - { config, bulletViewModel, chartCenter }: ShapeViewModel, -) { - // eslint-disable-next-line no-empty-pattern - const {} = config; - +export function renderCanvas2d(ctx: CanvasRenderingContext2D, dpr: number, geomObjects: Mark[]) { withContext(ctx, (ctx) => { // set some defaults for the overall rendering @@ -49,168 +24,9 @@ export function renderCanvas2d( // text rendering must be y-flipped, which is a bit easier this way ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.translate(chartCenter.x, chartCenter.y); // this applies the mathematical x/y conversion (+y is North) which is easier when developing geometry // functions - also, all renderers have flexibility (eg. SVG scale) and WebGL NDC is also +y up // - in any case, it's possible to refactor for a -y = North convention if that's deemed preferable - ctx.scale(1, -1); - - const { - subtype, - lowestValue, - highestValue, - base, - target, - actual, - bands, - ticks, - labelMajor, - labelMinor, - centralMajor, - centralMinor, - } = bulletViewModel; - - const circular = subtype === GoalSubtype.Goal; - const vertical = subtype === GoalSubtype.VerticalBullet; - - const domain = [lowestValue, highestValue]; - const data = { - base: { value: base }, - ...Object.fromEntries(bands.map(({ value }, index) => [`qualitative_${index}`, { value }])), - target: { value: target }, - actual: { value: actual }, - labelMajor: { value: domain[circular || !vertical ? 0 : 1], text: labelMajor }, - labelMinor: { value: domain[circular || !vertical ? 0 : 1], text: labelMinor }, - ...Object.assign({}, ...ticks.map(({ value, text }, i) => ({ [`tick_${i}`]: { value, text } }))), - ...(circular - ? { - centralMajor: { value: 0, text: centralMajor }, - centralMinor: { value: 0, text: centralMinor }, - } - : {}), - }; - - const minSize = Math.min(config.width, config.height); - - const referenceSize = - Math.min( - circular ? referenceCircularSizeCap : referenceBulletSizeCap, - circular ? minSize : vertical ? config.height : config.width, - ) * - (1 - 2 * marginRatio); - - const barThickness = Math.min( - circular ? baselineArcThickness : baselineBarThickness, - referenceSize * barThicknessMinSizeRatio, - ); - - const tickLength = barThickness * Math.pow(1 / GOLDEN_RATIO, 3); - const tickOffset = -tickLength / 2 - barThickness / 2; - const tickFontSize = Math.min(maxTickFontSize, referenceSize / 25); - const labelFontSize = Math.min(maxLabelFontSize, referenceSize / 18); - const centralFontSize = Math.min(maxCentralFontSize, referenceSize / 14); - - const geoms = [ - ...bulletViewModel.bands.map((b, i) => ({ - order: 0, - landmarks: { - from: i ? `qualitative_${i - 1}` : 'base', - to: `qualitative_${i}`, - }, - aes: { - shape: 'line', - fillColor: b.fillColor, - lineWidth: barThickness, - }, - })), - { - order: 1, - landmarks: { from: 'base', to: 'actual' }, - aes: { shape: 'line', fillColor: 'black', lineWidth: tickLength }, - }, - { - order: 2, - landmarks: { at: 'target' }, - aes: { shape: 'line', fillColor: 'black', lineWidth: barThickness / GOLDEN_RATIO }, - }, - ...bulletViewModel.ticks.map((b, i) => ({ - order: 3, - landmarks: { at: `tick_${i}` }, - aes: { - shape: 'line', - fillColor: 'darkgrey', - lineWidth: tickLength, - axisNormalOffset: tickOffset, - }, - })), - ...bulletViewModel.ticks.map((b, i) => ({ - order: 4, - landmarks: { at: `tick_${i}` }, - aes: { - shape: 'text', - textAlign: vertical ? 'right' : 'center', - textBaseline: vertical ? 'middle' : 'top', - fillColor: 'black', - fontShape: { fontStyle: 'normal', fontVariant: 'normal', fontWeight: '500', fontFamily: 'sans-serif' }, - axisNormalOffset: -barThickness, - }, - })), - { - order: 5, - landmarks: { at: 'labelMajor' }, - aes: { - shape: 'text', - axisNormalOffset: 0, - axisTangentOffset: circular || !vertical ? 0 : 2 * labelFontSize, - textAlign: vertical ? 'center' : 'right', - textBaseline: 'bottom', - fillColor: 'black', - fontShape: { fontStyle: 'normal', fontVariant: 'normal', fontWeight: '900', fontFamily: 'sans-serif' }, - }, - }, - { - order: 5, - landmarks: { at: 'labelMinor' }, - aes: { - shape: 'text', - axisNormalOffset: 0, - axisTangentOffset: circular || !vertical ? 0 : 2 * labelFontSize, - textAlign: vertical ? 'center' : 'right', - textBaseline: 'top', - fillColor: 'black', - fontShape: { fontStyle: 'normal', fontVariant: 'normal', fontWeight: '300', fontFamily: 'sans-serif' }, - }, - }, - ...(circular - ? [ - { - order: 6, - landmarks: { at: 'centralMajor' }, - aes: { - shape: 'text', - textAlign: 'center', - textBaseline: 'bottom', - fillColor: 'black', - fontShape: { fontStyle: 'normal', fontVariant: 'normal', fontWeight: '900', fontFamily: 'sans-serif' }, - }, - }, - { - order: 6, - landmarks: { at: 'centralMinor' }, - aes: { - shape: 'text', - textAlign: 'center', - textBaseline: 'top', - fillColor: 'black', - fontShape: { fontStyle: 'normal', fontVariant: 'normal', fontWeight: '300', fontFamily: 'sans-serif' }, - }, - }, - ] - : []), - ]; - - const maxWidth = geoms.reduce((p, g) => Math.max(p, get(g.aes, 'lineWidth', 0)), 0); - const r = 0.5 * referenceSize - maxWidth / 2; renderLayers(ctx, [ // clear the canvas @@ -218,115 +34,7 @@ export function renderCanvas2d( (context: CanvasRenderingContext2D) => withContext(context, (ctx) => { - const fullSize = referenceSize; - const labelSize = fullSize / 2; - const pxRangeFrom = -fullSize / 2 + (circular || vertical ? 0 : labelSize); - const pxRangeTo = fullSize / 2 + (!circular && vertical ? -2 * labelFontSize : 0); - const pxRangeMid = (pxRangeFrom + pxRangeTo) / 2; - const pxRange = pxRangeTo - pxRangeFrom; - - const domainExtent = domain[1] - domain[0]; - - const linearScale = (x: number) => pxRangeFrom + (pxRange * (x - domain[0])) / domainExtent; - - const { angleStart, angleEnd } = config; - const angleRange = angleEnd - angleStart; - const angleScale = (x: number) => angleStart + (angleRange * (x - domain[0])) / domainExtent; - const clockwise = angleStart > angleEnd; // todo refine this crude approach - - geoms - .slice() - .sort((a, b) => a.order - b.order) - .forEach(({ landmarks, aes }) => { - const at = get(landmarks, 'at', ''); - const from = get(landmarks, 'from', ''); - const to = get(landmarks, 'to', ''); - const textAlign = get(aes, 'textAlign', ''); - const textBaseline = get(aes, 'textBaseline', ''); - const fontShape = get(aes, 'fontShape', ''); - const axisNormalOffset = get(aes, 'axisNormalOffset', 0); - const axisTangentOffset = get(aes, 'axisTangentOffset', 0); - const lineWidth = get(aes, 'lineWidth', 0); - const strokeStyle = get(aes, 'fillColor', ''); - withContext(ctx, (ctx) => { - ctx.beginPath(); - if (circular) { - if (aes.shape === 'line') { - ctx.lineWidth = lineWidth; - ctx.strokeStyle = strokeStyle; - if (at) { - ctx.arc( - pxRangeMid, - 0, - r + axisNormalOffset, - angleScale(data[at].value) + Math.PI / 360, - angleScale(data[at].value) - Math.PI / 360, - true, - ); - } else { - const dataClockwise = data[from].value < data[to].value; - ctx.arc( - pxRangeMid, - 0, - r, - angleScale(data[from].value), - angleScale(data[to].value), - clockwise === dataClockwise, - ); - } - } else if (aes.shape === 'text') { - const label = at.slice(0, 5) === 'label'; - const central = at.slice(0, 7) === 'central'; - ctx.textAlign = 'center'; - ctx.textBaseline = label || central ? textBaseline : 'middle'; - ctx.font = cssFontShorthand( - fontShape, - label ? labelFontSize : central ? centralFontSize : tickFontSize, - ); - ctx.scale(1, -1); - const angle = angleScale(data[at].value); - if (label) { - ctx.translate(0, r); - } else if (!central) { - ctx.translate( - (r - GOLDEN_RATIO * barThickness) * Math.cos(angle), - -(r - GOLDEN_RATIO * barThickness) * Math.sin(angle), - ); - } - ctx.fillText(data[at].text, 0, 0); - } - } else { - ctx.translate( - vertical ? axisNormalOffset : axisTangentOffset, - vertical ? axisTangentOffset : axisNormalOffset, - ); - const atPx = data[at] && linearScale(data[at].value); - if (aes.shape === 'line') { - ctx.lineWidth = lineWidth; - ctx.strokeStyle = aes.fillColor; - if (at) { - const atFromPx = atPx - 1; - const atToPx = atPx + 1; - ctx.moveTo(vertical ? 0 : atFromPx, vertical ? atFromPx : 0); - ctx.lineTo(vertical ? 0 : atToPx, vertical ? atToPx : 0); - } else { - const fromPx = linearScale(data[from].value); - const toPx = linearScale(data[to].value); - ctx.moveTo(vertical ? 0 : fromPx, vertical ? fromPx : 0); - ctx.lineTo(vertical ? 0 : toPx, vertical ? toPx : 0); - } - } else if (aes.shape === 'text') { - ctx.textAlign = textAlign; - ctx.textBaseline = textBaseline; - ctx.font = cssFontShorthand(fontShape, tickFontSize); - ctx.scale(1, -1); - ctx.translate(vertical ? 0 : atPx, vertical ? -atPx : 0); - ctx.fillText(data[at].text, 0, 0); - } - } - ctx.stroke(); - }); - }); + geomObjects.forEach((obj) => withContext(ctx, (ctx) => obj.render(ctx))); }), ]); }); diff --git a/packages/charts/src/chart_types/goal_chart/renderer/canvas/connected_component.tsx b/packages/charts/src/chart_types/goal_chart/renderer/canvas/connected_component.tsx index af08426bf1..cd58887503 100644 --- a/packages/charts/src/chart_types/goal_chart/renderer/canvas/connected_component.tsx +++ b/packages/charts/src/chart_types/goal_chart/renderer/canvas/connected_component.tsx @@ -21,13 +21,15 @@ import { import { getInternalIsInitializedSelector, InitStatus } from '../../../../state/selectors/get_internal_is_intialized'; import { Dimensions } from '../../../../utils/dimensions'; import { BandViewModel, nullShapeViewModel, ShapeViewModel } from '../../layout/types/viewmodel_types'; -import { geometries } from '../../state/selectors/geometries'; +import { Mark } from '../../layout/viewmodel/geoms'; +import { geometries, getPrimitiveGeoms } from '../../state/selectors/geometries'; import { getFirstTickValueSelector, getGoalChartSemanticDataSelector } from '../../state/selectors/get_goal_chart_data'; import { renderCanvas2d } from './canvas_renderers'; interface ReactiveChartStateProps { initialized: boolean; geometries: ShapeViewModel; + geoms: Mark[]; chartContainerDimensions: Dimensions; a11ySettings: A11ySettings; bandLabels: BandViewModel[]; @@ -43,6 +45,7 @@ interface ReactiveChartOwnProps { } type Props = ReactiveChartStateProps & ReactiveChartDispatchProps & ReactiveChartOwnProps; + class Component extends React.Component { static displayName = 'Goal'; @@ -139,11 +142,7 @@ class Component extends React.Component { private drawCanvas() { if (this.ctx) { - const { width, height }: Dimensions = this.props.chartContainerDimensions; - renderCanvas2d(this.ctx, this.devicePixelRatio, { - ...this.props.geometries, - config: { ...this.props.geometries.config, width, height }, - }); + renderCanvas2d(this.ctx, this.devicePixelRatio, this.props.geoms); } } } @@ -159,6 +158,7 @@ const mapDispatchToProps = (dispatch: Dispatch): ReactiveChartDispatchProps => const DEFAULT_PROPS: ReactiveChartStateProps = { initialized: false, geometries: nullShapeViewModel(), + geoms: [], chartContainerDimensions: { width: 0, height: 0, @@ -181,6 +181,7 @@ const mapStateToProps = (state: GlobalChartState): ReactiveChartStateProps => { a11ySettings: getA11ySettingsSelector(state), bandLabels: getGoalChartSemanticDataSelector(state), firstValue: getFirstTickValueSelector(state), + geoms: getPrimitiveGeoms(state), }; }; diff --git a/packages/charts/src/chart_types/goal_chart/state/selectors/geometries.ts b/packages/charts/src/chart_types/goal_chart/state/selectors/geometries.ts index 189cf344ac..ad2c00be02 100644 --- a/packages/charts/src/chart_types/goal_chart/state/selectors/geometries.ts +++ b/packages/charts/src/chart_types/goal_chart/state/selectors/geometries.ts @@ -13,6 +13,7 @@ import { createCustomCachedSelector } from '../../../../state/create_selector'; import { getSpecs } from '../../../../state/selectors/get_settings_specs'; import { getSpecsFromStore } from '../../../../state/utils'; import { nullShapeViewModel, ShapeViewModel } from '../../layout/types/viewmodel_types'; +import { geoms, Mark } from '../../layout/viewmodel/geoms'; import { GoalSpec } from '../../specs'; import { render } from './scenegraph'; @@ -26,3 +27,12 @@ export const geometries = createCustomCachedSelector( return goalSpecs.length === 1 ? render(goalSpecs[0], parentDimensions) : nullShapeViewModel(); }, ); + +/** @internal */ +export const getPrimitiveGeoms = createCustomCachedSelector( + [geometries, getParentDimensions], + (shapeViewModel: ShapeViewModel, { width, height }): Mark[] => { + const { config, chartCenter, bulletViewModel } = shapeViewModel; + return geoms(bulletViewModel, { ...config, width, height }, chartCenter); + }, +); diff --git a/playground/playground.tsx b/playground/playground.tsx index d15d3220f5..e31662a990 100644 --- a/playground/playground.tsx +++ b/playground/playground.tsx @@ -8,49 +8,10 @@ import React from 'react'; -import { Chart, AreaSeries, LineSeries, BarSeries, ScaleType, Settings } from '../packages/charts/src'; +import { Example } from '../stories/goal/22_horizontal_plusminus'; export class Playground extends React.Component { render() { - return ( -
- - - - - - -
- ); + return ; } }