diff --git a/src/components/react_canvas/area_geometries.tsx b/src/components/react_canvas/area_geometries.tsx index 26de28bad6..88c9939bf0 100644 --- a/src/components/react_canvas/area_geometries.tsx +++ b/src/components/react_canvas/area_geometries.tsx @@ -9,6 +9,7 @@ import { buildAreaLineProps, buildAreaPointProps, buildAreaProps, + buildPointStyleProps, } from './utils/rendering_props_utils'; interface AreaGeometriesDataProps { @@ -24,7 +25,7 @@ interface AreaGeometriesDataState { export class AreaGeometries extends React.PureComponent< AreaGeometriesDataProps, AreaGeometriesDataState -> { + > { static defaultProps: Partial = { animated: false, }; @@ -41,29 +42,47 @@ export class AreaGeometries extends React.PureComponent< return ( - {area.visible && this.renderAreaGeoms()} - {line.visible && this.renderAreaLines()} - {point.visible && this.renderAreaPoints()} + {this.renderAreaGeoms(area.visible)} + {this.renderAreaLines(line.visible)} + {this.renderAreaPoints(point.visible)} ); } - private renderAreaPoints = (): JSX.Element[] => { + private renderAreaPoints = (themeIsVisible: boolean): JSX.Element[] => { const { areas } = this.props; return areas.reduce( (acc, glyph, i) => { - const { points } = glyph; - return [...acc, ...this.renderPoints(points, i)]; + const { points, seriesPointStyle } = glyph; + + const isVisible = seriesPointStyle ? seriesPointStyle.visible : themeIsVisible; + if (!isVisible) { + return acc; + } + + const { radius, strokeWidth, opacity } = this.props.style.point; + const pointStyleProps = buildPointStyleProps({ + radius, + strokeWidth, + opacity, + seriesPointStyle, + }); + + return [...acc, ...this.renderPoints(points, i, pointStyleProps)]; }, [] as JSX.Element[], ); } - private renderPoints = (areaPoints: PointGeometry[], areaIndex: number): JSX.Element[] => { - const { radius, strokeWidth, opacity } = this.props.style.point; - - return areaPoints.map((areaPoint, pointIndex) => { + private renderPoints = ( + areaPoints: PointGeometry[], + areaIndex: number, + pointStyleProps: any, + ): JSX.Element[] => { + const areaPointElements: JSX.Element[] = []; + areaPoints.forEach((areaPoint, pointIndex) => { const { x, y, color, transform } = areaPoint; + if (this.props.animated) { - return ( + areaPointElements.push( {(props: { y: number }) => { @@ -72,41 +91,42 @@ export class AreaGeometries extends React.PureComponent< pointIndex, x, y, - radius, - strokeWidth, color, - opacity, + pointStyleProps, }); return ; }} - - ); + ); } else { const pointProps = buildAreaPointProps({ areaIndex, pointIndex, x: transform.x + x, y, - radius, - strokeWidth, color, - opacity, + pointStyleProps, }); - return ; + areaPointElements.push(); } }); + return areaPointElements; } - private renderAreaGeoms = (): JSX.Element[] => { + private renderAreaGeoms = (themeIsVisible: boolean): JSX.Element[] => { const { areas } = this.props; const { opacity } = this.props.style.area; + const areasToRender: JSX.Element[] = []; - return areas.map((glyph, i) => { - const { area, color, transform } = glyph; + areas.forEach((glyph, i) => { + const { area, color, transform, seriesAreaStyle } = glyph; + const isVisible = seriesAreaStyle ? seriesAreaStyle.visible : themeIsVisible; + if (!isVisible) { + return; + } if (this.props.animated) { - return ( + areasToRender.push( {(props: { area: string }) => { @@ -115,34 +135,43 @@ export class AreaGeometries extends React.PureComponent< areaPath: props.area, color, opacity, + seriesAreaStyle, }); return ; }} - - ); + ); } else { const areaProps = buildAreaProps({ index: i, areaPath: area, color, opacity, + seriesAreaStyle, }); - return ; + areasToRender.push(); } }); + return areasToRender; } - private renderAreaLines = (): JSX.Element[] => { + private renderAreaLines = (themeIsVisible: boolean): JSX.Element[] => { const { areas, sharedStyle } = this.props; const { strokeWidth } = this.props.style.line; const linesToRender: JSX.Element[] = []; areas.forEach((glyph, areaIndex) => { - const { lines, color, geometryId } = glyph; + const { lines, color, geometryId, seriesAreaLineStyle } = glyph; + const isVisible = seriesAreaLineStyle ? seriesAreaLineStyle.visible : themeIsVisible; + if (!isVisible) { + return; + } + + const customOpacity = seriesAreaLineStyle ? seriesAreaLineStyle.opacity : undefined; const geometryStyle = getGeometryStyle( geometryId, this.props.highlightedLegendItem, sharedStyle, + customOpacity, ); lines.forEach((linePath, lineIndex) => { @@ -153,6 +182,7 @@ export class AreaGeometries extends React.PureComponent< color, strokeWidth, geometryStyle, + seriesAreaLineStyle, }); linesToRender.push(); }); diff --git a/src/components/react_canvas/bar_geometries.tsx b/src/components/react_canvas/bar_geometries.tsx index d088ad03f5..ca834179e3 100644 --- a/src/components/react_canvas/bar_geometries.tsx +++ b/src/components/react_canvas/bar_geometries.tsx @@ -20,7 +20,7 @@ interface BarGeometriesDataState { export class BarGeometries extends React.PureComponent< BarGeometriesDataProps, BarGeometriesDataState -> { + > { static defaultProps: Partial = { animated: false, }; @@ -44,11 +44,13 @@ export class BarGeometries extends React.PureComponent< private renderBarGeoms = (bars: BarGeometry[]): JSX.Element[] => { const { overBar } = this.state; const { - style: { border }, + style, sharedStyle, } = this.props; return bars.map((bar, index) => { - const { x, y, width, height, color } = bar; + const { x, y, width, height, color, seriesStyle } = bar; + const border = seriesStyle ? seriesStyle.border : style.border; + const customOpacity = seriesStyle ? seriesStyle.opacity : undefined; // Properties to determine if we need to highlight individual bars depending on hover state const hasGeometryHover = overBar != null; @@ -62,6 +64,7 @@ export class BarGeometries extends React.PureComponent< bar.geometryId, this.props.highlightedLegendItem, sharedStyle, + customOpacity, individualHighlight, ); diff --git a/src/components/react_canvas/line_geometries.tsx b/src/components/react_canvas/line_geometries.tsx index 62d35438ec..147cba0af4 100644 --- a/src/components/react_canvas/line_geometries.tsx +++ b/src/components/react_canvas/line_geometries.tsx @@ -5,7 +5,12 @@ import { animated, Spring } from 'react-spring/renderprops-konva.cjs'; import { LegendItem } from '../../lib/series/legend'; import { getGeometryStyle, LineGeometry, PointGeometry } from '../../lib/series/rendering'; import { LineSeriesStyle, SharedGeometryStyle } from '../../lib/themes/theme'; -import { buildLinePointProps, buildLineProps } from './utils/rendering_props_utils'; +import { + buildLinePointProps, + buildLineProps, + buildPointStyleProps, + PointStyleProps, +} from './utils/rendering_props_utils'; interface LineGeometriesDataProps { animated?: boolean; @@ -20,7 +25,7 @@ interface LineGeometriesDataState { export class LineGeometries extends React.PureComponent< LineGeometriesDataProps, LineGeometriesDataState -> { + > { static defaultProps: Partial = { animated: false, }; @@ -38,30 +43,48 @@ export class LineGeometries extends React.PureComponent< return ( - {line.visible && this.renderLineGeoms()} - {point.visible && this.renderLinePoints()} + {this.renderLineGeoms(line.visible)} + {this.renderLinePoints(point.visible)} ); } - private renderLinePoints = (): JSX.Element[] => { + private renderLinePoints = (themeIsVisible: boolean): JSX.Element[] => { const { lines } = this.props; return lines.reduce( (acc, glyph, i) => { - const { points } = glyph; - return [...acc, ...this.renderPoints(points, i)]; + const { points, seriesPointStyle } = glyph; + + const isVisible = seriesPointStyle ? seriesPointStyle.visible : themeIsVisible; + if (!isVisible) { + return acc; + } + + const { radius, strokeWidth, opacity } = this.props.style.point; + const pointStyleProps = buildPointStyleProps({ + radius, + strokeWidth, + opacity, + seriesPointStyle, + }); + + return [...acc, ...this.renderPoints(points, i, pointStyleProps)]; }, [] as JSX.Element[], ); } - private renderPoints = (linePoints: PointGeometry[], lineIndex: number): JSX.Element[] => { - const { radius, strokeWidth, opacity } = this.props.style.point; - - return linePoints.map((linePoint, pointIndex) => { + private renderPoints = ( + linePoints: PointGeometry[], + lineIndex: number, + pointStyleProps: PointStyleProps, + ): JSX.Element[] => { + const linePointsElements: JSX.Element[] = []; + linePoints.forEach((linePoint, pointIndex) => { const { x, y, color, transform } = linePoint; + if (this.props.animated) { - return ( + linePointsElements.push( {(props: { y: number }) => { @@ -70,46 +93,53 @@ export class LineGeometries extends React.PureComponent< pointIndex, x, y, - radius, color, - strokeWidth, - opacity, + pointStyleProps, }); return ; }} - - ); + ); } else { const pointProps = buildLinePointProps({ lineIndex, pointIndex, x: transform.x + x, y, - radius, color, - strokeWidth, - opacity, + pointStyleProps, }); - return ; + linePointsElements.push(); } }); + return linePointsElements; } - private renderLineGeoms = (): JSX.Element[] => { + private renderLineGeoms = (themeIsVisible: boolean): JSX.Element[] => { const { style, lines, sharedStyle } = this.props; const { strokeWidth } = style.line; - return lines.map((glyph, index) => { - const { line, color, transform, geometryId } = glyph; + + const lineElements: JSX.Element[] = []; + + lines.forEach((glyph, index) => { + const { line, color, transform, geometryId, seriesLineStyle } = glyph; + const isVisible = seriesLineStyle ? seriesLineStyle.visible : themeIsVisible; + + if (!isVisible) { + return; + } + + const customOpacity = seriesLineStyle ? seriesLineStyle.opacity : undefined; const geometryStyle = getGeometryStyle( geometryId, this.props.highlightedLegendItem, sharedStyle, + customOpacity, ); if (this.props.animated) { - return ( + lineElements.push( {(props: { opacity: number }) => { @@ -118,25 +148,26 @@ export class LineGeometries extends React.PureComponent< linePath: line, color, strokeWidth, - opacity: props.opacity, geometryStyle, + seriesLineStyle, }); return ; }} - - ); + ); } else { const lineProps = buildLineProps({ index, linePath: line, color, strokeWidth, - opacity: 1, geometryStyle, + seriesLineStyle, }); - return ; + lineElements.push(); } }); + + return lineElements; } } diff --git a/src/components/react_canvas/utils/rendering_props_utils.test.ts b/src/components/react_canvas/utils/rendering_props_utils.test.ts index 511effb120..f1a4adc2eb 100644 --- a/src/components/react_canvas/utils/rendering_props_utils.test.ts +++ b/src/components/react_canvas/utils/rendering_props_utils.test.ts @@ -5,19 +5,24 @@ import { buildBarProps, buildLinePointProps, buildLineProps, + buildPointStyleProps, } from './rendering_props_utils'; describe('[canvas] Area Geometries props', () => { test('can build area point props', () => { + const pointStyleProps = buildPointStyleProps({ + radius: 30, + strokeWidth: 2, + opacity: 0.5, + }); + const props = buildAreaPointProps({ areaIndex: 1, pointIndex: 2, x: 10, y: 20, - radius: 30, - strokeWidth: 2, color: 'red', - opacity: 0.5, + pointStyleProps, }); expect(props).toEqual({ key: 'area-point-1-2', @@ -34,15 +39,19 @@ describe('[canvas] Area Geometries props', () => { listening: false, }); + const noStrokePointStyleProps = buildPointStyleProps({ + radius: 30, + strokeWidth: 0, + opacity: 0.5, + }); + const propsNoStroke = buildAreaPointProps({ areaIndex: 1, pointIndex: 2, x: 10, y: 20, - radius: 30, - strokeWidth: 0, color: 'red', - opacity: 0.5, + pointStyleProps: noStrokePointStyleProps, }); expect(propsNoStroke).toEqual({ key: 'area-point-1-2', @@ -58,6 +67,41 @@ describe('[canvas] Area Geometries props', () => { perfectDrawEnabled: false, listening: false, }); + + const seriesPointStyleProps = buildPointStyleProps({ + radius: 30, + strokeWidth: 2, + opacity: 0.5, + seriesPointStyle: { + radius: 123, + stroke: 'series-stroke', + strokeWidth: 456, + opacity: 789, + visible: true, + }, + }); + const seriesPointStyle = buildAreaPointProps({ + areaIndex: 1, + pointIndex: 2, + x: 10, + y: 20, + color: 'red', + pointStyleProps: seriesPointStyleProps, + }); + expect(seriesPointStyle).toEqual({ + key: 'area-point-1-2', + x: 10, + y: 20, + radius: 123, + strokeWidth: 456, + strokeEnabled: true, + stroke: 'red', + fill: 'white', + opacity: 789, + strokeHitEnabled: false, + perfectDrawEnabled: false, + listening: false, + }); }); test('can build area path props', () => { const props = buildAreaProps({ @@ -77,6 +121,29 @@ describe('[canvas] Area Geometries props', () => { perfectDrawEnabled: false, listening: false, }); + + const seriesAreaStyle = buildAreaProps({ + index: 1, + areaPath: 'M0,0L10,10Z', + color: 'red', + opacity: 0.5, + seriesAreaStyle: { + opacity: 123, + fill: '', + visible: true, + }, + }); + expect(seriesAreaStyle).toEqual({ + key: 'area-1', + data: 'M0,0L10,10Z', + fill: 'red', + lineCap: 'round', + lineJoin: 'round', + opacity: 123, + strokeHitEnabled: false, + perfectDrawEnabled: false, + listening: false, + }); }); test('can build area line path props', () => { const props = buildAreaLineProps({ @@ -102,20 +169,53 @@ describe('[canvas] Area Geometries props', () => { listening: false, }); expect(props.fill).toBeFalsy(); + + const seriesLineStyle = buildAreaLineProps({ + areaIndex: 1, + lineIndex: 2, + linePath: 'M0,0L10,10Z', + color: 'red', + strokeWidth: 1, + geometryStyle: { + opacity: 0.5, + }, + seriesAreaLineStyle: { + opacity: 0.5, + stroke: 'series-stroke', + strokeWidth: 66, + visible: true, + }, + }); + expect(seriesLineStyle).toEqual({ + key: `area-1-line-2`, + data: 'M0,0L10,10Z', + stroke: 'red', + strokeWidth: 66, + lineCap: 'round', + lineJoin: 'round', + opacity: 0.5, + strokeHitEnabled: false, + perfectDrawEnabled: false, + listening: false, + }); }); }); describe('[canvas] Line Geometries', () => { test('can build line point props', () => { + const pointStyleProps = buildPointStyleProps({ + radius: 30, + strokeWidth: 2, + opacity: 0.5, + }); + const props = buildLinePointProps({ lineIndex: 1, pointIndex: 2, x: 10, y: 20, - radius: 30, - strokeWidth: 2, color: 'red', - opacity: 0.5, + pointStyleProps, }); expect(props).toEqual({ key: 'line-point-1-2', @@ -132,15 +232,18 @@ describe('[canvas] Line Geometries', () => { listening: false, }); + const noStrokeStyleProps = buildPointStyleProps({ + radius: 30, + strokeWidth: 0, + opacity: 0.5, + }); const propsNoStroke = buildLinePointProps({ lineIndex: 1, pointIndex: 2, x: 10, y: 20, - radius: 30, - strokeWidth: 0, color: 'red', - opacity: 0.5, + pointStyleProps: noStrokeStyleProps, }); expect(propsNoStroke).toEqual({ key: 'line-point-1-2', @@ -156,6 +259,41 @@ describe('[canvas] Line Geometries', () => { perfectDrawEnabled: false, listening: false, }); + + const seriesPointStyleProps = buildPointStyleProps({ + radius: 30, + strokeWidth: 2, + opacity: 0.5, + seriesPointStyle: { + stroke: 'series-stroke', + strokeWidth: 6, + visible: true, + radius: 12, + opacity: 18, + }, + }); + const seriesPointStyle = buildLinePointProps({ + lineIndex: 1, + pointIndex: 2, + x: 10, + y: 20, + color: 'red', + pointStyleProps: seriesPointStyleProps, + }); + expect(seriesPointStyle).toEqual({ + key: 'line-point-1-2', + x: 10, + y: 20, + radius: 12, + strokeWidth: 6, + strokeEnabled: true, + stroke: 'red', + fill: 'white', + opacity: 18, + strokeHitEnabled: false, + perfectDrawEnabled: false, + listening: false, + }); }); test('can build line path props', () => { const props = buildLineProps({ @@ -163,7 +301,6 @@ describe('[canvas] Line Geometries', () => { linePath: 'M0,0L10,10Z', color: 'red', strokeWidth: 1, - opacity: 0.3, geometryStyle: { opacity: 0.5, }, @@ -181,6 +318,33 @@ describe('[canvas] Line Geometries', () => { listening: false, }); expect(props.fill).toBeFalsy(); + + const seriesLineStyleProps = buildLineProps({ + index: 1, + linePath: 'M0,0L10,10Z', + color: 'red', + strokeWidth: 1, + geometryStyle: { + opacity: 0.5, + }, + seriesLineStyle: { + stroke: 'series-stroke', + strokeWidth: 66, + visible: true, + }, + }); + expect(seriesLineStyleProps).toEqual({ + key: `line-1`, + data: 'M0,0L10,10Z', + stroke: 'red', + strokeWidth: 66, + lineCap: 'round', + lineJoin: 'round', + opacity: 0.5, + strokeHitEnabled: false, + perfectDrawEnabled: false, + listening: false, + }); }); }); diff --git a/src/components/react_canvas/utils/rendering_props_utils.ts b/src/components/react_canvas/utils/rendering_props_utils.ts index 742344d980..d99357573a 100644 --- a/src/components/react_canvas/utils/rendering_props_utils.ts +++ b/src/components/react_canvas/utils/rendering_props_utils.ts @@ -1,49 +1,73 @@ import { GeometryStyle } from '../../../lib/series/rendering'; +import { AreaStyle, LineStyle, PointStyle } from '../../../lib/themes/theme'; import { GlobalKonvaElementProps } from '../globals'; +export interface PointStyleProps { + radius: number; + strokeWidth: number; + strokeEnabled: boolean; + fill: string; + opacity: number; +} + export function buildAreaPointProps({ areaIndex, pointIndex, x, y, - radius, - strokeWidth, color, - opacity, + pointStyleProps, }: { areaIndex: number; pointIndex: number; x: number; y: number; - radius: number; - strokeWidth: number; color: string; - opacity: number; + pointStyleProps: PointStyleProps; }) { return { key: `area-point-${areaIndex}-${pointIndex}`, x, y, - radius, - strokeWidth, - strokeEnabled: strokeWidth !== 0, stroke: color, - fill: 'white', - opacity, + ...pointStyleProps, ...GlobalKonvaElementProps, }; } +export function buildPointStyleProps({ + radius, + strokeWidth, + opacity, + seriesPointStyle, +}: { + radius: number; + strokeWidth: number; + opacity: number; + seriesPointStyle?: PointStyle; +}): PointStyleProps { + const pointStrokeWidth = seriesPointStyle ? seriesPointStyle.strokeWidth : strokeWidth; + return { + radius: seriesPointStyle ? seriesPointStyle.radius : radius, + strokeWidth: pointStrokeWidth, + strokeEnabled: pointStrokeWidth !== 0, + fill: 'white', + opacity: seriesPointStyle ? seriesPointStyle.opacity : opacity, + }; +} + export function buildAreaProps({ index, areaPath, color, opacity, + seriesAreaStyle, }: { index: number; areaPath: string; color: string; opacity: number; + seriesAreaStyle?: AreaStyle, }) { return { key: `area-${index}`, @@ -51,7 +75,7 @@ export function buildAreaProps({ fill: color, lineCap: 'round', lineJoin: 'round', - opacity, + opacity: seriesAreaStyle ? seriesAreaStyle.opacity : opacity, ...GlobalKonvaElementProps, }; } @@ -63,6 +87,7 @@ export function buildAreaLineProps({ color, strokeWidth, geometryStyle, + seriesAreaLineStyle, }: { areaIndex: number; lineIndex: number; @@ -70,12 +95,13 @@ export function buildAreaLineProps({ color: string; strokeWidth: number; geometryStyle: GeometryStyle; + seriesAreaLineStyle?: LineStyle; }) { return { key: `area-${areaIndex}-line-${lineIndex}`, data: linePath, stroke: color, - strokeWidth, + strokeWidth: seriesAreaLineStyle ? seriesAreaLineStyle.strokeWidth : strokeWidth, lineCap: 'round', lineJoin: 'round', ...geometryStyle, @@ -126,30 +152,22 @@ export function buildLinePointProps({ pointIndex, x, y, - radius, - strokeWidth, color, - opacity, + pointStyleProps, }: { lineIndex: number; pointIndex: number; x: number; y: number; - radius: number; - strokeWidth: number; color: string; - opacity: number; + pointStyleProps: PointStyleProps; }) { return { key: `line-point-${lineIndex}-${pointIndex}`, x, y, - radius, stroke: color, - strokeWidth, - strokeEnabled: strokeWidth !== 0, - fill: 'white', - opacity, + ...pointStyleProps, ...GlobalKonvaElementProps, }; } @@ -159,22 +177,21 @@ export function buildLineProps({ linePath, color, strokeWidth, - opacity, geometryStyle, + seriesLineStyle, }: { index: number; linePath: string; color: string; strokeWidth: number; - opacity: number; geometryStyle: GeometryStyle; + seriesLineStyle?: LineStyle; }) { return { key: `line-${index}`, data: linePath, stroke: color, - strokeWidth, - opacity, + strokeWidth: seriesLineStyle ? seriesLineStyle.strokeWidth : strokeWidth, lineCap: 'round', lineJoin: 'round', ...geometryStyle, diff --git a/src/lib/series/rendering.test.ts b/src/lib/series/rendering.test.ts index 57c2cd0d29..d3f140b143 100644 --- a/src/lib/series/rendering.test.ts +++ b/src/lib/series/rendering.test.ts @@ -1,5 +1,6 @@ +import { DEFAULT_GEOMETRY_STYLES } from '../themes/theme_commons'; import { getSpecId } from '../utils/ids'; -import { BarGeometry, isPointOnGeometry, PointGeometry } from './rendering'; +import { BarGeometry, getGeometryStyle, isPointOnGeometry, PointGeometry } from './rendering'; describe('Rendering utils', () => { test('check if point is in geometry', () => { @@ -56,4 +57,128 @@ describe('Rendering utils', () => { expect(isPointOnGeometry(-11, 0, geometry)).toBe(false); expect(isPointOnGeometry(11, 11, geometry)).toBe(false); }); + + test('should get common geometry style dependent on legend item highlight state', () => { + const geometryId = { + seriesKey: [], + specId: getSpecId('id'), + }; + const highlightedLegendItem = { + key: '', + color: '', + label: '', + value: { + colorValues: [], + specId: getSpecId('id'), + }, + isSeriesVisible: true, + isLegendItemVisible: true, + displayValue: { + raw: '', + formatted: '', + }, + }; + + const unhighlightedLegendItem = { + ...highlightedLegendItem, + value: { + colorValues: [], + specId: getSpecId('foo'), + }, + }; + + const sharedThemeStyle = DEFAULT_GEOMETRY_STYLES; + const specOpacity = 0.66; + + const defaultStyle = getGeometryStyle( + geometryId, + null, + sharedThemeStyle, + ); + + // no highlighted elements + expect(defaultStyle).toEqual({ opacity: 1 }); + + + const customDefaultStyle = getGeometryStyle( + geometryId, + null, + sharedThemeStyle, + specOpacity, + ); + + // no highlighted elements with custom spec opacity + expect(customDefaultStyle).toEqual({ opacity: 0.66 }); + + const highlightedStyle = getGeometryStyle( + geometryId, + highlightedLegendItem, + sharedThemeStyle, + ); + + // should equal highlighted opacity + expect(highlightedStyle).toEqual({ opacity: 1 }); + + const unhighlightedStyle = getGeometryStyle( + geometryId, + unhighlightedLegendItem, + sharedThemeStyle, + ); + + // should equal unhighlighted opacity + expect(unhighlightedStyle).toEqual({ opacity: 0.25 }); + + const customHighlightedStyle = getGeometryStyle( + geometryId, + highlightedLegendItem, + sharedThemeStyle, + specOpacity, + ); + + // should equal custom spec highlighted opacity + expect(customHighlightedStyle).toEqual({ opacity: 0.66 }); + + const customUnhighlightedStyle = getGeometryStyle( + geometryId, + unhighlightedLegendItem, + sharedThemeStyle, + specOpacity, + ); + + // unhighlighted elements remain unchanged with custom opacity + expect(customUnhighlightedStyle).toEqual({ opacity: 0.25 }); + + // has individual highlight + const hasIndividualHighlight = getGeometryStyle( + geometryId, + null, + sharedThemeStyle, + undefined, + { hasHighlight: true, hasGeometryHover: true }, + ); + + expect(hasIndividualHighlight).toEqual({ opacity: 1 }); + + // no highlight + const noHighlight = getGeometryStyle( + geometryId, + null, + sharedThemeStyle, + undefined, + { hasHighlight: false, hasGeometryHover: true }, + ); + + expect(noHighlight).toEqual({ opacity: 0.25 }); + + // no geometry hover + const noHover = getGeometryStyle( + geometryId, + null, + sharedThemeStyle, + undefined, + { hasHighlight: true, hasGeometryHover: false }, + ); + + expect(noHover).toEqual({ opacity: 1 }); + }); }); diff --git a/src/lib/series/rendering.ts b/src/lib/series/rendering.ts index ebe78c4571..cb94f20eee 100644 --- a/src/lib/series/rendering.ts +++ b/src/lib/series/rendering.ts @@ -1,6 +1,14 @@ import { area, line } from 'd3-shape'; import { mutableIndexedGeometryMapUpsert } from '../../state/utils'; -import { SharedGeometryStyle } from '../themes/theme'; +import { + AreaSeriesStyle, + AreaStyle, + CustomBarSeriesStyle, + LineSeriesStyle, + LineStyle, + PointStyle, + SharedGeometryStyle, +} from '../themes/theme'; import { SpecId } from '../utils/ids'; import { isLogarithmicScale } from '../utils/scales/scale_continuous'; import { Scale, ScaleType } from '../utils/scales/scales'; @@ -47,6 +55,7 @@ export interface BarGeometry { color: string; geometryId: GeometryId; value: GeometryValue; + seriesStyle?: CustomBarSeriesStyle; } export interface LineGeometry { line: string; @@ -57,6 +66,8 @@ export interface LineGeometry { y: number; }; geometryId: GeometryId; + seriesLineStyle?: LineStyle; + seriesPointStyle?: PointStyle; } export interface AreaGeometry { area: string; @@ -68,6 +79,9 @@ export interface AreaGeometry { y: number; }; geometryId: GeometryId; + seriesAreaStyle?: AreaStyle; + seriesAreaLineStyle?: LineStyle; + seriesPointStyle?: PointStyle; } export function isPointGeometry(ig: IndexedGeometry): ig is PointGeometry { @@ -159,6 +173,7 @@ export function renderBars( color: string, specId: SpecId, seriesKey: any[], + seriesStyle?: CustomBarSeriesStyle, ): { barGeometries: BarGeometry[]; indexedGeometries: Map; @@ -210,6 +225,7 @@ export function renderBars( specId, seriesKey, }, + seriesStyle, }; mutableIndexedGeometryMapUpsert(indexedGeometries, datum.x, barGeometry); barGeometries.push(barGeometry); @@ -230,6 +246,7 @@ export function renderLine( specId: SpecId, hasY0Accessors: boolean, seriesKey: any[], + seriesStyle?: LineSeriesStyle, ): { lineGeometry: LineGeometry; indexedGeometries: Map; @@ -243,6 +260,10 @@ export function renderLine( .curve(getCurveFactory(curve)); const y = 0; const x = shift; + + const seriesPointStyle = seriesStyle ? seriesStyle.point : undefined; + const seriesLineStyle = seriesStyle ? seriesStyle.line : undefined; + const { pointGeometries, indexedGeometries } = renderPoints( shift, dataset, @@ -265,6 +286,8 @@ export function renderLine( specId, seriesKey, }, + seriesLineStyle, + seriesPointStyle, }; return { lineGeometry, @@ -282,6 +305,7 @@ export function renderArea( specId: SpecId, hasY0Accessors: boolean, seriesKey: any[], + seriesStyle?: AreaSeriesStyle, ): { areaGeometry: AreaGeometry; indexedGeometries: Map; @@ -313,6 +337,10 @@ export function renderArea( } } + const seriesPointStyle = seriesStyle ? seriesStyle.point : undefined; + const seriesAreaStyle = seriesStyle ? seriesStyle.area : undefined; + const seriesAreaLineStyle = seriesStyle ? seriesStyle.line : undefined; + const { pointGeometries, indexedGeometries } = renderPoints( shift, dataset, @@ -337,6 +365,9 @@ export function renderArea( specId, seriesKey, }, + seriesAreaStyle, + seriesAreaLineStyle, + seriesPointStyle, }; return { areaGeometry, @@ -347,9 +378,19 @@ export function renderArea( export function getGeometryStyle( geometryId: GeometryId, highlightedLegendItem: LegendItem | null, - sharedStyle: SharedGeometryStyle, + sharedThemeStyle: SharedGeometryStyle, + specOpacity?: number, individualHighlight?: { [key: string]: boolean }, ): GeometryStyle { + + const sharedStyle = specOpacity == null ? + sharedThemeStyle : + { + ...sharedThemeStyle, + highlighted: { opacity: specOpacity }, + default: { opacity: specOpacity }, + }; + if (highlightedLegendItem != null) { const isPartOfHighlightedSeries = belongsToDataSeries(geometryId, highlightedLegendItem.value); diff --git a/src/lib/series/specs.ts b/src/lib/series/specs.ts index 99f61a18db..085aa9032e 100644 --- a/src/lib/series/specs.ts +++ b/src/lib/series/specs.ts @@ -1,4 +1,10 @@ -import { AnnotationLineStyle, GridLineConfig } from '../themes/theme'; +import { + AnnotationLineStyle, + AreaSeriesStyle, + CustomBarSeriesStyle, + GridLineConfig, + LineSeriesStyle, +} from '../themes/theme'; import { Accessor } from '../utils/accessor'; import { AnnotationId, AxisId, GroupId, SpecId } from '../utils/ids'; import { ScaleContinuousType, ScaleType } from '../utils/scales/scales'; @@ -86,7 +92,11 @@ export interface SeriesScales { yScaleToDataExtent: boolean; } -export type BasicSeriesSpec = SeriesSpec & SeriesAccessors & SeriesScales; +export type BasicSeriesSpec = SeriesSpec & SeriesAccessors & SeriesScales & { + barSeriesStyle?: CustomBarSeriesStyle; + lineSeriesStyle?: LineSeriesStyle; + areaSeriesStyle?: AreaSeriesStyle; +}; /** * This spec describe the dataset configuration used to display a bar series. diff --git a/src/lib/themes/theme.ts b/src/lib/themes/theme.ts index ec4a6fccfe..9be233b103 100644 --- a/src/lib/themes/theme.ts +++ b/src/lib/themes/theme.ts @@ -90,16 +90,24 @@ export interface Theme { export interface BarSeriesStyle { border: StrokeStyle & Visible; } + +export type CustomBarSeriesStyle = BarSeriesStyle & Partial; + export interface LineSeriesStyle { - line: StrokeStyle & Visible; + line: LineStyle; border: StrokeStyle & Visible; - point: StrokeStyle & Opacity & Visible & Radius; + point: PointStyle; } + +export type PointStyle = StrokeStyle & Opacity & Visible & Radius; +export type LineStyle = StrokeStyle & Visible & Partial; +export type AreaStyle = FillStyle & Opacity & Visible; + export interface AreaSeriesStyle { - area: FillStyle & Opacity & Visible; - line: StrokeStyle & Visible; + area: AreaStyle; + line: LineStyle; border: StrokeStyle & Visible; - point: StrokeStyle & Opacity & Visible & Radius; + point: PointStyle; } export interface CrosshairStyle { band: FillStyle & Visible; diff --git a/src/state/utils.ts b/src/state/utils.ts index f16840ae1d..d971c86424 100644 --- a/src/state/utils.ts +++ b/src/state/utils.ts @@ -351,7 +351,8 @@ export function renderGeometries( break; case 'bar': const shift = isStacked ? indexOffset : indexOffset + i; - const renderedBars = renderBars(shift, ds.data, xScale, yScale, color, ds.specId, ds.key); + const barSeriesStyle = spec.barSeriesStyle; + const renderedBars = renderBars(shift, ds.data, xScale, yScale, color, ds.specId, ds.key, barSeriesStyle); barGeometriesIndex = mergeGeometriesIndexes( barGeometriesIndex, renderedBars.indexedGeometries, @@ -361,6 +362,7 @@ export function renderGeometries( break; case 'line': const lineShift = clusteredCount > 0 ? clusteredCount : 1; + const lineSeriesStyle = spec.lineSeriesStyle; const renderedLines = renderLine( // move the point on half of the bandwidth if we have mixed bars/lines (xScale.bandwidth * lineShift) / 2, @@ -372,6 +374,7 @@ export function renderGeometries( ds.specId, Boolean(spec.y0Accessors), ds.key, + lineSeriesStyle, ); lineGeometriesIndex = mergeGeometriesIndexes( lineGeometriesIndex, @@ -383,6 +386,7 @@ export function renderGeometries( break; case 'area': const areaShift = clusteredCount > 0 ? clusteredCount : 1; + const areaSeriesStyle = spec.areaSeriesStyle; const renderedAreas = renderArea( // move the point on half of the bandwidth if we have mixed bars/lines (xScale.bandwidth * areaShift) / 2, @@ -394,6 +398,7 @@ export function renderGeometries( ds.specId, Boolean(spec.y0Accessors), ds.key, + areaSeriesStyle, ); areaGeometriesIndex = mergeGeometriesIndexes( areaGeometriesIndex, diff --git a/stories/styling.tsx b/stories/styling.tsx index a8e4bba864..44284cda9c 100644 --- a/stories/styling.tsx +++ b/stories/styling.tsx @@ -47,6 +47,40 @@ function range( ); } +function generateLineSeriesStyleKnobs(groupName: string) { + return { + line: { + stroke: DEFAULT_MISSING_COLOR, + strokeWidth: range(`line.strokeWidth (${groupName})`, 0, 10, 1, groupName), + visible: boolean(`line.visible (${groupName})`, true, groupName), + opacity: range(`line.opacity (${groupName})`, 0, 1, 1, groupName, 0.01), + }, + border: { + stroke: 'gray', + strokeWidth: 2, + visible: false, + }, + point: { + visible: boolean(`point.visible (${groupName})`, true, groupName), + radius: range(`point.radius (${groupName})`, 0, 20, 1, groupName, 0.5), + opacity: range(`point.opacity (${groupName})`, 0, 1, 1, groupName, 0.01), + stroke: '', + strokeWidth: 0.5, + }, + }; +} + +function generateAreaSeriesStyleKnobs(groupName: string) { + return { + ...generateLineSeriesStyleKnobs(groupName), + area: { + fill: DEFAULT_MISSING_COLOR, + visible: boolean(`area.visible (${groupName})`, true, groupName), + opacity: range(`area.opacity ${groupName}`, 0, 1, 1, groupName, 0.01), + }, + }; +} + const dg = new DataGenerator(); const data1 = dg.generateGroupedSeries(40, 4); const data2 = dg.generateSimpleSeries(40); @@ -408,4 +442,215 @@ storiesOf('Stylings', module) /> ); + }) + .add('custom series styles: bars', () => { + const useOnlyChartTheme = boolean('ignore series style (use only chart theme)', false, 'chartTheme'); + + const barSeriesStyle1 = useOnlyChartTheme ? undefined : { + border: { + stroke: color('borderStroke 1', 'white', 'group1'), + strokeWidth: range('strokeWidth 1', 0, 10, 1, 'group1'), + visible: boolean('borderVisible 1', true, 'group1'), + }, + opacity: range('opacity 1', 0, 1, 1, 'group1', 0.1), + }; + + const barSeriesStyle2 = useOnlyChartTheme ? undefined : { + border: { + stroke: color('borderStroke 2', 'white', 'group2'), + strokeWidth: range('strokeWidth 2', 0, 10, 1, 'group2'), + visible: boolean('borderVisible 2', true, 'group2'), + }, + opacity: range('opacity 2', 0, 1, 1, 'group2', 0.1), + }; + + const chartTheme = { + ...LIGHT_THEME, + barSeriesStyle: { + border: { + stroke: color('theme borderStroke', 'white', 'chartTheme'), + strokeWidth: range('theme strokeWidth', 0, 10, 1, 'chartTheme'), + visible: boolean('theme borderVisible', true, 'chartTheme'), + }, + }, + }; + + const dataset1 = TestDatasets.BARCHART_2Y2G.filter((data) => data.g1 === 'cdn.google.com'); + const dataset2 = TestDatasets.BARCHART_2Y2G.filter((data) => data.g1 === 'cloudflare.com'); + const dataset3 = TestDatasets.BARCHART_2Y2G.filter((data) => data.g2 === 'indirect-cdn'); + + return ( + + + + Number(d).toFixed(2)} + /> + + + + + + ); + }) + .add('custom series styles: lines', () => { + const useOnlyChartTheme = boolean('ignore series style (use only chart theme)', false, 'chartTheme'); + const lineSeriesStyle1 = useOnlyChartTheme ? undefined : generateLineSeriesStyleKnobs('lines1'); + const lineSeriesStyle2 = useOnlyChartTheme ? undefined : generateLineSeriesStyleKnobs('lines2'); + + const chartTheme = { + ...LIGHT_THEME, + lineSeriesStyle: generateLineSeriesStyleKnobs('chartTheme'), + }; + + const dataset1 = [{ x: 0, y: 3 }, { x: 1, y: 2 }, { x: 2, y: 4 }, { x: 3, y: 10 }]; + const dataset2 = dataset1.map((datum) => ({ ...datum, y: datum.y - 1 })); + const dataset3 = dataset1.map((datum) => ({ ...datum, y: datum.y - 2 })); + + return ( + + + + Number(d).toFixed(2)} + /> + + + + + ); + }) + .add('custom series styles: area', () => { + const chartTheme = { + ...LIGHT_THEME, + areaSeriesStyle: generateAreaSeriesStyleKnobs('chartTheme'), + }; + + const useOnlyChartTheme = boolean('ignore series style (use only chart theme)', false, 'chartTheme'); + + const dataset1 = [{ x: 0, y: 3 }, { x: 1, y: 2 }, { x: 2, y: 4 }, { x: 3, y: 10 }]; + const dataset2 = dataset1.map((datum) => ({ ...datum, y: datum.y - 1 })); + const dataset3 = dataset1.map((datum) => ({ ...datum, y: datum.y - 2 })); + + const areaStyle1 = useOnlyChartTheme ? undefined : generateAreaSeriesStyleKnobs('area1'); + const areaStyle2 = useOnlyChartTheme ? undefined : generateAreaSeriesStyleKnobs('area2'); + + return ( + + + + Number(d).toFixed(2)} + /> + + + + + ); });