diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-gauge-with-target-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-gauge-with-target-visually-looks-correct-1-snap.png index ed14863731..eee2232f88 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-gauge-with-target-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-gauge-with-target-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-goal-semantics-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-goal-semantics-visually-looks-correct-1-snap.png index e316a42c0e..409cb22f12 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-goal-semantics-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-goal-semantics-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-half-circle-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-half-circle-visually-looks-correct-1-snap.png index 02f9e12370..1757d93012 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-half-circle-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-half-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-side-gauge-inverted-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-side-gauge-inverted-visually-looks-correct-1-snap.png index 9580a35d13..24b6030d43 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-side-gauge-inverted-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-side-gauge-inverted-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-side-gauge-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-side-gauge-visually-looks-correct-1-snap.png index d593d0c129..959e04f7aa 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-side-gauge-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-goal-alpha-side-gauge-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 0027c5f74d..3902149a9f 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 f20dec7cb9..1a52c29cd0 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/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-sagitta-offset-should-limit-max-offset-to-3-2-\317\200-1-snap.png" "b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-sagitta-offset-should-limit-max-offset-to-3-2-\317\200-1-snap.png" new file mode 100644 index 0000000000..bff3993491 Binary files /dev/null and "b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-sagitta-offset-should-limit-max-offset-to-3-2-\317\200-1-snap.png" differ diff --git "a/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-sagitta-offset-should-limit-min-offset-to-\317\200-2-1-snap.png" "b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-sagitta-offset-should-limit-min-offset-to-\317\200-2-1-snap.png" new file mode 100644 index 0000000000..6f481b498f Binary files /dev/null and "b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-sagitta-offset-should-limit-min-offset-to-\317\200-2-1-snap.png" differ diff --git a/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-sagitta-offset-should-render-goal-with-asymetric-angle-1-snap.png b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-sagitta-offset-should-render-goal-with-asymetric-angle-1-snap.png new file mode 100644 index 0000000000..15f84a6d4f Binary files /dev/null and b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-sagitta-offset-should-render-goal-with-asymetric-angle-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-dark-should-render-gauge-with-target-story-1-snap.png b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-dark-should-render-gauge-with-target-story-1-snap.png index bf6ad91550..8f0d0d601b 100644 Binary files a/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-dark-should-render-gauge-with-target-story-1-snap.png and b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-dark-should-render-gauge-with-target-story-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-eui-dark-should-render-gauge-with-target-story-1-snap.png b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-eui-dark-should-render-gauge-with-target-story-1-snap.png index b040d6a26b..484b95600d 100644 Binary files a/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-eui-dark-should-render-gauge-with-target-story-1-snap.png and b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-eui-dark-should-render-gauge-with-target-story-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-eui-light-should-render-gauge-with-target-story-1-snap.png b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-eui-light-should-render-gauge-with-target-story-1-snap.png index 08cfbd11dc..ccf1633d5a 100644 Binary files a/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-eui-light-should-render-gauge-with-target-story-1-snap.png and b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-eui-light-should-render-gauge-with-target-story-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-light-should-render-gauge-with-target-story-1-snap.png b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-light-should-render-gauge-with-target-story-1-snap.png index ed14863731..eee2232f88 100644 Binary files a/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-light-should-render-gauge-with-target-story-1-snap.png and b/integration/tests/__image_snapshots__/goal-stories-test-ts-goal-stories-theme-light-should-render-gauge-with-target-story-1-snap.png differ diff --git a/integration/tests/goal_stories.test.ts b/integration/tests/goal_stories.test.ts index 44c14fac3c..62a488eef4 100644 --- a/integration/tests/goal_stories.test.ts +++ b/integration/tests/goal_stories.test.ts @@ -23,6 +23,27 @@ describe('Goal stories', () => { ); }); + describe('Sagitta offset', () => { + it('should render goal with asymetric angle', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/goal-alpha--gauge-with-target&knob-angleStart (n * π/8)=10&knob-angleEnd (n * π/8)=1', + ); + }); + + it('should limit min offset to π/2', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/goal-alpha--gauge-with-target&knob-angleStart (n * π/8)=6&knob-angleEnd (n * π/8)=2', + ); + }); + + it('should limit max offset to 3/2π', async () => { + // TODO revist once full circle chart is handled + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/goal-alpha--gauge-with-target&knob-angleStart (n * π/8)=11&knob-angleEnd (n * π/8)=-3', + ); + }); + }); + eachTheme.describe((_, params) => { it('should render gauge with target story', async () => { await common.expectChartAtUrlToMatchScreenshot( 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 index 45dcbd51e2..a5448e789d 100644 --- a/packages/charts/src/chart_types/goal_chart/layout/viewmodel/geoms.ts +++ b/packages/charts/src/chart_types/goal_chart/layout/viewmodel/geoms.ts @@ -14,6 +14,7 @@ import { Dimensions } from '../../../../utils/dimensions'; import { Theme } from '../../../../utils/themes/theme'; import { GoalSubtype } from '../../specs/constants'; import { BulletViewModel } from '../types/viewmodel_types'; +import { getSagitta, getMinSagitta } from './utils'; 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 @@ -260,6 +261,7 @@ export function geoms( ...Object.fromEntries(bands.map(({ value }, index) => [`qualitative_${index}`, { value }])), target: { value: target }, actual: { value: actual }, + yOffset: { value: 0 }, 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 } }))), @@ -299,31 +301,32 @@ export function geoms( landmarks: { from: i ? `qualitative_${i - 1}` : 'base', to: `qualitative_${i}`, + yOffset: 'yOffset', }, aes: { shape, fillColor: b.fillColor, lineWidth: barThickness }, })), { order: 1, - landmarks: { from: 'base', to: 'actual' }, + landmarks: { from: 'base', to: 'actual', yOffset: 'yOffset' }, aes: { shape, fillColor: config.progressLine.stroke, lineWidth: tickLength }, }, ...(target ? [ { order: 2, - landmarks: { at: 'target' }, + landmarks: { at: 'target', yOffset: 'yOffset' }, aes: { shape, fillColor: config.targetLine.stroke, lineWidth: barThickness / GOLDEN_RATIO }, }, ] : []), ...bulletViewModel.ticks.map((b, i) => ({ order: 3, - landmarks: { at: `tick_${i}` }, + landmarks: { at: `tick_${i}`, yOffset: 'yOffset' }, aes: { shape, fillColor: config.tickLine.stroke, lineWidth: tickLength, axisNormalOffset: tickOffset }, })), ...bulletViewModel.ticks.map((b, i) => ({ order: 4, - landmarks: { at: `tick_${i}` }, + landmarks: { at: `tick_${i}`, yOffset: 'yOffset' }, aes: { shape: 'text', textAlign: vertical ? 'right' : 'center', @@ -363,7 +366,7 @@ export function geoms( ? [ { order: 6, - landmarks: { at: 'centralMajor' }, + landmarks: { at: 'centralMajor', yOffset: 'yOffset' }, aes: { shape: 'text', textAlign: 'center', @@ -374,7 +377,7 @@ export function geoms( }, { order: 6, - landmarks: { at: 'centralMinor' }, + landmarks: { at: 'centralMinor', yOffset: 'yOffset' }, aes: { shape: 'text', textAlign: 'center', @@ -390,6 +393,12 @@ export function geoms( const maxWidth = abstractGeoms.reduce((p, g) => Math.max(p, get(g.aes, 'lineWidth', 0)), 0); const r = 0.5 * referenceSize - maxWidth / 2; + if (circular) { + const sagitta = getMinSagitta(angleStart, angleEnd, r); + const maxSagitta = getSagitta((3 / 2) * Math.PI, r); + data.yOffset.value = sagitta >= maxSagitta ? 0 : (maxSagitta - sagitta) / 2; + } + const fullSize = referenceSize; const labelSize = fullSize / 2; const pxRangeFrom = -fullSize / 2 + (circular || vertical ? 0 : labelSize); @@ -411,11 +420,14 @@ export function geoms( const at = get(landmarks, 'at', ''); const from = get(landmarks, 'from', ''); const to = get(landmarks, 'to', ''); + const yOffset = get(landmarks, 'yOffset', ''); 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 yOffsetValue = data[yOffset]?.value ?? 0; + const strokeStyle = get(aes, 'fillColor', ''); if (aes.shape === 'text') { const { text } = data[at]; @@ -434,7 +446,7 @@ export function geoms( : (vertical ? -axisTangentOffset - scaledValue : -axisNormalOffset); return new Text( x + chartCenter.x, - y + chartCenter.y, + y + chartCenter.y + yOffsetValue, text, textAlign, textBaseline, @@ -444,7 +456,7 @@ export function geoms( ); } else if (aes.shape === 'arc') { const cx = chartCenter.x + pxRangeMid; - const cy = chartCenter.y; + const cy = chartCenter.y + yOffsetValue; 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); @@ -453,7 +465,7 @@ export function geoms( return new Arc(cx, cy, radius, -startAngle, -endAngle, !anticlockwise, lineWidth, strokeStyle); } else { const translateX = chartCenter.x + (vertical ? axisNormalOffset : axisTangentOffset); - const translateY = chartCenter.y - (vertical ? axisTangentOffset : axisNormalOffset); + const translateY = chartCenter.y - (vertical ? axisTangentOffset : axisNormalOffset) + yOffsetValue; 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); diff --git a/packages/charts/src/chart_types/goal_chart/layout/viewmodel/utils.ts b/packages/charts/src/chart_types/goal_chart/layout/viewmodel/utils.ts new file mode 100644 index 0000000000..03291e540b --- /dev/null +++ b/packages/charts/src/chart_types/goal_chart/layout/viewmodel/utils.ts @@ -0,0 +1,47 @@ +/* + * 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 { Radian } from '../../../../common/geometry'; +import { round } from '../../../../utils/common'; + +/** + * Set to half circle such that anything smaller than a half circle will not + * continue to increase offset + */ +const LIMITING_ANGLE = Math.PI / 2; + +/** + * Returns limiting angle form π/2 towards 3/2π from left and right + */ +const controllingAngle = (...angles: Radian[]): Radian => + angles.reduce((limitAngle, a) => { + if (a >= Math.PI / 2 && a <= (3 / 2) * Math.PI) { + const newA = Math.abs(a - Math.PI / 2); + return Math.max(limitAngle, newA); + } + if (a >= -Math.PI / 2 && a <= Math.PI / 2) { + const newA = Math.abs(a - Math.PI / 2); + return Math.max(limitAngle, newA); + } + return limitAngle; + }, LIMITING_ANGLE); + +/** @internal */ +export function getSagitta(angle: Radian, radius: number, fractionDigits: number = 1) { + const arcLength = angle * radius; + const halfCord = radius * Math.sin(arcLength / (2 * radius)); + const lengthMiltiplier = arcLength > Math.PI ? 1 : -1; + const sagitta = radius + lengthMiltiplier * Math.sqrt(Math.pow(radius, 2) - Math.pow(halfCord, 2)); + return round(sagitta, fractionDigits); +} + +/** @internal */ +export function getMinSagitta(startAngle: Radian, endAngle: Radian, radius: number, fractionDigits?: number) { + const limitingAngle = controllingAngle(startAngle, endAngle); + return getSagitta(limitingAngle * 2, radius, fractionDigits); +} diff --git a/storybook/stories/goal/2_gauge_with_target.story.tsx b/storybook/stories/goal/2_gauge_with_target.story.tsx index 4b1437dac2..896ea51bb0 100644 --- a/storybook/stories/goal/2_gauge_with_target.story.tsx +++ b/storybook/stories/goal/2_gauge_with_target.story.tsx @@ -40,6 +40,21 @@ export const Example = () => { const bandFillColor = (x: number): Color => colorMap[x]; + const angleStart = + number('angleStart (n * π/8)', 8, { + step: 1, + min: -2 * 8, + max: 2 * 8, + }) * + (Math.PI / 8); + const angleEnd = + number('angleEnd (n * π/8)', 0, { + step: 1, + min: -2 * 8, + max: 2 * 8, + }) * + (Math.PI / 8); + return ( @@ -57,7 +72,7 @@ export const Example = () => { labelMinor="(thousand USD) " centralMajor={`${actual}`} centralMinor="" - config={{ angleStart: Math.PI, angleEnd: 0 }} + config={{ angleStart, angleEnd }} /> );