From b418b67b5041516ec1715e1bb978d0b1d10596d6 Mon Sep 17 00:00:00 2001 From: Emma Cunningham Date: Mon, 10 Jun 2019 17:00:18 -0700 Subject: [PATCH] feat: add histogram mode (#218) --- src/index.ts | 3 + src/lib/axes/axis_utils.test.ts | 51 ++++-- src/lib/axes/axis_utils.ts | 30 ++-- src/lib/series/rendering.areas.test.ts | 26 ++- src/lib/series/rendering.bands.test.ts | 10 +- src/lib/series/rendering.bars.test.ts | 12 +- src/lib/series/rendering.lines.test.ts | 26 ++- src/lib/series/rendering.ts | 11 +- src/lib/series/specs.ts | 30 +++- src/lib/themes/dark_theme.ts | 1 + src/lib/themes/light_theme.ts | 1 + src/lib/themes/theme.test.ts | 1 + src/lib/themes/theme.ts | 6 + src/lib/utils/commons.ts | 3 + src/lib/utils/dimensions.test.ts | 3 - src/specs/area_series.tsx | 3 +- src/specs/bar_series.tsx | 1 + src/specs/histogram_bar_series.tsx | 42 +++++ src/specs/index.ts | 1 + src/specs/line_series.tsx | 3 +- src/specs/settings.tsx | 1 - src/state/annotation_marker.test.tsx | 3 + src/state/annotation_utils.test.ts | 122 ++++++++++++- src/state/annotation_utils.ts | 100 +++++++++-- src/state/chart_state.ts | 26 ++- src/state/utils.test.ts | 184 +++++++++++++++++++ src/state/utils.ts | 81 ++++++++- stories/annotations.tsx | 60 +++++-- stories/bar_chart.tsx | 237 ++++++++++++++++++++++++- 29 files changed, 978 insertions(+), 100 deletions(-) create mode 100644 src/specs/histogram_bar_series.tsx diff --git a/src/index.ts b/src/index.ts index fc632bc4c8..bf2c99020b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,8 +13,11 @@ export { timeFormatter, niceTimeFormatter, niceTimeFormatByDay } from './utils/d export { DataGenerator } from './utils/data_generators/data_generator'; export { DataSeriesColorsValues } from './lib/series/series'; export { + AnnotationDomainType, AnnotationDomainTypes, CustomSeriesColorsMap, + HistogramModeAlignment, + HistogramModeAlignments, LineAnnotationDatum, LineAnnotationSpec, RectAnnotationDatum, diff --git a/src/lib/axes/axis_utils.test.ts b/src/lib/axes/axis_utils.test.ts index abf65b87c9..134da2597a 100644 --- a/src/lib/axes/axis_utils.test.ts +++ b/src/lib/axes/axis_utils.test.ts @@ -2,9 +2,11 @@ import { XDomain } from '../series/domains/x_domain'; import { YDomain } from '../series/domains/y_domain'; import { AxisSpec, DomainRange, Position } from '../series/specs'; import { LIGHT_THEME } from '../themes/light_theme'; -import { getAxisId, getGroupId, GroupId } from '../utils/ids'; +import { AxisId, getAxisId, getGroupId, GroupId } from '../utils/ids'; import { ScaleType } from '../utils/scales/scales'; import { + AxisTick, + AxisTicksDimensions, centerRotationOrigin, computeAxisGridLinePositions, computeAxisTicksDimensions, @@ -65,8 +67,6 @@ describe('Axis computational utils', () => { left: 0, }; const axis1Dims = { - axisScaleType: ScaleType.Linear, - axisScaleDomain: [0, 1], tickValues: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1], tickLabels: ['0', '0.1', '0.2', '0.3', '0.4', '0.5', '0.6', '0.7', '0.8', '0.9', '1'], maxLabelBboxWidth: 10, @@ -223,7 +223,7 @@ describe('Axis computational utils', () => { test('should compute available ticks', () => { const scale = getScaleForAxisSpec(verticalAxisSpec, xDomain, [yDomain], 0, 0, 100, 0); - const axisPositions = getAvailableTicks(verticalAxisSpec, scale!, 0); + const axisPositions = getAvailableTicks(verticalAxisSpec, scale!, 0, false); const expectedAxisPositions = [ { label: '0', position: 100, value: 0 }, { label: '0.1', position: 90, value: 0.1 }, @@ -238,6 +238,19 @@ describe('Axis computational utils', () => { { label: '1', position: 0, value: 1 }, ]; expect(axisPositions).toEqual(expectedAxisPositions); + + // histogram mode axis ticks should add an additional tick + const xBandDomain: XDomain = { + type: 'xDomain', + scaleType: ScaleType.Linear, + domain: [0, 100], + isBandScale: true, + minInterval: 10, + }; + const xScale = getScaleForAxisSpec(horizontalAxisSpec, xBandDomain, [yDomain], 1, 0, 100, 0); + const histogramAxisPositions = getAvailableTicks(horizontalAxisSpec, xScale!, 1, true); + const histogramTickLabels = histogramAxisPositions.map((tick: AxisTick) => tick.label); + expect(histogramTickLabels).toEqual(['0', '10', '20', '30', '40', '50', '60', '70', '80', '90', '100', '110']); }); test('should compute visible ticks for a vertical axis', () => { const allTicks = [ @@ -723,7 +736,7 @@ describe('Axis computational utils', () => { test('should compute axis ticks positions with title', () => { const chartRotation = 0; const showLegend = false; - + // validate assumptions for test expect(verticalAxisSpec.id).toEqual(verticalAxisSpecWTitle.id); @@ -743,6 +756,7 @@ describe('Axis computational utils', () => { xDomain, [yDomain], 1, + false, ); let left = 12 + 5 + 10 + 10; // font size + title padding + chart margin left + label width @@ -763,6 +777,7 @@ describe('Axis computational utils', () => { xDomain, [yDomain], 1, + false, ); left = 0 + 10 + 10; // no title + chart margin left + label width @@ -918,10 +933,10 @@ describe('Axis computational utils', () => { const showLegend = true; const leftLegendPosition = Position.Left; - const axisSpecs = new Map(); + const axisSpecs = new Map(); axisSpecs.set(verticalAxisSpec.id, verticalAxisSpec); - const axisDims = new Map(); + const axisDims = new Map(); axisDims.set(getAxisId('not_a_mapped_one'), axis1Dims); const axisTicksPosition = getAxisTicksPositions( @@ -934,6 +949,7 @@ describe('Axis computational utils', () => { xDomain, [yDomain], 1, + false, leftLegendPosition, ); expect(axisTicksPosition.axisPositions.size).toBe(0); @@ -948,10 +964,10 @@ describe('Axis computational utils', () => { const leftLegendPosition = Position.Left; const topLegendPosition = Position.Top; - const axisSpecs = new Map(); + const axisSpecs = new Map(); axisSpecs.set(verticalAxisSpec.id, verticalAxisSpec); - const axisDims = new Map(); + const axisDims = new Map(); axisDims.set(verticalAxisSpec.id, axis1Dims); const axisTicksPosition = getAxisTicksPositions( @@ -964,6 +980,7 @@ describe('Axis computational utils', () => { xDomain, [yDomain], 1, + false, leftLegendPosition, ); @@ -995,6 +1012,7 @@ describe('Axis computational utils', () => { xDomain, [yDomain], 1, + false, topLegendPosition, ); @@ -1010,7 +1028,7 @@ describe('Axis computational utils', () => { expect(verticalAxisWithTopLegendPosition).toEqual(expectedPositionWithTopLegend); const ungroupedAxisSpec = { ...verticalAxisSpec, groupId: getGroupId('foo') }; - const invalidSpecs = new Map(); + const invalidSpecs = new Map(); invalidSpecs.set(verticalAxisSpec.id, ungroupedAxisSpec); const computeScalelessSpec = () => { getAxisTicksPositions( @@ -1023,6 +1041,7 @@ describe('Axis computational utils', () => { xDomain, [yDomain], 1, + false, leftLegendPosition, ); }; @@ -1073,7 +1092,7 @@ describe('Axis computational utils', () => { verticalAxisSpec.domain = domainRange1; - const axesSpecs = new Map(); + const axesSpecs = new Map(); axesSpecs.set(verticalAxisSpec.id, verticalAxisSpec); // Base case @@ -1129,7 +1148,7 @@ describe('Axis computational utils', () => { verticalAxisSpec.domain = domainRange1; - const axesSpecs = new Map(); + const axesSpecs = new Map(); axesSpecs.set(verticalAxisSpec.id, verticalAxisSpec); const axis2 = { ...verticalAxisSpec, id: getAxisId('axis2') }; @@ -1157,7 +1176,7 @@ describe('Axis computational utils', () => { verticalAxisSpec.domain = domainRange1; - const axesSpecs = new Map(); + const axesSpecs = new Map(); axesSpecs.set(verticalAxisSpec.id, verticalAxisSpec); const axis2 = { ...verticalAxisSpec, id: getAxisId('axis2') }; @@ -1188,7 +1207,7 @@ describe('Axis computational utils', () => { verticalAxisSpec.domain = domainRange1; - const axesSpecs = new Map(); + const axesSpecs = new Map(); axesSpecs.set(verticalAxisSpec.id, verticalAxisSpec); const axis2 = { ...verticalAxisSpec, id: getAxisId('axis2') }; @@ -1224,7 +1243,7 @@ describe('Axis computational utils', () => { verticalAxisSpec.domain = domainRange1; - const axesSpecs = new Map(); + const axesSpecs = new Map(); axesSpecs.set(verticalAxisSpec.id, verticalAxisSpec); const axis2 = { ...verticalAxisSpec, id: getAxisId('axis2') }; @@ -1252,7 +1271,7 @@ describe('Axis computational utils', () => { verticalAxisSpec.domain = domainRange1; - const axesSpecs = new Map(); + const axesSpecs = new Map(); axesSpecs.set(verticalAxisSpec.id, verticalAxisSpec); const attemptToMerge = () => { diff --git a/src/lib/axes/axis_utils.ts b/src/lib/axes/axis_utils.ts index 111244f260..22b531236e 100644 --- a/src/lib/axes/axis_utils.ts +++ b/src/lib/axes/axis_utils.ts @@ -13,9 +13,8 @@ import { } from '../series/specs'; import { AxisConfig, Theme } from '../themes/theme'; import { Dimensions, Margins } from '../utils/dimensions'; -import { Domain } from '../utils/domain'; import { AxisId, GroupId } from '../utils/ids'; -import { Scale, ScaleType } from '../utils/scales/scales'; +import { Scale } from '../utils/scales/scales'; import { BBox, BBoxCalculator } from './bbox_calculator'; export type AxisLinePosition = [number, number, number, number]; @@ -27,8 +26,6 @@ export interface AxisTick { } export interface AxisTicksDimensions { - axisScaleType: ScaleType; - axisScaleDomain: Domain; tickValues: string[] | number[]; tickLabels: string[]; maxLabelBboxWidth: number; @@ -90,8 +87,6 @@ export function computeAxisTicksDimensions( ); return { - axisScaleDomain: xDomain.domain, - axisScaleType: xDomain.scaleType, ...dimensions, }; } @@ -291,7 +286,7 @@ export function getTickLabelProps( } return { - x: tickPosition - maxLabelBboxWidth / 2, + x: (tickPosition - maxLabelBboxWidth / 2), y: isAxisTop ? 0 : tickSize + tickPadding, align, verticalAlign, @@ -393,10 +388,24 @@ export function getLeftAxisMinMaxRange(chartRotation: Rotation, height: number) } } -export function getAvailableTicks(axisSpec: AxisSpec, scale: Scale, totalBarsInCluster: number) { +export function getAvailableTicks( + axisSpec: AxisSpec, + scale: Scale, + totalBarsInCluster: number, + enableHistogramMode: boolean, +): AxisTick[] { const ticks = scale.ticks(); + + if (enableHistogramMode && scale.bandwidth > 0) { + const finalTick = ticks[ticks.length - 1] + scale.minInterval; + ticks.push(finalTick); + } + const shift = totalBarsInCluster > 0 ? totalBarsInCluster : 1; - const offset = (scale.bandwidth * shift) / 2; + + const band = scale.bandwidth / (1 - scale.barsPadding); + const halfPadding = (band - scale.bandwidth) / 2; + const offset = enableHistogramMode ? -halfPadding : (scale.bandwidth * shift) / 2; return ticks.map((tick) => { return { value: tick, @@ -507,6 +516,7 @@ export function getAxisTicksPositions( xDomain: XDomain, yDomain: YDomain[], totalGroupsCount: number, + enableHistogramMode: boolean, legendPosition?: Position, barsPadding?: number, ) { @@ -567,7 +577,7 @@ export function getAxisTicksPositions( throw new Error(`Cannot compute scale for axis spec ${axisSpec.id}`); } - const allTicks = getAvailableTicks(axisSpec, scale, totalGroupsCount); + const allTicks = getAvailableTicks(axisSpec, scale, totalGroupsCount, enableHistogramMode); const visibleTicks = getVisibleTicks(allTicks, axisSpec, axisDim); if (axisSpec.showGridLines) { diff --git a/src/lib/series/rendering.areas.test.ts b/src/lib/series/rendering.areas.test.ts index 3c996fa481..194fdad723 100644 --- a/src/lib/series/rendering.areas.test.ts +++ b/src/lib/series/rendering.areas.test.ts @@ -1,5 +1,5 @@ import { computeSeriesDomains } from '../../state/utils'; -import { getGroupId, getSpecId } from '../utils/ids'; +import { getGroupId, getSpecId, SpecId } from '../utils/ids'; import { ScaleType } from '../utils/scales/scales'; import { CurveType } from './curves'; import { AreaGeometry, IndexedGeometry, PointGeometry, renderArea } from './rendering'; @@ -21,7 +21,7 @@ describe('Rendering points - areas', () => { xScaleType: ScaleType.Ordinal, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(SPEC_ID, pointSeriesSpec); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); const xScale = computeXScale(pointSeriesDomains.xDomain, pointSeriesMap.size, 0, 100); @@ -42,6 +42,7 @@ describe('Rendering points - areas', () => { SPEC_ID, false, [], + 0, ); }); test('Can render an line and area paths', () => { @@ -129,7 +130,7 @@ describe('Rendering points - areas', () => { xScaleType: ScaleType.Ordinal, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(spec1Id, pointSeriesSpec1); pointSeriesMap.set(spec2Id, pointSeriesSpec2); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); @@ -156,6 +157,7 @@ describe('Rendering points - areas', () => { spec1Id, false, [], + 0, ); secondLine = renderArea( 25, // adding a ideal 25px shift, generally applied by renderGeometries @@ -167,6 +169,7 @@ describe('Rendering points - areas', () => { spec2Id, false, [], + 0, ); }); @@ -290,7 +293,7 @@ describe('Rendering points - areas', () => { xScaleType: ScaleType.Linear, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(SPEC_ID, pointSeriesSpec); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); const xScale = computeXScale(pointSeriesDomains.xDomain, pointSeriesMap.size, 0, 100); @@ -312,6 +315,7 @@ describe('Rendering points - areas', () => { SPEC_ID, false, [], + 0, ); }); test('Can render a linear area', () => { @@ -393,7 +397,7 @@ describe('Rendering points - areas', () => { xScaleType: ScaleType.Linear, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(spec1Id, pointSeriesSpec1); pointSeriesMap.set(spec2Id, pointSeriesSpec2); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); @@ -420,6 +424,7 @@ describe('Rendering points - areas', () => { spec1Id, false, [], + 0, ); secondLine = renderArea( 0, // not applied any shift, renderGeometries applies it only with mixed charts @@ -431,6 +436,7 @@ describe('Rendering points - areas', () => { spec2Id, false, [], + 0, ); }); test('can render two linear areas', () => { @@ -553,7 +559,7 @@ describe('Rendering points - areas', () => { xScaleType: ScaleType.Time, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(SPEC_ID, pointSeriesSpec); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); const xScale = computeXScale(pointSeriesDomains.xDomain, pointSeriesMap.size, 0, 100); @@ -575,6 +581,7 @@ describe('Rendering points - areas', () => { SPEC_ID, false, [], + 0, ); }); test('Can render a time area', () => { @@ -656,7 +663,7 @@ describe('Rendering points - areas', () => { xScaleType: ScaleType.Time, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(spec1Id, pointSeriesSpec1); pointSeriesMap.set(spec2Id, pointSeriesSpec2); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); @@ -683,6 +690,7 @@ describe('Rendering points - areas', () => { spec1Id, false, [], + 0, ); secondLine = renderArea( 0, // not applied any shift, renderGeometries applies it only with mixed charts @@ -694,6 +702,7 @@ describe('Rendering points - areas', () => { spec2Id, false, [], + 0, ); }); test('can render first spec points', () => { @@ -801,7 +810,7 @@ describe('Rendering points - areas', () => { xScaleType: ScaleType.Linear, yScaleType: ScaleType.Log, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(SPEC_ID, pointSeriesSpec); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); const xScale = computeXScale(pointSeriesDomains.xDomain, pointSeriesMap.size, 0, 90); @@ -823,6 +832,7 @@ describe('Rendering points - areas', () => { SPEC_ID, false, [], + 0, ); }); test('Can render a splitted area and line', () => { diff --git a/src/lib/series/rendering.bands.test.ts b/src/lib/series/rendering.bands.test.ts index 2752101322..c0765d2cda 100644 --- a/src/lib/series/rendering.bands.test.ts +++ b/src/lib/series/rendering.bands.test.ts @@ -1,5 +1,5 @@ import { computeSeriesDomains } from '../../state/utils'; -import { getGroupId, getSpecId } from '../utils/ids'; +import { getGroupId, getSpecId, SpecId } from '../utils/ids'; import { ScaleType } from '../utils/scales/scales'; import { CurveType } from './curves'; import { AreaGeometry, IndexedGeometry, PointGeometry, renderArea, renderBars } from './rendering'; @@ -22,7 +22,7 @@ describe('Rendering bands - areas', () => { xScaleType: ScaleType.Ordinal, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(SPEC_ID, pointSeriesSpec); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); const xScale = computeXScale(pointSeriesDomains.xDomain, pointSeriesMap.size, 0, 100); @@ -43,6 +43,7 @@ describe('Rendering bands - areas', () => { SPEC_ID, true, [], + 0, ); }); test('Can render upper and lower lines and area paths', () => { @@ -158,7 +159,7 @@ describe('Rendering bands - areas', () => { xScaleType: ScaleType.Ordinal, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(SPEC_ID, pointSeriesSpec); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); const xScale = computeXScale(pointSeriesDomains.xDomain, pointSeriesMap.size, 0, 100); @@ -179,6 +180,7 @@ describe('Rendering bands - areas', () => { SPEC_ID, true, [], + 0, ); }); test('Can render upper and lower lines and area paths', () => { @@ -333,7 +335,7 @@ describe('Rendering bands - areas', () => { xScaleType: ScaleType.Ordinal, yScaleType: ScaleType.Linear, }; - const barSeriesMap = new Map(); + const barSeriesMap = new Map(); barSeriesMap.set(SPEC_ID, barSeriesSpec); const barSeriesDomains = computeSeriesDomains(barSeriesMap, new Map()); const xScale = computeXScale(barSeriesDomains.xDomain, barSeriesMap.size, 0, 100); diff --git a/src/lib/series/rendering.bars.test.ts b/src/lib/series/rendering.bars.test.ts index 3aae03a75a..4231f814d7 100644 --- a/src/lib/series/rendering.bars.test.ts +++ b/src/lib/series/rendering.bars.test.ts @@ -1,6 +1,6 @@ import { computeSeriesDomains } from '../../state/utils'; import { identity } from '../utils/commons'; -import { getGroupId, getSpecId } from '../utils/ids'; +import { getGroupId, getSpecId, SpecId } from '../utils/ids'; import { ScaleType } from '../utils/scales/scales'; import { renderBars } from './rendering'; import { computeXScale, computeYScales } from './scales'; @@ -22,7 +22,7 @@ describe('Rendering bars', () => { xScaleType: ScaleType.Ordinal, yScaleType: ScaleType.Linear, }; - const barSeriesMap = new Map(); + const barSeriesMap = new Map(); barSeriesMap.set(SPEC_ID, barSeriesSpec); const customDomain = [0, 1]; const barSeriesDomains = computeSeriesDomains(barSeriesMap, new Map(), customDomain); @@ -159,7 +159,7 @@ describe('Rendering bars', () => { xScaleType: ScaleType.Ordinal, yScaleType: ScaleType.Linear, }; - const barSeriesMap = new Map(); + const barSeriesMap = new Map(); barSeriesMap.set(spec1Id, barSeriesSpec1); barSeriesMap.set(spec2Id, barSeriesSpec2); const barSeriesDomains = computeSeriesDomains(barSeriesMap, new Map()); @@ -267,7 +267,7 @@ describe('Rendering bars', () => { xScaleType: ScaleType.Linear, yScaleType: ScaleType.Linear, }; - const barSeriesMap = new Map(); + const barSeriesMap = new Map(); barSeriesMap.set(SPEC_ID, barSeriesSpec); const barSeriesDomains = computeSeriesDomains(barSeriesMap, new Map()); const xScale = computeXScale(barSeriesDomains.xDomain, barSeriesMap.size, 0, 100); @@ -342,7 +342,7 @@ describe('Rendering bars', () => { xScaleType: ScaleType.Linear, yScaleType: ScaleType.Linear, }; - const barSeriesMap = new Map(); + const barSeriesMap = new Map(); barSeriesMap.set(spec1Id, barSeriesSpec1); barSeriesMap.set(spec2Id, barSeriesSpec2); const barSeriesDomains = computeSeriesDomains(barSeriesMap, new Map()); @@ -463,7 +463,7 @@ describe('Rendering bars', () => { xScaleType: ScaleType.Time, yScaleType: ScaleType.Linear, }; - const barSeriesMap = new Map(); + const barSeriesMap = new Map(); barSeriesMap.set(spec1Id, barSeriesSpec1); barSeriesMap.set(spec2Id, barSeriesSpec2); const barSeriesDomains = computeSeriesDomains(barSeriesMap, new Map()); diff --git a/src/lib/series/rendering.lines.test.ts b/src/lib/series/rendering.lines.test.ts index 30a5b69c88..55cce9a6b9 100644 --- a/src/lib/series/rendering.lines.test.ts +++ b/src/lib/series/rendering.lines.test.ts @@ -1,5 +1,5 @@ import { computeSeriesDomains } from '../../state/utils'; -import { getGroupId, getSpecId } from '../utils/ids'; +import { getGroupId, getSpecId, SpecId } from '../utils/ids'; import { ScaleType } from '../utils/scales/scales'; import { CurveType } from './curves'; import { IndexedGeometry, LineGeometry, PointGeometry, renderLine } from './rendering'; @@ -21,7 +21,7 @@ describe('Rendering points - line', () => { xScaleType: ScaleType.Ordinal, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(SPEC_ID, pointSeriesSpec); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); const xScale = computeXScale(pointSeriesDomains.xDomain, pointSeriesMap.size, 0, 100); @@ -42,6 +42,7 @@ describe('Rendering points - line', () => { SPEC_ID, false, [], + 0, ); }); test('Can render a line', () => { @@ -124,7 +125,7 @@ describe('Rendering points - line', () => { xScaleType: ScaleType.Ordinal, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(spec1Id, pointSeriesSpec1); pointSeriesMap.set(spec2Id, pointSeriesSpec2); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); @@ -151,6 +152,7 @@ describe('Rendering points - line', () => { spec1Id, false, [], + 0, ); secondLine = renderLine( 25, // adding a ideal 25px shift, generally applied by renderGeometries @@ -162,6 +164,7 @@ describe('Rendering points - line', () => { spec2Id, false, [], + 0, ); }); @@ -283,7 +286,7 @@ describe('Rendering points - line', () => { xScaleType: ScaleType.Linear, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(SPEC_ID, pointSeriesSpec); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); const xScale = computeXScale(pointSeriesDomains.xDomain, pointSeriesMap.size, 0, 100); @@ -305,6 +308,7 @@ describe('Rendering points - line', () => { SPEC_ID, false, [], + 0, ); }); test('Can render a linear line', () => { @@ -385,7 +389,7 @@ describe('Rendering points - line', () => { xScaleType: ScaleType.Linear, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(spec1Id, pointSeriesSpec1); pointSeriesMap.set(spec2Id, pointSeriesSpec2); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); @@ -412,6 +416,7 @@ describe('Rendering points - line', () => { spec1Id, false, [], + 0, ); secondLine = renderLine( 0, // not applied any shift, renderGeometries applies it only with mixed charts @@ -423,6 +428,7 @@ describe('Rendering points - line', () => { spec2Id, false, [], + 0, ); }); test('can render two linear lines', () => { @@ -543,7 +549,7 @@ describe('Rendering points - line', () => { xScaleType: ScaleType.Time, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(SPEC_ID, pointSeriesSpec); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); const xScale = computeXScale(pointSeriesDomains.xDomain, pointSeriesMap.size, 0, 100); @@ -565,6 +571,7 @@ describe('Rendering points - line', () => { SPEC_ID, false, [], + 0, ); }); test('Can render a time line', () => { @@ -645,7 +652,7 @@ describe('Rendering points - line', () => { xScaleType: ScaleType.Time, yScaleType: ScaleType.Linear, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(spec1Id, pointSeriesSpec1); pointSeriesMap.set(spec2Id, pointSeriesSpec2); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); @@ -672,6 +679,7 @@ describe('Rendering points - line', () => { spec1Id, false, [], + 0, ); secondLine = renderLine( 0, // not applied any shift, renderGeometries applies it only with mixed charts @@ -683,6 +691,7 @@ describe('Rendering points - line', () => { spec2Id, false, [], + 0, ); }); test('can render first spec points', () => { @@ -790,7 +799,7 @@ describe('Rendering points - line', () => { xScaleType: ScaleType.Linear, yScaleType: ScaleType.Log, }; - const pointSeriesMap = new Map(); + const pointSeriesMap = new Map(); pointSeriesMap.set(SPEC_ID, pointSeriesSpec); const pointSeriesDomains = computeSeriesDomains(pointSeriesMap, new Map()); const xScale = computeXScale(pointSeriesDomains.xDomain, pointSeriesMap.size, 0, 90); @@ -812,6 +821,7 @@ describe('Rendering points - line', () => { SPEC_ID, false, [], + 0, ); }); test('Can render a splitted line', () => { diff --git a/src/lib/series/rendering.ts b/src/lib/series/rendering.ts index f403871abd..9f08c44874 100644 --- a/src/lib/series/rendering.ts +++ b/src/lib/series/rendering.ts @@ -238,6 +238,7 @@ export function renderBars( y = yScale.scale(y1); height = yScale.scale(y0) - y; } + const x = xScale.scale(datum.x) + xScale.bandwidth * orderIndex; const width = xScale.bandwidth; @@ -320,6 +321,7 @@ export function renderLine( specId: SpecId, hasY0Accessors: boolean, seriesKey: any[], + xScaleOffset: number, seriesStyle?: LineSeriesStyle, ): { lineGeometry: LineGeometry; @@ -328,7 +330,7 @@ export function renderLine( const isLogScale = isLogarithmicScale(yScale); const pathGenerator = line() - .x((datum: DataSeriesDatum) => xScale.scale(datum.x)) + .x((datum: DataSeriesDatum) => xScale.scale(datum.x) - xScaleOffset) .y((datum: DataSeriesDatum) => yScale.scale(datum.y1)) .defined((datum: DataSeriesDatum) => datum.y1 !== null && !(isLogScale && datum.y1 <= 0)) .curve(getCurveFactory(curve)); @@ -339,7 +341,7 @@ export function renderLine( const seriesLineStyle = seriesStyle ? seriesStyle.line : undefined; const { pointGeometries, indexedGeometries } = renderPoints( - shift, + shift - xScaleOffset, dataset, xScale, yScale, @@ -379,6 +381,7 @@ export function renderArea( specId: SpecId, hasY0Accessors: boolean, seriesKey: any[], + xScaleOffset: number, seriesStyle?: AreaSeriesStyle, ): { areaGeometry: AreaGeometry; @@ -387,7 +390,7 @@ export function renderArea( const isLogScale = isLogarithmicScale(yScale); const pathGenerator = area() - .x((datum: DataSeriesDatum) => xScale.scale(datum.x)) + .x((datum: DataSeriesDatum) => xScale.scale(datum.x) - xScaleOffset) .y1((datum: DataSeriesDatum) => yScale.scale(datum.y1)) .y0((datum: DataSeriesDatum) => { if (datum.y0 === null || (isLogScale && datum.y0 <= 0)) { @@ -416,7 +419,7 @@ export function renderArea( const seriesAreaLineStyle = seriesStyle ? seriesStyle.line : undefined; const { pointGeometries, indexedGeometries } = renderPoints( - shift, + shift - xScaleOffset, dataset, xScale, yScale, diff --git a/src/lib/series/specs.ts b/src/lib/series/specs.ts index 3c21626a31..8844fad587 100644 --- a/src/lib/series/specs.ts +++ b/src/lib/series/specs.ts @@ -7,6 +7,7 @@ import { RectAnnotationStyle, } from '../themes/theme'; import { Accessor } from '../utils/accessor'; +import { Omit } from '../utils/commons'; import { AnnotationId, AxisId, GroupId, SpecId } from '../utils/ids'; import { ScaleContinuousType, ScaleType } from '../utils/scales/scales'; import { CurveType } from './curves'; @@ -118,13 +119,23 @@ export type BasicSeriesSpec = SeriesSpec & SeriesAccessors & SeriesScales; export type BarSeriesSpec = BasicSeriesSpec & { /** @default bar */ seriesType: 'bar'; + /** If true, will stack all BarSeries and align bars to ticks (instead of centered on ticks) */ + enableHistogramMode?: boolean; barSeriesStyle?: CustomBarSeriesStyle; }; +/** + * This spec describe the dataset configuration used to display a histogram bar series. + * A histogram bar series is identical to a bar series except that stackAccessors are not allowed. + */ +export type HistogramBarSeriesSpec = Omit & { + enableHistogramMode: true; +}; + /** * This spec describe the dataset configuration used to display a line series. */ -export type LineSeriesSpec = BasicSeriesSpec & { +export type LineSeriesSpec = BasicSeriesSpec & HistogramConfig & { /** @default line */ seriesType: 'line'; curve?: CurveType; @@ -134,7 +145,7 @@ export type LineSeriesSpec = BasicSeriesSpec & { /** * This spec describe the dataset configuration used to display an area series. */ -export type AreaSeriesSpec = BasicSeriesSpec & { +export type AreaSeriesSpec = BasicSeriesSpec & HistogramConfig & { /** @default area */ seriesType: 'area'; /** The type of interpolator to be used to interpolate values between points */ @@ -142,6 +153,21 @@ export type AreaSeriesSpec = BasicSeriesSpec & { areaSeriesStyle?: AreaSeriesStyle; }; +interface HistogramConfig { + /** Determines how points in the series will align to bands in histogram mode + * @default 'start' + */ + histogramModeAlignment?: HistogramModeAlignment; +} + +export const HistogramModeAlignments = Object.freeze({ + Start: 'start' as HistogramModeAlignment, + Center: 'center' as HistogramModeAlignment, + End: 'end' as HistogramModeAlignment, +}); + +export type HistogramModeAlignment = 'start' | 'center' | 'end'; + /** * This spec describe the configuration for a chart axis. */ diff --git a/src/lib/themes/dark_theme.ts b/src/lib/themes/dark_theme.ts index 77d5cd9d79..0905a8eac3 100644 --- a/src/lib/themes/dark_theme.ts +++ b/src/lib/themes/dark_theme.ts @@ -73,6 +73,7 @@ export const DARK_THEME: Theme = { sharedStyle: DEFAULT_GEOMETRY_STYLES, scales: { barsPadding: 0.25, + histogramPadding: 0.05, }, axes: { axisTitleStyle: { diff --git a/src/lib/themes/light_theme.ts b/src/lib/themes/light_theme.ts index 61d7fd22af..cce01be178 100644 --- a/src/lib/themes/light_theme.ts +++ b/src/lib/themes/light_theme.ts @@ -73,6 +73,7 @@ export const LIGHT_THEME: Theme = { sharedStyle: DEFAULT_GEOMETRY_STYLES, scales: { barsPadding: 0.25, + histogramPadding: 0.05, }, axes: { axisTitleStyle: { diff --git a/src/lib/themes/theme.test.ts b/src/lib/themes/theme.test.ts index a7d7441c22..db0fea8c27 100644 --- a/src/lib/themes/theme.test.ts +++ b/src/lib/themes/theme.test.ts @@ -203,6 +203,7 @@ describe('Themes', () => { it('should merge partial theme: scales', () => { const scales: ScalesConfig = { barsPadding: 314571, + histogramPadding: 0.05, }; const customTheme = mergeWithDefaultTheme({ scales, diff --git a/src/lib/themes/theme.ts b/src/lib/themes/theme.ts index a5e3cb69d4..f84fcfa1e2 100644 --- a/src/lib/themes/theme.ts +++ b/src/lib/themes/theme.ts @@ -62,6 +62,12 @@ export interface ScalesConfig { * A number between 0 and 1. */ barsPadding: number; + /** + * The proportion of the range that is reserved for blank space between bands in histogramMode. + * A value of 0 means no blank space between bands, and a value of 1 means a bandwidth of zero. + * A number between 0 and 1. + */ + histogramPadding: number; } export interface ColorConfig { vizColors: string[]; diff --git a/src/lib/utils/commons.ts b/src/lib/utils/commons.ts index 4b0370b1cc..177f2a059a 100644 --- a/src/lib/utils/commons.ts +++ b/src/lib/utils/commons.ts @@ -9,3 +9,6 @@ export function compareByValueAsc(firstEl: number, secondEl: number): number { export function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } + +// Can remove once we upgrade to TypesScript >= 3.5 +export type Omit = Pick>; diff --git a/src/lib/utils/dimensions.test.ts b/src/lib/utils/dimensions.test.ts index 3ed109c645..875ccb78a6 100644 --- a/src/lib/utils/dimensions.test.ts +++ b/src/lib/utils/dimensions.test.ts @@ -4,7 +4,6 @@ import { LIGHT_THEME } from '../themes/light_theme'; import { LegendStyle } from '../themes/theme'; import { computeChartDimensions, Margins } from './dimensions'; import { AxisId, getAxisId, getGroupId } from './ids'; -import { ScaleType } from './scales/scales'; describe('Computed chart dimensions', () => { const parentDim = { @@ -27,8 +26,6 @@ describe('Computed chart dimensions', () => { }; const axis1Dims: AxisTicksDimensions = { - axisScaleType: ScaleType.Linear, - axisScaleDomain: [0, 1], tickValues: [0, 1], tickLabels: ['first', 'second'], maxLabelBboxWidth: 10, diff --git a/src/specs/area_series.tsx b/src/specs/area_series.tsx index 5fd31bbf5e..7a9e725c66 100644 --- a/src/specs/area_series.tsx +++ b/src/specs/area_series.tsx @@ -1,6 +1,6 @@ import { inject } from 'mobx-react'; import { PureComponent } from 'react'; -import { AreaSeriesSpec } from '../lib/series/specs'; +import { AreaSeriesSpec, HistogramModeAlignments } from '../lib/series/specs'; import { getGroupId } from '../lib/utils/ids'; import { ScaleType } from '../lib/utils/scales/scales'; import { SpecProps } from './specs_parser'; @@ -17,6 +17,7 @@ export class AreaSeriesSpecComponent extends PureComponent { yAccessors: ['y'], yScaleToDataExtent: false, hideInLegend: false, + histogramModeAlignment: HistogramModeAlignments.Center, }; componentDidMount() { const { chartStore, children, ...config } = this.props; diff --git a/src/specs/bar_series.tsx b/src/specs/bar_series.tsx index be54884d38..6785ee479e 100644 --- a/src/specs/bar_series.tsx +++ b/src/specs/bar_series.tsx @@ -17,6 +17,7 @@ export class BarSeriesSpecComponent extends PureComponent { yAccessors: ['y'], yScaleToDataExtent: false, hideInLegend: false, + enableHistogramMode: false, }; componentDidMount() { const { chartStore, children, ...config } = this.props; diff --git a/src/specs/histogram_bar_series.tsx b/src/specs/histogram_bar_series.tsx new file mode 100644 index 0000000000..979d7a89ce --- /dev/null +++ b/src/specs/histogram_bar_series.tsx @@ -0,0 +1,42 @@ +import { inject } from 'mobx-react'; +import { PureComponent } from 'react'; +import { HistogramBarSeriesSpec } from '../lib/series/specs'; +import { getGroupId } from '../lib/utils/ids'; +import { ScaleType } from '../lib/utils/scales/scales'; +import { SpecProps } from './specs_parser'; + +type HistogramBarSpecProps = SpecProps & HistogramBarSeriesSpec; + +export class HistogramBarSeriesSpecComponent extends PureComponent { + static defaultProps: Partial = { + seriesType: 'bar', + groupId: getGroupId('__global__'), + xScaleType: ScaleType.Ordinal, + yScaleType: ScaleType.Linear, + xAccessor: 'x', + yAccessors: ['y'], + yScaleToDataExtent: false, + hideInLegend: false, + enableHistogramMode: true, + }; + componentDidMount() { + const { chartStore, children, ...config } = this.props; + chartStore!.addSeriesSpec({ ...config }); + } + componentDidUpdate(prevProps: HistogramBarSpecProps) { + const { chartStore, children, ...config } = this.props; + chartStore!.addSeriesSpec({ ...config }); + if (prevProps.id !== this.props.id) { + chartStore!.removeSeriesSpec(prevProps.id); + } + } + componentWillUnmount() { + const { chartStore, id } = this.props; + chartStore!.removeSeriesSpec(id); + } + render() { + return null; + } +} + +export const HistogramBarSeries = inject('chartStore')(HistogramBarSeriesSpecComponent); diff --git a/src/specs/index.ts b/src/specs/index.ts index 7191c967bb..84fbe940d5 100644 --- a/src/specs/index.ts +++ b/src/specs/index.ts @@ -3,5 +3,6 @@ export { LineAnnotation } from './line_annotation'; export { RectAnnotation } from './rect_annotation'; export { LineSeries } from './line_series'; export { BarSeries } from './bar_series'; +export { HistogramBarSeries } from './histogram_bar_series'; export { AreaSeries } from './area_series'; export { Settings } from './settings'; diff --git a/src/specs/line_series.tsx b/src/specs/line_series.tsx index 0652f100f0..581dcddf4d 100644 --- a/src/specs/line_series.tsx +++ b/src/specs/line_series.tsx @@ -1,6 +1,6 @@ import { inject } from 'mobx-react'; import { PureComponent } from 'react'; -import { LineSeriesSpec } from '../lib/series/specs'; +import { HistogramModeAlignments, LineSeriesSpec } from '../lib/series/specs'; import { getGroupId } from '../lib/utils/ids'; import { ScaleType } from '../lib/utils/scales/scales'; import { SpecProps } from './specs_parser'; @@ -17,6 +17,7 @@ export class LineSeriesSpecComponent extends PureComponent { yAccessors: ['y'], yScaleToDataExtent: false, hideInLegend: false, + histogramModeAlignment: HistogramModeAlignments.Center, }; componentDidMount() { const { chartStore, children, ...config } = this.props; diff --git a/src/specs/settings.tsx b/src/specs/settings.tsx index 4ae7d06cc3..27d9fa4af1 100644 --- a/src/specs/settings.tsx +++ b/src/specs/settings.tsx @@ -29,7 +29,6 @@ interface SettingSpecProps { tooltipSnap?: boolean; debug: boolean; legendPosition?: Position; - isLegendItemsSortDesc: boolean; showLegendDisplayValue: boolean; onElementClick?: ElementClickListener; onElementOver?: ElementOverListener; diff --git a/src/state/annotation_marker.test.tsx b/src/state/annotation_marker.test.tsx index e6d449717b..713a647c4a 100644 --- a/src/state/annotation_marker.test.tsx +++ b/src/state/annotation_marker.test.tsx @@ -65,6 +65,7 @@ describe('annotation marker', () => { yScales, xScale, Position.Left, + 0, ); const expectedDimensions = [ { @@ -103,6 +104,7 @@ describe('annotation marker', () => { yScales, xScale, Position.Left, + 0, ); const expectedDimensions = [ { @@ -141,6 +143,7 @@ describe('annotation marker', () => { yScales, xScale, Position.Left, + 0, ); const expectedDimensions = [ { diff --git a/src/state/annotation_utils.test.ts b/src/state/annotation_utils.test.ts index d51e501f68..82b147a82a 100644 --- a/src/state/annotation_utils.test.ts +++ b/src/state/annotation_utils.test.ts @@ -26,6 +26,7 @@ import { AnnotationLineProps, computeAnnotationDimensions, computeAnnotationTooltipState, + computeClusterOffset, computeLineAnnotationDimensions, computeLineAnnotationTooltipState, computeRectAnnotationDimensions, @@ -39,6 +40,7 @@ import { getAnnotationLineTooltipTransform, getAnnotationLineTooltipXOffset, getAnnotationLineTooltipYOffset, + getNearestTick, getRotatedCursor, isBottomRectTooltip, isRightRectTooltip, @@ -58,7 +60,7 @@ describe('annotation utils', () => { const continuousScale = new ScaleContinuous(ScaleType.Linear, continuousData, [ minRange, maxRange, - ]); + ], 0, 1); const ordinalData = ['a', 'b', 'c', 'd', 'a', 'b', 'c']; const ordinalScale = new ScaleBand(ordinalData, [minRange, maxRange]); @@ -136,6 +138,8 @@ describe('annotation utils', () => { yScales, xScale, axesSpecs, + 1, + false, ); const expectedDimensions = new Map(); expectedDimensions.set(annotationId, [{ @@ -175,6 +179,8 @@ describe('annotation utils', () => { yScales, xScale, new Map(), // empty axesSpecs + 1, + false, ); const expectedDimensions = new Map(); expect(dimensions).toEqual(expectedDimensions); @@ -204,6 +210,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Left, + 0, ); const expectedDimensions = [ { @@ -239,6 +246,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Right, + 0, ); const expectedDimensions = [ { @@ -274,6 +282,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Left, + 0, ); const expectedDimensions = [ { @@ -307,6 +316,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Left, + 0, ); expect(dimensions).toEqual(null); }); @@ -333,6 +343,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Left, + 0, ); const expectedDimensions = [ { @@ -366,6 +377,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Top, + 0, ); const expectedDimensions = [ { @@ -399,6 +411,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Bottom, + 0, ); const expectedDimensions = [ { @@ -433,6 +446,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Left, + 0, ); const expectedDimensions = [ { @@ -467,6 +481,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Left, + 0, ); const expectedDimensions = [ { @@ -502,6 +517,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Left, + 0, ); const expectedDimensions = [{ position: [20, -DEFAULT_LINE_OVERFLOW, 20, 10], @@ -535,6 +551,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Top, + 0, ); const expectedDimensions = [ { @@ -569,6 +586,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Bottom, + 0, ); const expectedDimensions = [{ position: [20, DEFAULT_LINE_OVERFLOW, 20, 20], @@ -603,6 +621,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Right, + 0, ); expect(emptyXDimensions).toEqual([]); @@ -623,6 +642,7 @@ describe('annotation utils', () => { yScales, continuousScale, Position.Right, + 0, ); expect(invalidStringXDimensions).toEqual([]); @@ -643,6 +663,7 @@ describe('annotation utils', () => { yScales, continuousScale, Position.Right, + 0, ); expect(emptyOutOfBoundsXDimensions).toEqual([]); @@ -663,6 +684,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Right, + 0, ); expect(emptyYDimensions).toEqual([]); @@ -683,6 +705,7 @@ describe('annotation utils', () => { yScales, xScale, Position.Right, + 0, ); expect(emptyOutOfBoundsYDimensions).toEqual([]); @@ -703,6 +726,7 @@ describe('annotation utils', () => { yScales, continuousScale, Position.Right, + 0, ); expect(invalidStringYDimensions).toEqual([]); @@ -724,6 +748,7 @@ describe('annotation utils', () => { yScales, continuousScale, Position.Right, + 0, ); expect(hiddenAnnotationDimensions).toEqual(null); @@ -1280,6 +1305,8 @@ describe('annotation utils', () => { annotationRectangle, yScales, xScale, + false, + 0, ); expect(noYScale).toBe(null); @@ -1304,10 +1331,59 @@ describe('annotation utils', () => { annotationRectangle, yScales, xScale, + false, + 0, ); expect(skippedInvalid).toEqual([]); }); + test('should compute rectangle dimensions shifted for histogram mode', () => { + const yScales: Map = new Map(); + yScales.set(groupId, continuousScale); + + const xScale: Scale = new ScaleContinuous(ScaleType.Linear, continuousData, [ minRange, maxRange ], 1, 1); + + const annotationRectangle: RectAnnotationSpec = { + annotationId: getAnnotationId('rect'), + groupId, + annotationType: 'rectangle', + dataValues: [ + { coordinates: { x0: 1, x1: null, y0: null, y1: null } }, + { coordinates: { x0: null, x1: 1, y0: null, y1: null } }, + { coordinates: { x0: null, x1: null, y0: 1, y1: null } }, + { coordinates: { x0: null, x1: null, y0: null, y1: 1 } }, + ], + }; + + const dimensions = computeRectAnnotationDimensions( + annotationRectangle, + yScales, + xScale, + true, + 0, + ); + + const [ dims1, dims2, dims3, dims4 ] = dimensions; + expect(dims1.rect.x).toBe(0); + expect(dims1.rect.y).toBe(0); + expect(dims1.rect.width).toBe(10); + expect(dims1.rect.height).toBe(100); + + expect(dims2.rect.x).toBe(10); + expect(dims2.rect.y).toBe(0); + expect(dims2.rect.width).toBeCloseTo(100); + expect(dims2.rect.height).toBe(100); + + expect(dims3.rect.x).toBe(0); + expect(dims3.rect.y).toBe(0); + expect(dims3.rect.width).toBeCloseTo(110); + expect(dims3.rect.height).toBe(10); + + expect(dims4.rect.x).toBe(0); + expect(dims4.rect.y).toBe(10); + expect(dims4.rect.width).toBeCloseTo(110); + expect(dims4.rect.height).toBe(90); + }); test('should compute rectangle dimensions when only a single coordinate defined', () => { const yScales: Map = new Map(); yScales.set(groupId, continuousScale); @@ -1330,6 +1406,8 @@ describe('annotation utils', () => { annotationRectangle, yScales, xScale, + false, + 0, ); const expectedDimensions = [ @@ -1358,6 +1436,8 @@ describe('annotation utils', () => { annotationRectangle, yScales, xScale, + false, + 0, ); expect(unrotated).toEqual([{ rect: { x: 10, y: 30, width: 10, height: 20 } }]); @@ -1379,15 +1459,28 @@ describe('annotation utils', () => { annotationRectangle, yScales, xScale, + false, + 0, ); expect(unrotated).toEqual([{ rect: { x: 0, y: 0, width: 25, height: 20 } }]); }); test('should validate scaled dataValues', () => { - expect(scaleAndValidateDatum('', ordinalScale)).toBe(null); - expect(scaleAndValidateDatum('a', continuousScale)).toBe(null); - expect(scaleAndValidateDatum(-10, continuousScale)).toBe(null); - expect(scaleAndValidateDatum(20, continuousScale)).toBe(null); + // not aligned with tick + expect(scaleAndValidateDatum('', ordinalScale, false)).toBe(null); + expect(scaleAndValidateDatum('a', continuousScale, false)).toBe(null); + expect(scaleAndValidateDatum(-10, continuousScale, false)).toBe(null); + expect(scaleAndValidateDatum(20, continuousScale, false)).toBe(null); + + // allow values within domainEnd + minInterval when not alignWithTick + expect(scaleAndValidateDatum(10.25, continuousScale, false)).toBeCloseTo(102.5); + expect(scaleAndValidateDatum(10.25, continuousScale, true)).toBe(null); + + expect(scaleAndValidateDatum('a', ordinalScale, false)).toBe(0); + expect(scaleAndValidateDatum(0, continuousScale, false)).toBe(0); + + // aligned with tick + expect(scaleAndValidateDatum(1.25, continuousScale, true)).toBe(10); }); test('should determine if a point is within a rectangle annotation', () => { const cursorPosition = { x: 3, y: 4 }; @@ -1560,4 +1653,23 @@ describe('annotation utils', () => { expect(getRotatedCursor(rawCursorPosition, chartDimensions, -90)).toEqual({ x: 18, y: 9 }); expect(getRotatedCursor(rawCursorPosition, chartDimensions, 180)).toEqual({ x: 9, y: 18 }); }); + test('should get nearest tick', () => { + const ticks = [0, 1, 2]; + expect(getNearestTick(0.25, [], 1)).toBeUndefined(); + expect(getNearestTick(0.25, [100], 1)).toBeUndefined(); + expect(getNearestTick(0.25, ticks, 1)).toBe(0); + expect(getNearestTick(0.75, ticks, 1)).toBe(1); + expect(getNearestTick(0.5, ticks, 1)).toBe(1); + expect(getNearestTick(1.75, ticks, 1)).toBe(2); + }); + test('should compute cluster offset', () => { + const singleBarCluster = 1; + const multiBarCluster = 2; + + const barsShift = 4; + const bandwidth = 2; + + expect(computeClusterOffset(singleBarCluster, barsShift, bandwidth)).toBe(0); + expect(computeClusterOffset(multiBarCluster, barsShift, bandwidth)).toBe(3); + }); }); diff --git a/src/state/annotation_utils.ts b/src/state/annotation_utils.ts index 18ea1bc2eb..15f7e3b462 100644 --- a/src/state/annotation_utils.ts +++ b/src/state/annotation_utils.ts @@ -6,6 +6,7 @@ import { AnnotationType, AnnotationTypes, AxisSpec, + HistogramModeAlignments, isLineAnnotation, isRectAnnotation, LineAnnotationDatum, @@ -20,7 +21,7 @@ import { Dimensions } from '../lib/utils/dimensions'; import { AnnotationId, AxisId, GroupId } from '../lib/utils/ids'; import { Scale, ScaleType } from '../lib/utils/scales/scales'; import { Point } from './chart_state'; -import { getAxesSpecForSpecId, isHorizontalRotation } from './utils'; +import { computeXScaleOffset, getAxesSpecForSpecId, isHorizontalRotation } from './utils'; export interface AnnotationTooltipState { annotationType: AnnotationType; @@ -177,6 +178,7 @@ export function computeXDomainLineAnnotationDimensions( axisPosition: Position, chartDimensions: Dimensions, lineColor: string, + xScaleOffset: number, marker?: JSX.Element, markerDimensions?: { width: number; height: number; }, ): AnnotationLineProps[] { @@ -192,8 +194,7 @@ export function computeXDomainLineAnnotationDimensions( headerText: datum.header || dataValue.toString(), }; - // TODO: make offset dependent on annotationSpec.alignment (left, center, right) - const offset = xScale.bandwidth / 2; + const offset = xScale.bandwidth / 2 - xScaleOffset; const isContinuous = xScale.type !== ScaleType.Ordinal; const scaledXValue = xScale.scale(dataValue); @@ -250,11 +251,8 @@ export function computeXDomainLineAnnotationDimensions( const startY = (axisPosition === Position.Bottom) ? 0 : -lineOverflow; const endY = (axisPosition === Position.Bottom) ? chartHeight + lineOverflow : chartHeight; linePosition = [xDomainPosition, startY, xDomainPosition, endY]; - // tooltipLinePosition = [chartWidth - xDomainPosition, 0, chartWidth - xDomainPosition, chartHeight]; tooltipLinePosition = [xDomainPosition, 0, xDomainPosition, chartHeight]; - // tooltipLinePosition = [0, chartWidth - xDomainPosition, chartHeight, chartWidth - xDomainPosition]; - const startMarkerY = (axisPosition === Position.Bottom) ? 0 : -lineOverflow - markerOffsets.height; const endMarkerY = (axisPosition === Position.Bottom) ? chartHeight + lineOverflow + markerOffsets.height : chartHeight; @@ -281,6 +279,7 @@ export function computeLineAnnotationDimensions( yScales: Map, xScale: Scale, axisPosition: Position, + xScaleOffset: number, ): AnnotationLineProps[] | null { const { domainType, dataValues, marker, markerDimensions, hideLines } = annotationSpec; @@ -304,6 +303,7 @@ export function computeLineAnnotationDimensions( axisPosition, chartDimensions, lineColor, + xScaleOffset, marker, markerDimensions, ); @@ -328,10 +328,44 @@ export function computeLineAnnotationDimensions( ); } -export function scaleAndValidateDatum(dataValue: any, scale: Scale): any | null { - const isContinuous = scale.type !== ScaleType.Ordinal; +/** + * Used when we need to snap values to the nearest tick edge, this performs a binary search for the nearest tick + * @param dataValue - dataValue defined as an annotation cooordinate + * @param ticks - ticks from the scale + * @param minInterval - minInterva from the scale + */ +export function getNearestTick(dataValue: number, ticks: number[], minInterval: number): number | undefined { + if (ticks.length === 0) { + return; + } - const scaledValue = scale.scale(dataValue); + if (ticks.length === 1) { + if (Math.abs(dataValue - ticks[0]) <= minInterval / 2) { + return ticks[0]; + } + return; + } + + const numTicks = ticks.length - 1; + const midIdx = Math.ceil(numTicks / 2); + const midPoint = ticks[midIdx]; + + if (Math.abs(dataValue - midPoint) <= minInterval / 2) { + return midPoint; + } + + if (dataValue > midPoint) { + return getNearestTick(dataValue, ticks.slice(midIdx, ticks.length), minInterval); + } + + return getNearestTick(dataValue, ticks.slice(0, midIdx), minInterval); +} + +export function scaleAndValidateDatum(dataValue: any, scale: Scale, alignWithTick: boolean): any | null { + const isContinuous = scale.type !== ScaleType.Ordinal; + const value = (isContinuous && alignWithTick) ? + getNearestTick(dataValue, scale.ticks(), scale.minInterval) : dataValue; + const scaledValue = scale.scale(value); // d3.scale will return 0 for '', rendering the line incorrectly at 0 if (isNaN(scaledValue) || (isContinuous && dataValue === '')) { @@ -341,7 +375,10 @@ export function scaleAndValidateDatum(dataValue: any, scale: Scale): any | null if (isContinuous) { const [domainStart, domainEnd] = scale.domain; - if (domainStart > dataValue || domainEnd < dataValue) { + // if we're not aligning the ticks, we need to extend the domain by one more tick for histograms + const domainEndOffset = alignWithTick ? 0 : scale.minInterval; + + if (domainStart > dataValue || domainEnd + domainEndOffset < dataValue) { return null; } } @@ -353,6 +390,8 @@ export function computeRectAnnotationDimensions( annotationSpec: RectAnnotationSpec, yScales: Map, xScale: Scale, + enableHistogramMode: boolean, + barsPadding: number, ): AnnotationRectProps[] | null { const { dataValues } = annotationSpec; @@ -364,6 +403,8 @@ export function computeRectAnnotationDimensions( const xDomain = xScale.domain; const yDomain = yScale.domain; + const lastX = xDomain[xDomain.length - 1]; + const xMinInterval = xScale.minInterval; const rectsProps: AnnotationRectProps[] = []; @@ -377,7 +418,8 @@ export function computeRectAnnotationDimensions( if (x0 == null) { // if x1 is defined, we want the rect to draw to the end of the scale - x0 = xDomain[xDomain.length - 1]; + // if we're in histogram mode, extend domain end by min interval + x0 = enableHistogramMode ? lastX + xMinInterval : lastX; } if (x1 == null) { @@ -395,10 +437,12 @@ export function computeRectAnnotationDimensions( y1 = yDomain[0]; } - let x0Scaled = scaleAndValidateDatum(x0, xScale); - let x1Scaled = scaleAndValidateDatum(x1, xScale); - const y0Scaled = scaleAndValidateDatum(y0, yScale); - const y1Scaled = scaleAndValidateDatum(y1, yScale); + const alignWithTick = xScale.bandwidth > 0 && !enableHistogramMode; + + let x0Scaled = scaleAndValidateDatum(x0, xScale, alignWithTick); + let x1Scaled = scaleAndValidateDatum(x1, xScale, alignWithTick); + const y0Scaled = scaleAndValidateDatum(y0, yScale, false); + const y1Scaled = scaleAndValidateDatum(y1, yScale, false); // TODO: surface this as a warning if ([x0Scaled, x1Scaled, y0Scaled, y1Scaled].includes(null)) { @@ -408,8 +452,9 @@ export function computeRectAnnotationDimensions( let xOffset = 0; if (xScale.bandwidth > 0) { const xBand = xScale.bandwidth / (1 - xScale.barsPadding); - xOffset = (xBand - xScale.bandwidth) / 2; + xOffset = enableHistogramMode ? (xBand - xScale.bandwidth) / 2 : barsPadding; } + x0Scaled = x0Scaled - xOffset; x1Scaled = x1Scaled - xOffset; @@ -454,6 +499,14 @@ export function getAnnotationAxis( return annotationAxis ? annotationAxis.position : null; } +export function computeClusterOffset(totalBarsInCluster: number, barsShift: number, bandwidth: number): number { + if (totalBarsInCluster > 1) { + return barsShift - bandwidth / 2; + } + + return 0; +} + export function computeAnnotationDimensions( annotations: Map, chartDimensions: Dimensions, @@ -461,9 +514,21 @@ export function computeAnnotationDimensions( yScales: Map, xScale: Scale, axesSpecs: Map, + totalBarsInCluster: number, + enableHistogramMode: boolean, ): Map { const annotationDimensions = new Map(); + const barsShift = totalBarsInCluster * xScale.bandwidth / 2; + + const band = xScale.bandwidth / (1 - xScale.barsPadding); + const halfPadding = (band - xScale.bandwidth) / 2; + const barsPadding = halfPadding * totalBarsInCluster; + const clusterOffset = computeClusterOffset(totalBarsInCluster, barsShift, xScale.bandwidth); + + // Annotations should always align with the axis line in histogram mode + const xScaleOffset = computeXScaleOffset(xScale, enableHistogramMode, HistogramModeAlignments.Start); + annotations.forEach((annotationSpec: AnnotationSpec, annotationId: AnnotationId) => { if (isLineAnnotation(annotationSpec)) { const { groupId, domainType } = annotationSpec; @@ -480,6 +545,7 @@ export function computeAnnotationDimensions( yScales, xScale, annotationAxisPosition, + xScaleOffset - clusterOffset, ); if (dimensions) { @@ -490,6 +556,8 @@ export function computeAnnotationDimensions( annotationSpec, yScales, xScale, + enableHistogramMode, + barsPadding, ); if (dimensions) { diff --git a/src/state/chart_state.ts b/src/state/chart_state.ts index 932fb223cb..4bdf6ff061 100644 --- a/src/state/chart_state.ts +++ b/src/state/chart_state.ts @@ -84,7 +84,9 @@ import { getAxesSpecForSpecId, getUpdatedCustomSeriesColors, isChartAnimatable, + isHistogramModeEnabled, isLineAreaOnlyChart, + setBarSeriesAccessors, Transform, updateDeselectedDataSeries, } from './utils'; @@ -113,6 +115,8 @@ export class ChartStore { debug = false; specsInitialized = observable.box(false); initialized = observable.box(false); + enableHistogramMode = observable.box(false); + parentDimensions: Dimensions = { width: 0, height: 0, @@ -722,10 +726,21 @@ export class ChartStore { } addSeriesSpec(seriesSpec: BasicSeriesSpec | LineSeriesSpec | AreaSeriesSpec | BarSeriesSpec) { this.seriesSpecs.set(seriesSpec.id, seriesSpec); + + const isEnabled = isHistogramModeEnabled(this.seriesSpecs); + this.enableHistogramMode.set(isEnabled); + + setBarSeriesAccessors(isEnabled, this.seriesSpecs); } removeSeriesSpec(specId: SpecId) { this.seriesSpecs.delete(specId); + + const isEnabled = isHistogramModeEnabled(this.seriesSpecs); + this.enableHistogramMode.set(isEnabled); + + setBarSeriesAccessors(isEnabled, this.seriesSpecs); } + /** * Add an axis spec to the store * @param axisSpec an axis spec @@ -825,6 +840,9 @@ export class ChartStore { // compute axis dimensions const bboxCalculator = new CanvasTextBBoxCalculator(); + const barsPadding = this.enableHistogramMode.get() ? + this.chartTheme.scales.histogramPadding : this.chartTheme.scales.barsPadding; + this.axesTicksDimensions.clear(); this.axesSpecs.forEach((axisSpec) => { const { id } = axisSpec; @@ -836,7 +854,7 @@ export class ChartStore { bboxCalculator, this.chartRotation, this.chartTheme.axes, - this.chartTheme.scales.barsPadding, + barsPadding, ); if (dimensions) { this.axesTicksDimensions.set(id, dimensions); @@ -871,6 +889,7 @@ export class ChartStore { this.chartDimensions, this.chartRotation, this.axesSpecs, + this.enableHistogramMode.get(), ); // tslint:disable-next-line:no-console @@ -892,8 +911,9 @@ export class ChartStore { seriesDomains.xDomain, seriesDomains.yDomain, totalBarsInCluster, + this.enableHistogramMode.get(), this.legendPosition, - this.chartTheme.scales.barsPadding, + barsPadding, ); // tslint:disable-next-line:no-console // console.log({axisTicksPositions}); @@ -910,6 +930,8 @@ export class ChartStore { this.yScales, this.xScale, this.axesSpecs, + this.totalBarsInCluster, + this.enableHistogramMode.get(), ); this.annotationDimensions.replace(updatedAnnotationDimensions); diff --git a/src/state/utils.test.ts b/src/state/utils.test.ts index 69822514d1..d8c1b29a41 100644 --- a/src/state/utils.test.ts +++ b/src/state/utils.test.ts @@ -6,21 +6,26 @@ import { AxisSpec, BarSeriesSpec, BasicSeriesSpec, + HistogramModeAlignments, LineSeriesSpec, } from '../lib/series/specs'; import { BARCHART_1Y0G, BARCHART_1Y1G } from '../lib/series/utils/test_dataset'; import { LIGHT_THEME } from '../lib/themes/light_theme'; import { AxisId, getGroupId, getSpecId, SpecId } from '../lib/utils/ids'; +import { ScaleContinuous } from '../lib/utils/scales/scale_continuous'; import { ScaleType } from '../lib/utils/scales/scales'; import { computeSeriesDomains, computeSeriesGeometries, + computeXScaleOffset, getUpdatedCustomSeriesColors, isChartAnimatable, + isHistogramModeEnabled, isHorizontalRotation, isLineAreaOnlyChart, isVerticalRotation, mergeGeometriesIndexes, + setBarSeriesAccessors, updateDeselectedDataSeries, } from './utils'; @@ -375,6 +380,7 @@ describe('Chart State utils', () => { chartDimensions, chartRotation, axesSpecs, + false, ); expect(geometries.geometriesCounts.bars).toBe(8); expect(geometries.geometriesCounts.linePoints).toBe(8); @@ -427,6 +433,7 @@ describe('Chart State utils', () => { chartDimensions, chartRotation, axesSpecs, + false, ); expect(geometries.geometriesIndex.size).toBe(4); expect(geometries.geometriesIndex.get(0)!.length).toBe(2); @@ -481,6 +488,7 @@ describe('Chart State utils', () => { chartDimensions, chartRotation, axesSpecs, + false, ); expect(geometries.geometriesIndex.size).toBe(4); expect(geometries.geometriesIndex.get(0)!.length).toBe(2); @@ -562,6 +570,7 @@ describe('Chart State utils', () => { chartDimensions, chartRotation, axesSpecs, + false, ); expect(geometries.geometriesCounts.bars).toBe(8); expect(geometries.geometriesCounts.linePoints).toBe(8); @@ -632,6 +641,7 @@ describe('Chart State utils', () => { chartDimensions, chartRotation, axesSpecs, + false, ); expect(geometries.geometriesCounts.bars).toBe(0); expect(geometries.geometriesCounts.linePoints).toBe(24); @@ -702,6 +712,7 @@ describe('Chart State utils', () => { chartDimensions, chartRotation, axesSpecs, + false, ); expect(geometries.geometriesCounts.bars).toBe(0); expect(geometries.geometriesCounts.linePoints).toBe(0); @@ -772,6 +783,7 @@ describe('Chart State utils', () => { chartDimensions, chartRotation, axesSpecs, + false, ); expect(geometries.geometriesCounts.bars).toBe(24); expect(geometries.geometriesCounts.linePoints).toBe(0); @@ -809,4 +821,176 @@ describe('Chart State utils', () => { expect(merged.get('a')).toBeDefined(); expect(merged.get('a')!.length).toBe(2); }); + test('can compute xScaleOffset dependent on histogram mode', () => { + const domain = [0, 10]; + const range: [number, number] = [0, 100]; + const bandwidth = 10; + const barsPadding = 0.5; + const scale = new ScaleContinuous( + ScaleType.Linear, + domain, + range, + bandwidth, + 0, + 'utc', + 1, + barsPadding, + ); + const histogramModeEnabled = true; + const histogramModeDisabled = false; + + expect(computeXScaleOffset(scale, histogramModeDisabled)).toBe(0); + + // default alignment (start) + expect(computeXScaleOffset(scale, histogramModeEnabled)).toBe(5); + + expect(computeXScaleOffset(scale, histogramModeEnabled, HistogramModeAlignments.Center)).toBe(0); + expect(computeXScaleOffset(scale, histogramModeEnabled, HistogramModeAlignments.End)).toBe(-5); + }); + test('can determine if histogram mode is enabled', () => { + const area: AreaSeriesSpec = { + id: getSpecId('area'), + groupId: getGroupId('group1'), + seriesType: 'area', + yScaleType: ScaleType.Log, + xScaleType: ScaleType.Linear, + xAccessor: 'x', + yAccessors: ['y'], + splitSeriesAccessors: ['g'], + yScaleToDataExtent: false, + data: BARCHART_1Y1G, + }; + const line: LineSeriesSpec = { + id: getSpecId('line'), + groupId: getGroupId('group2'), + seriesType: 'line', + yScaleType: ScaleType.Log, + xScaleType: ScaleType.Linear, + xAccessor: 'x', + yAccessors: ['y'], + splitSeriesAccessors: ['g'], + stackAccessors: ['x'], + yScaleToDataExtent: false, + data: BARCHART_1Y1G, + }; + const basicBar: BarSeriesSpec = { + id: getSpecId('bar'), + groupId: getGroupId('group2'), + seriesType: 'bar', + yScaleType: ScaleType.Log, + xScaleType: ScaleType.Linear, + xAccessor: 'x', + yAccessors: ['y'], + splitSeriesAccessors: ['g'], + stackAccessors: ['x'], + yScaleToDataExtent: false, + data: BARCHART_1Y1G, + }; + const histogramBar: BarSeriesSpec = { + id: getSpecId('histo'), + groupId: getGroupId('group2'), + seriesType: 'bar', + yScaleType: ScaleType.Log, + xScaleType: ScaleType.Linear, + xAccessor: 'x', + yAccessors: ['y'], + splitSeriesAccessors: ['g'], + stackAccessors: ['x'], + yScaleToDataExtent: false, + data: BARCHART_1Y1G, + enableHistogramMode: true, + }; + const seriesMap = new Map([ + [area.id, area], + [line.id, line], + [basicBar.id, basicBar], + [histogramBar.id, histogramBar], + ]); + + expect(isHistogramModeEnabled(seriesMap)).toBe(true); + + seriesMap.delete(histogramBar.id); + expect(isHistogramModeEnabled(seriesMap)).toBe(false); + + seriesMap.delete(basicBar.id); + expect(isHistogramModeEnabled(seriesMap)).toBe(false); + }); + test('can set the bar series accessors dependent on histogram mode', () => { + const isNotHistogramEnabled = false; + const isHistogramEnabled = true; + + const area: AreaSeriesSpec = { + id: getSpecId('area'), + groupId: getGroupId('group1'), + seriesType: 'area', + yScaleType: ScaleType.Log, + xScaleType: ScaleType.Linear, + xAccessor: 'x', + yAccessors: ['y'], + splitSeriesAccessors: ['g'], + yScaleToDataExtent: false, + data: BARCHART_1Y1G, + }; + const line: LineSeriesSpec = { + id: getSpecId('line'), + groupId: getGroupId('group2'), + seriesType: 'line', + yScaleType: ScaleType.Log, + xScaleType: ScaleType.Linear, + xAccessor: 'x', + yAccessors: ['y'], + splitSeriesAccessors: ['g'], + stackAccessors: ['x'], + yScaleToDataExtent: false, + data: BARCHART_1Y1G, + }; + const bar: BarSeriesSpec = { + id: getSpecId('bar'), + groupId: getGroupId('group2'), + seriesType: 'bar', + yScaleType: ScaleType.Log, + xScaleType: ScaleType.Linear, + xAccessor: 'x', + yAccessors: ['y'], + splitSeriesAccessors: ['g'], + stackAccessors: ['foo'], + yScaleToDataExtent: false, + data: BARCHART_1Y1G, + }; + + const seriesMap = new Map([ + [area.id, area], + [line.id, line], + ]); + + // should not affect area or line series + setBarSeriesAccessors(isHistogramEnabled, seriesMap); + expect(seriesMap).toEqual(seriesMap); + + // add bar series, histogram mode not enabled + seriesMap.set(bar.id, bar); + setBarSeriesAccessors(isNotHistogramEnabled, seriesMap); + + // histogram mode + setBarSeriesAccessors(isHistogramEnabled, seriesMap); + expect(bar.stackAccessors).toEqual(['foo', 'g']); + + // add another bar + const bar2: BarSeriesSpec = { + id: getSpecId('bar2'), + groupId: getGroupId('group2'), + seriesType: 'bar', + yScaleType: ScaleType.Log, + xScaleType: ScaleType.Linear, + xAccessor: 'x', + yAccessors: ['y'], + splitSeriesAccessors: ['bar'], + yScaleToDataExtent: false, + data: BARCHART_1Y1G, + }; + + seriesMap.set(bar2.id, bar2); + setBarSeriesAccessors(isHistogramEnabled, seriesMap); + expect(bar2.stackAccessors).toEqual(['y', 'bar']); + }); }); diff --git a/src/state/utils.ts b/src/state/utils.ts index ec62cb07ab..a5ab0a4ef6 100644 --- a/src/state/utils.ts +++ b/src/state/utils.ts @@ -28,6 +28,8 @@ import { AxisSpec, BasicSeriesSpec, DomainRange, + HistogramModeAlignment, + HistogramModeAlignments, isAreaSeriesSpec, isBarSeriesSpec, isLineSeriesSpec, @@ -151,6 +153,7 @@ export function computeSeriesGeometries( chartDims: Dimensions, chartRotation: Rotation, axesSpecs: Map, + enableHistogramMode: boolean, ): { scales: { xScale: Scale; @@ -166,7 +169,7 @@ export function computeSeriesGeometries( geometriesCounts: GeometriesCounts; } { const chartColors: ColorConfig = chartTheme.colors; - const barsPadding = chartTheme.scales.barsPadding; + const barsPadding = enableHistogramMode ? chartTheme.scales.histogramPadding : chartTheme.scales.barsPadding; const width = [0, 180].includes(chartRotation) ? chartDims.width : chartDims.height; const height = [0, 180].includes(chartRotation) ? chartDims.height : chartDims.width; @@ -217,6 +220,7 @@ export function computeSeriesGeometries( chartColors.defaultVizColor, axesSpecs, chartTheme, + enableHistogramMode, ); orderIndex = counts.barSeries > 0 ? orderIndex + 1 : orderIndex; areas.push(...geometries.areas); @@ -253,6 +257,7 @@ export function computeSeriesGeometries( chartColors.defaultVizColor, axesSpecs, chartTheme, + enableHistogramMode, ); areas.push(...geometries.areas); @@ -289,6 +294,63 @@ export function computeSeriesGeometries( }; } +export function setBarSeriesAccessors( + isHistogramMode: boolean, + seriesSpecs: Map, +): void { + if (!isHistogramMode) { + return; + } + + for (const [, spec] of seriesSpecs) { + if (isBarSeriesSpec(spec)) { + let stackAccessors = spec.stackAccessors ? [...spec.stackAccessors] : spec.yAccessors; + + if (spec.splitSeriesAccessors) { + stackAccessors = [...stackAccessors, ...spec.splitSeriesAccessors]; + } + + spec.stackAccessors = stackAccessors; + } + } + + return; +} + +export function isHistogramModeEnabled(seriesSpecs: Map): boolean { + for (const [, spec] of seriesSpecs) { + if (isBarSeriesSpec(spec) && spec.enableHistogramMode) { + return true; + } + } + return false; +} + +export function computeXScaleOffset( + xScale: Scale, + enableHistogramMode: boolean, + histogramModeAlignment: HistogramModeAlignment = HistogramModeAlignments.Start, +): number { + if (!enableHistogramMode) { + return 0; + } + + const { bandwidth, barsPadding } = xScale; + const band = bandwidth / (1 - barsPadding); + const halfPadding = (band - bandwidth) / 2; + + const startAlignmentOffset = (bandwidth / 2) + halfPadding; + + switch (histogramModeAlignment) { + case HistogramModeAlignments.Center: + return 0; + case HistogramModeAlignments.End: + return -startAlignmentOffset; + default: + return startAlignmentOffset; + } +} + export function renderGeometries( indexOffset: number, clusteredCount: number, @@ -301,6 +363,7 @@ export function renderGeometries( defaultColor: string, axesSpecs: Map, chartTheme: Theme, + enableHistogramMode: boolean, ): { points: PointGeometry[]; bars: BarGeometry[]; @@ -367,6 +430,13 @@ export function renderGeometries( } else if (isLineSeriesSpec(spec)) { const lineShift = clusteredCount > 0 ? clusteredCount : 1; const lineSeriesStyle = spec.lineSeriesStyle; + + const xScaleOffset = computeXScaleOffset( + xScale, + enableHistogramMode, + spec.histogramModeAlignment, + ); + const renderedLines = renderLine( // move the point on half of the bandwidth if we have mixed bars/lines (xScale.bandwidth * lineShift) / 2, @@ -378,6 +448,7 @@ export function renderGeometries( ds.specId, Boolean(spec.y0Accessors), ds.key, + xScaleOffset, lineSeriesStyle, ); lineGeometriesIndex = mergeGeometriesIndexes( @@ -390,6 +461,13 @@ export function renderGeometries( } else if (isAreaSeriesSpec(spec)) { const areaShift = clusteredCount > 0 ? clusteredCount : 1; const areaSeriesStyle = spec.areaSeriesStyle; + + const xScaleOffset = computeXScaleOffset( + xScale, + enableHistogramMode, + spec.histogramModeAlignment, + ); + const renderedAreas = renderArea( // move the point on half of the bandwidth if we have mixed bars/lines (xScale.bandwidth * areaShift) / 2, @@ -401,6 +479,7 @@ export function renderGeometries( ds.specId, Boolean(spec.y0Accessors), ds.key, + xScaleOffset, areaSeriesStyle, ); areaGeometriesIndex = mergeGeometriesIndexes( diff --git a/stories/annotations.tsx b/stories/annotations.tsx index 9fe7424679..bc53a92b44 100644 --- a/stories/annotations.tsx +++ b/stories/annotations.tsx @@ -294,18 +294,15 @@ storiesOf('Annotations', module) ); }) - .add('[rect] basic annotation (bar)', () => { - const dataValues = [ - { - coordinates: { - x0: 0, - x1: 1, - y0: 0, - y1: 7, - }, - details: 'details about this annotation', + .add('[rect] basic annotation (linear bar)', () => { + const dataValues = [{ + coordinates: { + x0: 0, + x1: 1, + y0: 0, + y1: 7, }, - ]; + }]; const chartRotation = select( 'chartRotation', @@ -335,6 +332,47 @@ storiesOf('Annotations', module) ); }) + .add('[rect] basic annotation (ordinal bar)', () => { + const dataValues = [{ + coordinates: { + x0: 'a', + x1: 'b.5', + }, + details: 'details about this annotation', + }]; + + const chartRotation = select( + 'chartRotation', + { + '0 deg': 0, + '90 deg': 90, + '-90 deg': -90, + '180 deg': 180, + }, + 0, + ); + + return ( + + + + + + + + ); + }) .add('[rect] basic annotation (line)', () => { const definedCoordinate = select( 'defined coordinate', diff --git a/stories/bar_chart.tsx b/stories/bar_chart.tsx index c38bb9d9b8..54042161b4 100644 --- a/stories/bar_chart.tsx +++ b/stories/bar_chart.tsx @@ -3,19 +3,26 @@ import { storiesOf } from '@storybook/react'; import { DateTime } from 'luxon'; import React from 'react'; import { + AnnotationDomainTypes, + AreaSeries, Axis, BarSeries, Chart, DARK_THEME, DataGenerator, + getAnnotationId, getAxisId, getGroupId, getSpecId, + HistogramBarSeries, + HistogramModeAlignments, LIGHT_THEME, + LineAnnotation, LineSeries, mergeWithDefaultTheme, niceTimeFormatByDay, Position, + RectAnnotation, Rotation, ScaleType, Settings, @@ -23,7 +30,9 @@ import { TooltipType, } from '../src/'; import * as TestDatasets from '../src/lib/series/utils/test_dataset'; + import { KIBANA_METRICS } from '../src/lib/series/utils/test_dataset_kibana'; + const dateFormatter = timeFormatter('HH:mm:ss'); const dataGen = new DataGenerator(); @@ -253,6 +262,12 @@ storiesOf('Bar Chart', module) const theme = { ...LIGHT_THEME, scales: { + histogramPadding: number('histogram padding', 0, { + range: true, + min: 0, + max: 1, + step: 0.01, + }), barsPadding: number('bar padding', 0, { range: true, min: 0, @@ -522,7 +537,13 @@ storiesOf('Bar Chart', module) const theme = { ...LIGHT_THEME, scales: { - barsPadding: number('bar padding', 0, { + histogramPadding: number('histogram padding', 0.05, { + range: true, + min: 0, + max: 1, + step: 0.01, + }), + barsPadding: number('bar padding', 0.25, { range: true, min: 0, max: 1, @@ -1418,6 +1439,220 @@ storiesOf('Bar Chart', module) ); }) + .add('[test] histogram mode (linear)', () => { + const data = TestDatasets.BARCHART_2Y1G; + + const lineAnnotationStyle = { + line: { + strokeWidth: 2, + stroke: '#c80000', + opacity: 0.3, + }, + }; + + const chartRotation = select( + 'chartRotation', + { + '0 deg': 0, + '90 deg': 90, + '-90 deg': -90, + '180 deg': 180, + }, + 0, + ); + + const theme = mergeWithDefaultTheme({ + scales: { + barsPadding: number('bars padding', 0.25, { + range: true, + min: 0, + max: 1, + step: 0.1, + }), + histogramPadding: number('histogram padding', 0.05, { + range: true, + min: 0, + max: 1, + step: 0.1, + }), + }, + }, LIGHT_THEME); + + const otherSeriesSelection = select( + 'other series', + { + line: 'line', + area: 'area', + }, + 'line', + ); + + const pointAlignment = select('point series alignment', HistogramModeAlignments, HistogramModeAlignments.Center); + const pointData = TestDatasets.BARCHART_1Y0G; + + const otherSeries = otherSeriesSelection === 'line' ? + : + ; + + const hasHistogramBarSeries = boolean('hasHistogramBarSeries', false); + + return ( + + + } + /> + + + + {hasHistogramBarSeries && } + + + {otherSeries} + + ); + }) + .add('[test] histogram mode (ordinal)', () => { + const data = [{ x: 'a', y: 2 }, { x: 'b', y: 7 }, { x: 'c', y: 0 }, { x: 'd', y: 6 }]; + + const chartRotation = select( + 'chartRotation', + { + '0 deg': 0, + '90 deg': 90, + '-90 deg': -90, + '180 deg': 180, + }, + 0, + ); + + const theme = mergeWithDefaultTheme({ + scales: { + barsPadding: number('bars padding', 0.25, { + range: true, + min: 0, + max: 1, + step: 0.1, + }), + }, + }, LIGHT_THEME); + + const hasHistogramBarSeries = boolean('hasHistogramBarSeries', false); + + return ( + + + + + {hasHistogramBarSeries && } + + + + ); + }) .add('stacked only grouped areas', () => { const data1 = [[1, 2], [2, 2], [3, 3], [4, 5], [5, 5], [6, 3], [7, 8], [8, 2], [9, 1]]; const data2 = [[1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 4], [7, 3], [8, 2], [9, 4]];