From 2a64288009df7f9d79ce66bd6d8e610634f130e2 Mon Sep 17 00:00:00 2001 From: nickofthyme Date: Tue, 2 Jul 2019 08:10:49 -0500 Subject: [PATCH] feat(bar_chart): allow grouping of datums within series Allows for grouping of datums within a series to enable global groups. Allow custom colors of groupings. BREAKING CHANGE: Color accesors is replaced with groupAccessors. DataSeriesColorsValues renamed to DataSeriesValues, colorValues renamed to accessors and added group. #216 --- src/lib/series/domains/y_domain.ts | 26 +-- src/lib/series/nonstacked_series_utils.ts | 58 ++++--- src/lib/series/rendering.ts | 7 +- src/lib/series/series.ts | 42 +++-- src/lib/series/specs.ts | 4 +- src/lib/series/stacked_series_utils.ts | 112 +++++++------ src/lib/utils/accessor.ts | 7 +- src/state/utils.ts | 186 +++++++++++----------- stories/styling.tsx | 6 +- 9 files changed, 253 insertions(+), 195 deletions(-) diff --git a/src/lib/series/domains/y_domain.ts b/src/lib/series/domains/y_domain.ts index 6849bdeed9..3014683fca 100644 --- a/src/lib/series/domains/y_domain.ts +++ b/src/lib/series/domains/y_domain.ts @@ -105,11 +105,13 @@ export function getDataSeriesOnGroup( function computeYStackedDomain(dataseries: RawDataSeries[], scaleToExtent: boolean): number[] { const stackMap = new Map(); dataseries.forEach((ds, index) => { - ds.data.forEach((datum) => { - const stack = stackMap.get(datum.x) || []; - stack[index] = datum.y1; - stackMap.set(datum.x, stack); - }); + for (const [group, data] of ds.data) { + data.forEach((datum) => { + const stack = stackMap.get(datum.x) || []; + stack[index] = datum.y1; + stackMap.set(datum.x, stack); + }); + } }); const dataValues = []; for (const stackValues of stackMap) { @@ -126,12 +128,14 @@ function computeYStackedDomain(dataseries: RawDataSeries[], scaleToExtent: boole function computeYNonStackedDomain(dataseries: RawDataSeries[], scaleToExtent: boolean) { const yValues = new Set(); dataseries.forEach((ds) => { - ds.data.forEach((datum) => { - yValues.add(datum.y1); - if (datum.y0 != null) { - yValues.add(datum.y0); - } - }); + for (const [group, data] of ds.data) { + data.forEach((datum) => { + yValues.add(datum.y1); + if (datum.y0 != null) { + yValues.add(datum.y0); + } + }); + } }); if (yValues.size === 0) { return []; diff --git a/src/lib/series/nonstacked_series_utils.ts b/src/lib/series/nonstacked_series_utils.ts index c62e7a02bf..cda955a3b9 100644 --- a/src/lib/series/nonstacked_series_utils.ts +++ b/src/lib/series/nonstacked_series_utils.ts @@ -8,41 +8,55 @@ export function formatNonStackedDataSeriesValues(dataseries: RawDataSeries[], sc const formattedValue = formatNonStackedDataValues(dataseries[i], scaleToExtent); formattedValues.push(formattedValue); } + return formattedValues; } export function formatNonStackedDataValues(dataSeries: RawDataSeries, scaleToExtent: boolean): DataSeries { - const len = dataSeries.data.length; - let i; const formattedValues: DataSeries = { keys: dataSeries.keys, specId: dataSeries.specId, seriesKey: dataSeries.seriesKey, - data: [], + data: new Map(), }; - for (i = 0; i < len; i++) { - const data = dataSeries.data[i]; - const { x, y1, datum } = data; - let y0: number | null; - if (y1 === null) { - y0 = null; - } else { - if (scaleToExtent) { - y0 = data.y0 ? data.y0 : y1; + + for (const [group, series] of dataSeries.data) { + const len = series.length; + const formatted = []; + + for (let i = 0; i < len; i++) { + const data = series[i]; + const { x, y1, datum, xAccessor, yAccessor } = data; + let y0: number | null; + if (y1 === null) { + y0 = null; } else { - y0 = data.y0 ? data.y0 : 0; + if (scaleToExtent) { + y0 = data.y0 ? data.y0 : y1; + } else { + y0 = data.y0 ? data.y0 : 0; + } } + + const formattedValue: DataSeriesDatum = { + x, + y1, + y0, + xAccessor, + yAccessor, + initialY1: y1, + initialY0: data.y0 == null || y1 === null ? null : data.y0, + datum, + }; + formatted.push(formattedValue); + } + + if (!formattedValues.data.has(group)) { + formattedValues.data.set(group, []); } - const formattedValue: DataSeriesDatum = { - x, - y1, - y0, - initialY1: y1, - initialY0: data.y0 == null || y1 === null ? null : data.y0, - datum, - }; - formattedValues.data.push(formattedValue); + formattedValues.data.get(group)!.push(...formatted); } + return formattedValues; } diff --git a/src/lib/series/rendering.ts b/src/lib/series/rendering.ts index c715dbfe53..a5e534d13d 100644 --- a/src/lib/series/rendering.ts +++ b/src/lib/series/rendering.ts @@ -14,7 +14,7 @@ import { isLogarithmicScale } from '../utils/scales/scale_continuous'; import { Scale, ScaleType } from '../utils/scales/scales'; import { CurveType, getCurveFactory } from './curves'; import { LegendItem } from './legend'; -import { DataSeriesDatum } from './series'; +import { DataSeriesDatum, RawDataSeriesDatum } from './series'; import { belongsToDataSeries } from './series_utils'; import { DisplayValueSpec } from './specs'; import { Accessor, getAccessorValues, AccessorFn } from '../utils/accessor'; @@ -209,7 +209,7 @@ export function renderBars( seriesKey: any[], displayValueSettings?: DisplayValueSpec, seriesStyle?: CustomBarSeriesStyle, - groupAccessors?: (Accessor | AccessorFn)[], + groupAccessors?: (Accessor | AccessorFn)[], ): RenderBarsGeometry { const indexedGeometries = new Map(); const xDomain = xScale.domain; @@ -284,9 +284,8 @@ export function renderBars( : undefined; if (groupAccessors) { - const [colorGroup] = getAccessorValues(datum!.datum, groupAccessors); + const [colorGroup] = getAccessorValues(datum, groupAccessors); if (colorGroup) { - console.log(colorGroup); color = colorGroup; } } diff --git a/src/lib/series/series.ts b/src/lib/series/series.ts index bfb168a624..e3a77255d1 100644 --- a/src/lib/series/series.ts +++ b/src/lib/series/series.ts @@ -15,6 +15,10 @@ export interface RawDataSeriesDatum { y1: number | null; /** the optional y0 metric, used for bars or area with a lower bound */ y0?: number | null; + /** main(x) accessor value */ + xAccessor: Accessor; + /** secondary(y) accessor value */ + yAccessor: Accessor; /** the datum */ datum?: any; } @@ -36,13 +40,13 @@ export interface RawDataSeries { specId: SpecId; keys: any[]; seriesKey: string; - data: RawDataSeriesDatum[]; + data: Map; } export type DataSeries = Merge< RawDataSeries, { - data: DataSeriesDatum[]; + data: Map; } >; @@ -66,6 +70,8 @@ export interface DataSeriesValues { specSortIndex?: number; } +export const BASE_GROUP_KEY = '__base__'; + export function getSeriesIndex(series: DataSeriesValues[] | null, value: DataSeriesValues): number { if (!series) { return -1; @@ -97,28 +103,24 @@ export function splitSeries( data.forEach((datum) => { const splitSeriesKeys = getAccessorValues(datum, splitSeriesAccessors); - const [groupKey] = getAccessorValues(datum, groupAccessors); if (isMultipleY) { yAccessors.forEach((accessor, index) => { const seriesKeys = [...splitSeriesKeys, accessor]; const seriesKey = getSeriesKey(specId, seriesKeys); - - console.log('nick - 1', seriesKey); - const cleanedDatum = cleanDatum(datum, xAccessor, accessor, y0Accessors && y0Accessors[index]); + const [groupKey] = getAccessorValues(cleanedDatum, groupAccessors); splitSeriesLastValues.set(seriesKey, cleanedDatum.y1); xValues.add(cleanedDatum.x); - updateSeriesMap(series, seriesKeys, seriesKey, cleanedDatum, specId); + updateSeriesMap(series, seriesKeys, seriesKey, cleanedDatum, specId, groupKey); }); } else { const seriesKey = getSeriesKey(specId, splitSeriesKeys); - - console.log('nick - 2', seriesKey); const cleanedDatum = cleanDatum(datum, xAccessor, yAccessors[0], y0Accessors && y0Accessors[0]); + const [groupKey] = getAccessorValues(cleanedDatum, groupAccessors); splitSeriesLastValues.set(seriesKey, cleanedDatum.y1); xValues.add(cleanedDatum.x); - updateSeriesMap(series, splitSeriesKeys, seriesKey, cleanedDatum, specId); + updateSeriesMap(series, splitSeriesKeys, seriesKey, cleanedDatum, specId, groupKey); } }); @@ -139,16 +141,23 @@ function updateSeriesMap( seriesKey: string, datum: RawDataSeriesDatum, specId: SpecId, + groupKey: string = BASE_GROUP_KEY, ): Map { const series = seriesMap.get(seriesKey); if (series) { - series.data.push(datum); + if (series.data.has(groupKey)) { + series.data.get(groupKey)!.push(datum); + } else { + series.data.set(groupKey, [datum]); + } } else { + const data = new Map(); + data.set(groupKey, [datum]); seriesMap.set(seriesKey, { specId, keys, seriesKey, - data: [datum], + data, }); } return seriesMap; @@ -167,7 +176,14 @@ export function getSeriesKey(specId?: SpecId, accessors: any[] = []): string { function cleanDatum(datum: Datum, xAccessor: Accessor, yAccessor: Accessor, y0Accessor?: Accessor): RawDataSeriesDatum { const x = datum[xAccessor]; const y1 = datum[yAccessor]; - const cleanedDatum: RawDataSeriesDatum = { x, y1, datum, y0: null }; + const cleanedDatum: RawDataSeriesDatum = { + x, + y1, + datum, + xAccessor, + yAccessor, + y0: null, + }; if (y0Accessor) { cleanedDatum.y0 = datum[y0Accessor]; } diff --git a/src/lib/series/specs.ts b/src/lib/series/specs.ts index 5f6c53f4b7..938cc7340c 100644 --- a/src/lib/series/specs.ts +++ b/src/lib/series/specs.ts @@ -11,7 +11,7 @@ import { Omit } from '../utils/commons'; import { AnnotationId, AxisId, GroupId, SpecId } from '../utils/ids'; import { ScaleContinuousType, ScaleType } from '../utils/scales/scales'; import { CurveType } from './curves'; -import { DataSeriesValues } from './series'; +import { DataSeriesValues, RawDataSeriesDatum } from './series'; export type Datum = any; export type Rotation = 0 | 90 | -90 | 180; @@ -97,7 +97,7 @@ export interface SeriesAccessors { /** An array of fields thats indicates the stack membership */ stackAccessors?: Accessor[]; /** An optional array of field name thats indicates the color grouping */ - groupAccessors?: (Accessor | AccessorFn)[]; + groupAccessors?: (Accessor | AccessorFn)[]; } export interface SeriesScales { diff --git a/src/lib/series/stacked_series_utils.ts b/src/lib/series/stacked_series_utils.ts index d801d45c2e..1f98fec433 100644 --- a/src/lib/series/stacked_series_utils.ts +++ b/src/lib/series/stacked_series_utils.ts @@ -8,11 +8,13 @@ import { DataSeries, DataSeriesDatum, RawDataSeries } from './series'; export function getYValueStackMap(dataseries: RawDataSeries[]): Map { const stackMap = new Map(); dataseries.forEach((ds, index) => { - ds.data.forEach((datum) => { - const stack = stackMap.get(datum.x) || new Array(dataseries.length).fill(0); - stack[index] = datum.y1; - stackMap.set(datum.x, stack); - }); + for (const [group, data] of ds.data) { + data.forEach((datum) => { + const stack = stackMap.get(datum.x) || new Array(dataseries.length).fill(0); + stack[index] = datum.y1; + stackMap.set(datum.x, stack); + }); + } }); return stackMap; } @@ -53,51 +55,67 @@ export function formatStackedDataSeriesValues(dataseries: RawDataSeries[], scale const stackedValues = computeYStackedMapValues(yValueStackMap, scaleToExtent); const stackedDataSeries: DataSeries[] = dataseries.map((ds, seriesIndex) => { - const newData: DataSeriesDatum[] = []; - ds.data.forEach((data) => { - const { x, y1, datum } = data; - if (stackedValues.get(x) === undefined) { - return; - } - let computedY0: number | null; - if (scaleToExtent) { - computedY0 = data.y0 ? data.y0 : y1; - } else { - computedY0 = data.y0 ? data.y0 : 0; - } - const initialY0 = data.y0 == null ? null : data.y0; - if (seriesIndex === 0) { - newData.push({ - x, - y1, - y0: computedY0, - initialY1: y1, - initialY0, - datum, - }); - } else { - const stack = stackedValues.get(x); - if (!stack) { + const newData = new Map(); + for (const [group, series] of ds.data) { + series.forEach((data) => { + const { x, y1, datum, xAccessor, yAccessor } = data; + if (stackedValues.get(x) === undefined) { return; } - const stackY = stack[seriesIndex]; - const stackedY1 = y1 !== null ? stackY + y1 : null; - let stackedY0: number | null = data.y0 == null ? stackY : stackY + data.y0; - // configure null y0 if y1 is null - // it's semantically right to say y0 is null if y1 is null - if (stackedY1 === null) { - stackedY0 = null; + let computedY0: number | null; + if (scaleToExtent) { + computedY0 = data.y0 ? data.y0 : y1; + } else { + computedY0 = data.y0 ? data.y0 : 0; } - newData.push({ - x, - y1: stackedY1, - y0: stackedY0, - initialY1: y1, - initialY0, - datum, - }); - } - }); + const initialY0 = data.y0 == null ? null : data.y0; + if (seriesIndex === 0) { + if (!newData.has(group)) { + newData.set(group, []); + } + + newData.get(group)!.push({ + x, + y1, + xAccessor, + yAccessor, + y0: computedY0, + initialY1: y1, + initialY0, + datum, + }); + } else { + const stack = stackedValues.get(x); + if (!stack) { + return; + } + const stackY = stack[seriesIndex]; + const stackedY1 = y1 !== null ? stackY + y1 : null; + let stackedY0: number | null = data.y0 == null ? stackY : stackY + data.y0; + // configure null y0 if y1 is null + // it's semantically right to say y0 is null if y1 is null + if (stackedY1 === null) { + stackedY0 = null; + } + + if (!newData.has(group)) { + newData.set(group, []); + } + + newData.get(group)!.push({ + x, + xAccessor, + yAccessor, + y1: stackedY1, + y0: stackedY0, + initialY1: y1, + initialY0, + datum, + }); + } + }); + } + return { specId: ds.specId, keys: ds.keys, diff --git a/src/lib/utils/accessor.ts b/src/lib/utils/accessor.ts index 7a4781286d..fc463b1d26 100644 --- a/src/lib/utils/accessor.ts +++ b/src/lib/utils/accessor.ts @@ -1,6 +1,7 @@ import { Datum } from '../series/specs'; +import { RawDataSeriesDatum } from '../series/series'; -export type AccessorFn = (datum: Datum) => any; +export type AccessorFn = (cleanedDatum: T, datum?: any) => any; export type AccessorString = string | number; export type Accessor = AccessorString; @@ -21,8 +22,8 @@ export function getAccessorFn(accessor: Accessor): AccessorFn { /** * Get array of values that from accessors */ -export function getAccessorValues(datum: Datum, accessors: (Accessor | AccessorFn)[] = []): any[] { +export function getAccessorValues(datum: Datum, accessors: (Accessor | AccessorFn)[] = []): any[] { return accessors - .map((accessor) => (typeof accessor === 'function' ? accessor(datum) : datum[accessor])) + .map((accessor) => (typeof accessor === 'function' ? accessor(datum, datum.datum) : datum[accessor])) .filter((value) => value !== undefined && value !== null); } diff --git a/src/state/utils.ts b/src/state/utils.ts index 194462bb39..9057f5022f 100644 --- a/src/state/utils.ts +++ b/src/state/utils.ts @@ -88,8 +88,8 @@ export function getUpdatedCustomSeriesColors(seriesSpecs: Map { if (spec.customSeriesColors) { spec.customSeriesColors.forEach((color: string, seriesColorValues: DataSeriesValues) => { - const { specId, accessors, group } = seriesColorValues; - const seriesLabel = getSeriesKey(specId, accessors, group); + const { specId, accessors } = seriesColorValues; + const seriesLabel = getSeriesKey(specId, accessors); updatedCustomSeriesColors.set(seriesLabel, color); }); } @@ -375,94 +375,97 @@ export function renderGeometries( if (spec === undefined) { continue; } - const color = seriesColorsMap.get(ds.seriesKey) || defaultColor; - if (isBarSeriesSpec(spec)) { - const shift = isStacked ? indexOffset : indexOffset + i; - - // TODO: we can handle style merging here and not pass that off to the component - // then barSeriesStyle should not be an optional parameter and we can simplify - // the props building in the geometries component - const barSeriesStyle = spec.barSeriesStyle - ? { - ...chartTheme.barSeriesStyle, - ...spec.barSeriesStyle, - } - : chartTheme.barSeriesStyle; - - const { yAxis } = getAxesSpecForSpecId(axesSpecs, spec.groupId); - const valueFormatter = yAxis && yAxis.tickFormat ? yAxis.tickFormat : identity; - - const displayValueSettings = spec.displayValueSettings - ? { - valueFormatter, - ...spec.displayValueSettings, - } - : undefined; - - const renderedBars = renderBars( - shift, - ds.data, - xScale, - yScale, - color, - ds.specId, - ds.keys, - displayValueSettings, - barSeriesStyle, - spec.groupAccessors, - ); - barGeometriesIndex = mergeGeometriesIndexes(barGeometriesIndex, renderedBars.indexedGeometries); - bars.push(...renderedBars.barGeometries); - geometriesCounts.bars += renderedBars.barGeometries.length; - } 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, - ds.data, - xScale, - yScale, - color, - (spec as LineSeriesSpec).curve || CurveType.LINEAR, - ds.specId, - Boolean(spec.y0Accessors), - ds.keys, - xScaleOffset, - lineSeriesStyle, - ); - lineGeometriesIndex = mergeGeometriesIndexes(lineGeometriesIndex, renderedLines.indexedGeometries); - lines.push(renderedLines.lineGeometry); - geometriesCounts.linePoints += renderedLines.lineGeometry.points.length; - geometriesCounts.lines += 1; - } 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, - ds.data, - xScale, - yScale, - color, - (spec as AreaSeriesSpec).curve || CurveType.LINEAR, - ds.specId, - Boolean(spec.y0Accessors), - ds.keys, - xScaleOffset, - areaSeriesStyle, - ); - areaGeometriesIndex = mergeGeometriesIndexes(areaGeometriesIndex, renderedAreas.indexedGeometries); - areas.push(renderedAreas.areaGeometry); - geometriesCounts.areasPoints += renderedAreas.areaGeometry.points.length; - geometriesCounts.areas += 1; + for (const [group, data] of ds.data) { + const color = seriesColorsMap.get(ds.seriesKey) || 'blue'; + + if (isBarSeriesSpec(spec)) { + const shift = isStacked ? indexOffset : indexOffset + i; + + // TODO: we can handle style merging here and not pass that off to the component + // then barSeriesStyle should not be an optional parameter and we can simplify + // the props building in the geometries component + const barSeriesStyle = spec.barSeriesStyle + ? { + ...chartTheme.barSeriesStyle, + ...spec.barSeriesStyle, + } + : chartTheme.barSeriesStyle; + + const { yAxis } = getAxesSpecForSpecId(axesSpecs, spec.groupId); + const valueFormatter = yAxis && yAxis.tickFormat ? yAxis.tickFormat : identity; + + const displayValueSettings = spec.displayValueSettings + ? { + valueFormatter, + ...spec.displayValueSettings, + } + : undefined; + + const renderedBars = renderBars( + shift, + data, + xScale, + yScale, + color, + ds.specId, + ds.keys, + displayValueSettings, + barSeriesStyle, + spec.groupAccessors, + ); + barGeometriesIndex = mergeGeometriesIndexes(barGeometriesIndex, renderedBars.indexedGeometries); + bars.push(...renderedBars.barGeometries); + geometriesCounts.bars += renderedBars.barGeometries.length; + } 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, + data, + xScale, + yScale, + color, + (spec as LineSeriesSpec).curve || CurveType.LINEAR, + ds.specId, + Boolean(spec.y0Accessors), + ds.keys, + xScaleOffset, + lineSeriesStyle, + ); + lineGeometriesIndex = mergeGeometriesIndexes(lineGeometriesIndex, renderedLines.indexedGeometries); + lines.push(renderedLines.lineGeometry); + geometriesCounts.linePoints += renderedLines.lineGeometry.points.length; + geometriesCounts.lines += 1; + } 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, + data, + xScale, + yScale, + color, + (spec as AreaSeriesSpec).curve || CurveType.LINEAR, + ds.specId, + Boolean(spec.y0Accessors), + ds.keys, + xScaleOffset, + areaSeriesStyle, + ); + areaGeometriesIndex = mergeGeometriesIndexes(areaGeometriesIndex, renderedAreas.indexedGeometries); + areas.push(renderedAreas.areaGeometry); + geometriesCounts.areasPoints += renderedAreas.areaGeometry.points.length; + geometriesCounts.areas += 1; + } } } const geometriesIndex = mergeGeometriesIndexes( @@ -471,6 +474,7 @@ export function renderGeometries( areaGeometriesIndex, barGeometriesIndex, ); + return { points, bars, @@ -583,9 +587,7 @@ export function isVerticalRotation(chartRotation: Rotation) { * @param specs Map */ export function isLineAreaOnlyChart(specs: Map) { - return ![...specs.values()].some((spec) => { - return spec.seriesType === 'bar'; - }); + return ![...specs.values()].some((spec) => spec.seriesType === 'bar'); } export function isChartAnimatable(geometriesCounts: GeometriesCounts, animationEnabled: boolean): boolean { diff --git a/stories/styling.tsx b/stories/styling.tsx index 4acbe20423..ce2d594061 100644 --- a/stories/styling.tsx +++ b/stories/styling.tsx @@ -450,7 +450,11 @@ storiesOf('Stylings', module) xAccessor="x" yAccessors={['y1', 'y2']} splitSeriesAccessors={['g1', 'g2']} - groupAccessors={['color']} + groupAccessors={[ + (d) => { + return d && d.yAccessor === 'y1' && d.y1 && d.y1 > 6 ? 'nick' : null; + }, + ]} data={TestDatasets.BARCHART_2Y2G} /> {/*