Skip to content

feat(react-charting): Support marker shapes for scatter chart #34656

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat(react-charting): Support marker shapes for scatter chart",
"packageName": "@fluentui/react-charting",
"email": "120183316+srmukher@users.noreply.github.com",
"dependentChangeType": "patch"
}
3 changes: 3 additions & 0 deletions packages/charts/react-charting/etc/react-charting.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,7 @@ export interface ISankeyChartStyles {

// @public
export interface IScatterChartDataPoint extends IBaseDataPoint {
shape?: LegendShape;
x: number | Date | string;
y: number;
}
Expand Down Expand Up @@ -1428,6 +1429,8 @@ export interface IShapeProps {
// (undocumented)
classNameForNonSvg?: string;
// (undocumented)
isOpenShape?: boolean;
// (undocumented)
pathProps: React_2.SVGAttributes<SVGPathElement>;
// (undocumented)
shape: LegendShape;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import { curveCardinal as d3CurveCardinal } from 'd3-shape';
import { IScatterChartProps } from '../ScatterChart/index';
import type { ColorwayType } from './PlotlyColorAdapter';
import { getOpacity, extractColor, resolveColor } from './PlotlyColorAdapter';
import { ILegend, ILegendsProps } from '../Legends/index';
import { ILegend, ILegendsProps, LegendShape } from '../Legends/index';
import { rgb } from 'd3-color';
import { ICartesianChartProps } from '../CommonComponents/index';

Expand Down Expand Up @@ -1696,7 +1696,7 @@ const getLegendShape = (series: Partial<PlotData>): ILegend['shape'] => {
if (dashType === 'dot' || dashType === 'dash' || dashType === 'dashdot') {
return 'dottedLine';
} else if (series.mode?.includes('markers')) {
return 'circle';
return (series?.marker?.symbol as LegendShape) ?? 'circle';
}
return 'default';
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -443,8 +443,13 @@ export class LegendsBase extends React.Component<ILegendsProps, ILegendState> im
<Shape
svgProps={svgParentProps}
pathProps={svgChildProps}
shape={legend.shape as LegendShape}
shape={
legend.shape?.includes('open')
? (legend.shape.replace('open', '') as LegendShape)
: (legend.shape as LegendShape)
}
classNameForNonSvg={classNames.rect}
isOpenShape={legend.shape?.toLowerCase().includes('open')}
/>
);
}
Expand Down
36 changes: 13 additions & 23 deletions packages/charts/react-charting/src/components/Legends/shape.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,34 @@
import * as React from 'react';
import { LegendShape } from './Legends.types';
import { CustomPoints, getSecureProps, Points } from '../../utilities/utilities';
import { CustomPoints, getSecureProps, getShapePath, isOpenShape, scatterPointPaths } from '../../utilities/utilities';

export interface IShapeProps {
svgProps: React.SVGAttributes<SVGElement>;
pathProps: React.SVGAttributes<SVGPathElement>;
shape: LegendShape;
classNameForNonSvg?: string;
isOpenShape?: boolean;
}

type PointPathType = {
[key: string]: string;
};

const pointPath: PointPathType = {
[`${Points[Points.circle]}`]: 'M1 6 A5 5 0 1 0 12 6 M1 6 A5 5 0 0 1 12 6',
[`${Points[Points.square]}`]: 'M1 1 L12 1 L12 12 L1 12 L1 1 Z',
[`${Points[Points.triangle]}`]: 'M6 10L8.74228e-07 -1.04907e-06L12 0L6 10Z',
[`${Points[Points.pyramid]}`]: 'M6 10L8.74228e-07 -1.04907e-06L12 0L6 10Z',
[`${Points[Points.diamond]}`]: 'M2 2 L10 2 L10 10 L2 10 L2 2 Z',
[`${Points[Points.hexagon]}`]: 'M9 0H3L0 5L3 10H9L12 5L9 0Z',
[`${Points[Points.pentagon]}`]: 'M6.06061 0L0 4.21277L2.30303 11H9.69697L12 4.21277L6.06061 0Z',
[`${Points[Points.octagon]}`]:
'M7.08333 0H2.91667L0 2.91667V7.08333L2.91667 10H7.08333L10 7.08333V2.91667L7.08333 0Z',
// Legacy point paths for backward compatibility
const legacyPointPaths: { [key: string]: string } = {
[`${CustomPoints[CustomPoints.dottedLine]}`]: 'M0 6 H3 M5 6 H8 M10 6 H13',
};

const pointPath = { ...scatterPointPaths, ...legacyPointPaths };

export const Shape: React.FC<IShapeProps> = ({ svgProps, pathProps, shape, classNameForNonSvg }) => {
if (Object.keys(pointPath).indexOf(shape) === -1) {
return <div className={classNameForNonSvg} />;
}

return (
<svg
width={14}
height={14}
viewBox={'-1 -1 14 14'}
{...getSecureProps(svgProps)}
transform={`rotate(${shape === Points[Points.diamond] ? 45 : shape === Points[Points.pyramid] ? 180 : 0}, 0, 0)`}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rotate(${shape === Points[Points.diamond] ? 45 : shape === Points[Points.pyramid] ? 180 : 0}, 0, 0)

this is not needed anymore?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, since we have the exact shapes this transform was changing the legend shape

>
<path d={pointPath[shape]} {...getSecureProps(pathProps)} />
<svg width={14} height={14} viewBox={'-1 -1 14 14'} {...getSecureProps(svgProps)}>
<path
d={getShapePath(shape)}
{...getSecureProps(pathProps)}
fill={isOpenShape(shape) ? 'none' : pathProps.fill || 'currentColor'}
/>
</svg>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5380,12 +5380,11 @@ exports[`Line chart rendering Should render the Line chart with points in multip
}
focusable="false"
height="14"
transform="rotate(0, 0, 0)"
viewBox="-1 -1 14 14"
width="14"
>
<path
d="M1 6 A5 5 0 1 0 12 6 M1 6 A5 5 0 0 1 12 6"
d="M6 1 A5 5 0 1 0 6 11 A5 5 0 1 0 6 1 Z"
fill="red"
stroke="red"
stroke-width="2"
Expand Down Expand Up @@ -5482,12 +5481,11 @@ exports[`Line chart rendering Should render the Line chart with points in multip
}
focusable="false"
height="14"
transform="rotate(0, 0, 0)"
viewBox="-1 -1 14 14"
width="14"
>
<path
d="M1 1 L12 1 L12 12 L1 12 L1 1 Z"
d="M2 2 L10 2 L10 10 L2 10 L2 2 Z"
fill="green"
stroke="green"
stroke-width="2"
Expand Down Expand Up @@ -5584,12 +5582,11 @@ exports[`Line chart rendering Should render the Line chart with points in multip
}
focusable="false"
height="14"
transform="rotate(0, 0, 0)"
viewBox="-1 -1 14 14"
width="14"
>
<path
d="M6 10L8.74228e-07 -1.04907e-06L12 0L6 10Z"
d="M6 2 L10 10 H2 L6 2 Z"
fill="yellow"
stroke="yellow"
stroke-width="2"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from 'react';
import { IChildProps, IScatterChartStyleProps, IScatterChartStyles, IScatterChartProps } from './ScatterChart.types';
import { Axis as D3Axis } from 'd3-axis';
import { select as d3Select } from 'd3-selection';
import { ILegend, ILegendContainer, Legends } from '../Legends/index';
import { ILegend, ILegendContainer, Legends, LegendShape } from '../Legends/index';
import { max as d3Max, min as d3Min } from 'd3-array';
import {
areArraysEqual,
Expand All @@ -12,6 +12,8 @@ import {
domainRangeOfNumericForScatterChart,
domainRangeOfXStringAxis,
findNumericMinMaxOfY,
getShapePath,
isOpenShape,
YAxisType,
} from '../../utilities/index';
import {
Expand Down Expand Up @@ -358,8 +360,8 @@ export const ScatterChartBase: React.FunctionComponent<IScatterChartProps> = Rea
* This function checks if none of the legends is selected or hovered.*/

const _noLegendHighlighted = React.useCallback((): boolean => {
return selectedLegends.length === 0;
}, [selectedLegends]);
return selectedLegends.length === 0 && activeLegend === '';
}, [selectedLegends, activeLegend]);

const _getHighlightedLegend = React.useCallback((): string[] => {
return selectedLegends.length > 0 ? selectedLegends : activeLegend ? [activeLegend] : [];
Expand Down Expand Up @@ -392,21 +394,81 @@ export const ScatterChartBase: React.FunctionComponent<IScatterChartProps> = Rea
[_points, props.culture, props.useUTC],
);

function _getRangeForScatterMarkerSize(
yScale: ScaleLinear<number, number>,
yPadding: number,
xMin: number,
xMax: number,
xPadding: number,
): number {
const extraXPixels = getRTL()
? _xAxisScale.current?.(xMax - xPadding) - _xAxisScale.current?.(xMax)
: _xAxisScale.current?.(xMin + xPadding) - _xAxisScale.current?.(xMin);

const yMin = yScale.domain()[0];
const extraYPixels = yScale(yMin) - yScale(yMin + yPadding);
return Math.min(extraXPixels, extraYPixels);
}
const _getRangeForScatterMarkerSize = React.useCallback(
(yScale: ScaleLinear<number, number>, yPadding: number, xMin: number, xMax: number, xPadding: number): number => {
const extraXPixels = getRTL()
? _xAxisScale.current?.(xMax - xPadding) - _xAxisScale.current?.(xMax)
: _xAxisScale.current?.(xMin + xPadding) - _xAxisScale.current?.(xMin);

const yMin = yScale.domain()[0];
const extraYPixels = yScale(yMin) - yScale(yMin + yPadding);
return Math.min(extraXPixels, extraYPixels);
},
[],
);

const _renderShape = React.useCallback(
(
shape: LegendShape | undefined,
x: number | string | Date,
y: number,
size: number,
fill: string,
stroke: string,
opacity: number,
circleId: string,
isLegendSelected: boolean,
eventHandlers: any,
ariaLabel: string,
tabIndex?: number,
): JSX.Element => {
const cx = _xAxisScale.current?.(x) + _xBandwidth.current;
const cy = _yAxisScale.current?.(y);

// Default to circle if no shape specified
if (!shape || shape === 'default' || shape === 'circle') {
return (
<circle
id={circleId}
key={circleId}
r={Math.max(size, 4)}
cx={cx}
cy={cy}
data-is-focusable={isLegendSelected}
{...eventHandlers}
opacity={opacity}
fill={isOpenShape(shape) ? 'none' : fill}
stroke={stroke}
strokeWidth={isOpenShape(shape) ? 2 : 1}
role="img"
aria-label={ariaLabel}
tabIndex={tabIndex}
/>
);
}

const pathData = getShapePath(shape);

return (
<path
id={circleId}
key={circleId}
d={pathData}
transform={`translate(${cx}, ${cy}) scale(${size / 6})`}
data-is-focusable={isLegendSelected}
{...eventHandlers}
opacity={opacity}
fill={isOpenShape(shape) ? 'none' : fill}
stroke={stroke}
strokeWidth={isOpenShape(shape) ? 2 : 1}
role="img"
aria-label={ariaLabel}
tabIndex={tabIndex}
/>
);
},
[_xAxisScale, _xBandwidth, _yAxisScale],
);

const _createPlot = React.useCallback(
(xElement: SVGElement, containerHeight: number): JSX.Element[] => {
Expand Down Expand Up @@ -470,10 +532,13 @@ export const ScatterChartBase: React.FunctionComponent<IScatterChartProps> = Rea
const verticaLineHeight = containerHeight - (margins.current?.bottom ?? 0) + 6;

for (let j = 0; j < _points.current?.[i]?.data?.length; j++) {
console.log('Rendering point:', _points.current?.[i]);
const seriesId = `${_seriesId}_${i}_${j}`;
const circleId = `${_circleId}_${i}_${j}`;
const { x, y, xAxisCalloutData, xAxisCalloutAccessibilityData } = _points.current?.[i]?.data[j];
const pointMarkerSize = (_points.current?.[i]?.data[j] as IScatterChartDataPoint).markerSize;
const pointShape =
(_points.current?.[i]?.data[j] as IScatterChartDataPoint).shape || _points.current?.[i]?.legendShape;
const extraMaxPixels =
_xAxisType !== XAxisTypes.StringAxis
? _getRangeForScatterMarkerSize(_yAxisScale.current, yPadding, xMin, xMax, xPadding)
Expand All @@ -495,46 +560,45 @@ export const ScatterChartBase: React.FunctionComponent<IScatterChartProps> = Rea
const text = _points.current?.[i].data[j]?.text;
pointsForSeries.push(
<>
<circle
id={circleId}
key={circleId}
r={Math.max(circleRadius, 4)}
cx={_xAxisScale.current?.(x) + _xBandwidth.current}
cy={_yAxisScale.current?.(y)}
data-is-focusable={isLegendSelected}
onMouseOver={(event: React.MouseEvent<SVGElement>) =>
_handleHover(
x,
y,
verticaLineHeight,
xAxisCalloutData,
circleId,
xAxisCalloutAccessibilityData,
event,
)
}
onMouseMove={(event: React.MouseEvent<SVGElement>) =>
_handleHover(
x,
y,
verticaLineHeight,
xAxisCalloutData,
circleId,
xAxisCalloutAccessibilityData,
event,
)
}
onMouseOut={_handleMouseOut}
onFocus={() => _handleFocus(seriesId, x, xAxisCalloutData, circleId, xAxisCalloutAccessibilityData)}
onBlur={_handleMouseOut}
{..._getClickHandler(_points.current?.[i]?.data[j]?.onDataPointClick)}
opacity={isLegendSelected && !currentPointHidden ? 1 : 0.1}
fill={_getPointFill(seriesColor, circleId)}
stroke={seriesColor}
role="img"
aria-label={_getAriaLabel(i, j)}
tabIndex={_points.current?.[i]?.legend !== '' ? 0 : undefined}
/>
{_renderShape(
pointShape || _points.current?.[i]?.legendShape,
x,
y,
circleRadius,
_getPointFill(seriesColor, circleId),
seriesColor,
isLegendSelected && !currentPointHidden ? 1 : 0.1,
circleId,
isLegendSelected,
{
onMouseOver: (event: React.MouseEvent<SVGElement>) =>
_handleHover(
x,
y,
verticaLineHeight,
xAxisCalloutData,
circleId,
xAxisCalloutAccessibilityData,
event,
),
onMouseMove: (event: React.MouseEvent<SVGElement>) =>
_handleHover(
x,
y,
verticaLineHeight,
xAxisCalloutData,
circleId,
xAxisCalloutAccessibilityData,
event,
),
onMouseOut: _handleMouseOut,
onFocus: () => _handleFocus(seriesId, x, xAxisCalloutData, circleId, xAxisCalloutAccessibilityData),
onBlur: _handleMouseOut,
..._getClickHandler(_points.current?.[i]?.data[j]?.onDataPointClick),
},
_getAriaLabel(i, j),
_points.current?.[i]?.legend !== '' ? 0 : undefined,
)}
{text && (
<text
key={`${circleId}-label`}
Expand Down Expand Up @@ -606,6 +670,7 @@ export const ScatterChartBase: React.FunctionComponent<IScatterChartProps> = Rea
isSelectedLegend,
selectedLegendPoints,
classNames,
_renderShape,
],
);

Expand Down
Loading
Loading