diff --git a/packages/swarmplot/src/SwarmPlot.tsx b/packages/swarmplot/src/SwarmPlot.tsx index 0694551333..c357e92a9c 100644 --- a/packages/swarmplot/src/SwarmPlot.tsx +++ b/packages/swarmplot/src/SwarmPlot.tsx @@ -10,6 +10,7 @@ import { useSwarmPlot, useSwarmPlotLayerContext, useNodeMouseHandlers } from './ import { Circles } from './Circles' import { CircleSvg } from './CircleSvg' import { SwarmPlotAnnotations } from './SwarmPlotAnnotations' +import { SwarmPlotLegends } from './SwarmPlotLegends' type InnerSwarmPlotProps = Partial< Omit< @@ -52,6 +53,8 @@ const InnerSwarmPlot = ({ axisRight = defaultProps.axisRight, axisBottom = defaultProps.axisBottom, axisLeft = defaultProps.axisLeft, + legendLabel, + legends = defaultProps.legends, isInteractive, onMouseEnter, onMouseMove, @@ -67,7 +70,7 @@ const InnerSwarmPlot = ({ partialMargin ) - const { nodes, ...props } = useSwarmPlot({ + const { nodes, legendsData, ...props } = useSwarmPlot({ width: innerWidth, height: innerHeight, data, @@ -85,6 +88,8 @@ const InnerSwarmPlot = ({ colorBy, forceStrength, simulationIterations, + legendLabel, + legends, }) const xScale = props.xScale as Exclude[]> @@ -105,6 +110,7 @@ const InnerSwarmPlot = ({ circles: null, annotations: null, mesh: null, + legends: null, } if (layers.includes('grid')) { @@ -165,6 +171,17 @@ const InnerSwarmPlot = ({ ) } + if (layers.includes('legends')) { + layerById.legends = ( + + ) + } + if (isInteractive && useMesh) { layerById.mesh = ( ( ctx: CanvasRenderingContext2D, @@ -78,6 +79,8 @@ export const InnerSwarmPlotCanvas = ({ axisRight = defaultProps.axisRight, axisBottom = defaultProps.axisBottom, axisLeft = defaultProps.axisLeft, + legendLabel, + legends = defaultProps.legends, isInteractive, onMouseMove, onClick, @@ -95,7 +98,7 @@ export const InnerSwarmPlotCanvas = ({ partialMargin ) - const { nodes, ...scales } = useSwarmPlot({ + const { nodes, legendsData, ...scales } = useSwarmPlot({ width: innerWidth, height: innerHeight, data, @@ -113,6 +116,8 @@ export const InnerSwarmPlotCanvas = ({ colorBy, forceStrength, simulationIterations, + legendLabel, + legends, }) const { xScale, yScale } = scales as Record<'xScale' | 'yScale', AnyScale> @@ -125,7 +130,8 @@ export const InnerSwarmPlotCanvas = ({ }) const getBorderColor = useInheritedColor(borderColor, theme) - const getBorderWidth = () => 1 + // memoize getBorderWidth to provide a stable object for the rendering + const getBorderWidth = useMemo(() => () => 1, []) useEffect(() => { if (!canvasEl.current) return @@ -203,6 +209,18 @@ export const InnerSwarmPlotCanvas = ({ renderVoronoiCellToCanvas(ctx, voronoi, currentNode.index) } } + + if (layer === 'legends') { + legendsData.forEach(([legend, data]) => { + renderLegendToCanvas(ctx, { + ...legend, + data, + containerWidth: innerWidth, + containerHeight: innerHeight, + theme, + }) + }) + } }) }, [ canvasEl, @@ -231,6 +249,7 @@ export const InnerSwarmPlotCanvas = ({ renderCircle, getBorderWidth, getBorderColor, + legendsData, ]) const getNodeFromMouseEvent = useCallback( diff --git a/packages/swarmplot/src/SwarmPlotLegends.tsx b/packages/swarmplot/src/SwarmPlotLegends.tsx new file mode 100644 index 0000000000..fdfdf1dd56 --- /dev/null +++ b/packages/swarmplot/src/SwarmPlotLegends.tsx @@ -0,0 +1,23 @@ +import { BoxLegendSvg } from '@nivo/legends' +import { LegendProps } from '@nivo/legends' +import { SwarmPlotLegendData } from './types' + +interface SwarmPlotLegendsProps { + width: number + height: number + legends: [LegendProps, SwarmPlotLegendData[]][] +} + +export const SwarmPlotLegends = ({ width, height, legends }: SwarmPlotLegendsProps) => ( + <> + {legends.map(([legend, data], i) => ( + + ))} + +) diff --git a/packages/swarmplot/src/compute.ts b/packages/swarmplot/src/compute.ts index 393991af9e..7402cccb9b 100644 --- a/packages/swarmplot/src/compute.ts +++ b/packages/swarmplot/src/compute.ts @@ -1,21 +1,22 @@ import isNumber from 'lodash/isNumber' import isPlainObject from 'lodash/isPlainObject' import isString from 'lodash/isString' +import uniqBy from 'lodash/uniqBy' import get from 'lodash/get' import { scaleLinear, ScaleOrdinal, scaleOrdinal } from 'd3-scale' import { forceSimulation, forceX, forceY, forceCollide, ForceX, ForceY } from 'd3-force' +import { computeScale, createDateNormalizer, generateSeriesAxis } from '@nivo/scales' import { - computeScale, - createDateNormalizer, - generateSeriesAxis, - ScaleLinear, - ScaleLinearSpec, - ScaleTime, - ScaleTimeSpec, -} from '@nivo/scales' -import { ComputedDatum, PreSimulationDatum, SizeSpec, SimulationForces } from './types' - -const getParsedValue = (scaleSpec: ScaleLinearSpec | ScaleTimeSpec) => { + ComputedDatum, + PreSimulationDatum, + SizeSpec, + SimulationForces, + SwarmPlotLegendData, + SwarmPlotValueScaleSpec, + SwarmPlotValueScale, +} from './types' + +const getParsedValue = (scaleSpec: SwarmPlotValueScaleSpec) => { if (scaleSpec.type === 'time' && scaleSpec.format !== 'native') { return createDateNormalizer(scaleSpec) as (value: T) => T } @@ -66,9 +67,9 @@ export const computeValueScale = ({ height: number axis: 'x' | 'y' getValue: (datum: RawDatum) => number | Date - scale: ScaleLinearSpec | ScaleTimeSpec + scale: SwarmPlotValueScaleSpec data: RawDatum[] -}) => { +}): SwarmPlotValueScale => { const values = data.map(getValue) if (scale.type === 'time') { @@ -77,9 +78,7 @@ export const computeValueScale = ({ ] const axes = generateSeriesAxis(series, axis, scale) - return computeScale(scale, axes, axis === 'x' ? width : height, axis) as ScaleTime< - Date | string - > + return computeScale(scale, axes, axis === 'x' ? width : height, axis) as SwarmPlotValueScale } const min = Math.min(...(values as number[])) @@ -90,7 +89,7 @@ export const computeValueScale = ({ { all: values, min, max }, axis === 'x' ? width : height, axis - ) as ScaleLinear + ) as SwarmPlotValueScale } export const getSizeGenerator = (size: SizeSpec) => { @@ -140,7 +139,7 @@ export const computeForces = ({ forceStrength, }: { axis: 'x' | 'y' - valueScale: ScaleLinear | ScaleTime + valueScale: SwarmPlotValueScale ordinalScale: ScaleOrdinal spacing: number forceStrength: number @@ -183,13 +182,13 @@ export const computeNodes = ({ getId: (datum: RawDatum) => string layout: 'vertical' | 'horizontal' getValue: (datum: RawDatum) => number | Date - valueScale: ScaleLinear | ScaleTime + valueScale: SwarmPlotValueScale getGroup: (datum: RawDatum) => string ordinalScale: ScaleOrdinal getSize: (datum: RawDatum) => number forces: SimulationForces simulationIterations: number - valueScaleConfig: ScaleLinearSpec | ScaleTimeSpec + valueScaleConfig: SwarmPlotValueScaleSpec }) => { const config = { horizontal: ['x', 'y'], @@ -220,3 +219,21 @@ export const computeNodes = ({ nodes: simulation.nodes() as ComputedDatum[], } } + +export const getBaseLegendData = ({ + nodes, + getLegendLabel, +}: { + nodes: ComputedDatum[] + getLegendLabel: (datum: RawDatum) => string +}) => { + const nodeData = nodes.map( + node => + ({ + id: node.group, + label: getLegendLabel(node.data), + color: node?.color, + } as SwarmPlotLegendData) + ) + return uniqBy(nodeData, ({ id }) => id) +} diff --git a/packages/swarmplot/src/hooks.ts b/packages/swarmplot/src/hooks.ts index 4d33692d38..5d076bc33d 100644 --- a/packages/swarmplot/src/hooks.ts +++ b/packages/swarmplot/src/hooks.ts @@ -4,21 +4,25 @@ import { usePropertyAccessor, useValueFormatter } from '@nivo/core' import { useOrdinalColorScale } from '@nivo/colors' import { AnnotationMatcher, useAnnotations } from '@nivo/annotations' import { useTooltip } from '@nivo/tooltip' -import { ScaleLinear, ScaleLinearSpec, ScaleTime, ScaleTimeSpec } from '@nivo/scales' import { computeValueScale, computeOrdinalScale, getSizeGenerator, + getBaseLegendData, computeForces, computeNodes, } from './compute' import { SwarmPlotCommonProps, + SwarmPlotLegendData, ComputedDatum, SizeSpec, SwarmPlotCustomLayerProps, MouseHandlers, + SwarmPlotValueScale, + SwarmPlotValueScaleSpec, } from './types' +import { LegendProps } from '@nivo/legends' export const useValueScale = ({ width, @@ -32,7 +36,7 @@ export const useValueScale = ({ height: number axis: 'x' | 'y' getValue: (datum: RawDatum) => number | Date - scale: ScaleLinearSpec | ScaleTimeSpec + scale: SwarmPlotValueScaleSpec data: RawDatum[] }) => useMemo( @@ -77,7 +81,7 @@ export const useForces = ({ forceStrength, }: { axis: 'x' | 'y' - valueScale: ScaleLinear | ScaleTime + valueScale: SwarmPlotValueScale ordinalScale: ScaleOrdinal spacing: number forceStrength: number @@ -112,6 +116,8 @@ export const useSwarmPlot = ({ simulationIterations, colors, colorBy, + legendLabel, + legends, }: { data: RawDatum[] width: number @@ -130,6 +136,8 @@ export const useSwarmPlot = ({ simulationIterations: SwarmPlotCommonProps['simulationIterations'] colors: SwarmPlotCommonProps['colors'] colorBy: SwarmPlotCommonProps['colorBy'] + legendLabel: SwarmPlotCommonProps['legendLabel'] + legends: SwarmPlotCommonProps['legends'] }) => { const axis = layout === 'horizontal' ? 'x' : 'y' @@ -143,6 +151,7 @@ export const useSwarmPlot = ({ colors, getColorId ) + const getLegendLabel = usePropertyAccessor(legendLabel ?? groupBy) const valueScale = useValueScale({ width, @@ -209,11 +218,25 @@ export const useSwarmPlot = ({ [nodes, formatValue, getColor] ) + const legendsData: [LegendProps, SwarmPlotLegendData[]][] = useMemo( + () => + legends.map(legend => { + if (legend.data) return [legend, legend.data as SwarmPlotLegendData[]] + const data = getBaseLegendData({ + nodes: augmentedNodes, + getLegendLabel, + }) + return [legend, data] + }), + [legends, augmentedNodes, getLegendLabel] + ) + return { nodes: augmentedNodes, xScale, yScale, getColor, + legendsData, } } @@ -306,10 +329,7 @@ export const useSwarmPlotAnnotations = ( export const useSwarmPlotLayerContext = < RawDatum, - Scale extends - | ScaleLinear - | ScaleTime - | ScaleOrdinal + Scale extends SwarmPlotValueScale | ScaleOrdinal >({ nodes, xScale, diff --git a/packages/swarmplot/src/props.ts b/packages/swarmplot/src/props.ts index ed6b1da6e0..d3c166d3af 100644 --- a/packages/swarmplot/src/props.ts +++ b/packages/swarmplot/src/props.ts @@ -17,13 +17,14 @@ export const defaultProps = { colorBy: 'group', borderWidth: 0, borderColor: 'rgba(0, 0, 0, 0)', - layers: ['grid', 'axes', 'circles', 'annotations', 'mesh'] as SwarmPlotLayerId[], + layers: ['grid', 'axes', 'circles', 'annotations', 'mesh', 'legends'] as SwarmPlotLayerId[], enableGridX: true, enableGridY: true, axisTop: {}, axisRight: {}, axisBottom: {}, axisLeft: {}, + legends: [], isInteractive: true, useMesh: false, debugMesh: false, diff --git a/packages/swarmplot/src/types.ts b/packages/swarmplot/src/types.ts index 0b4521a830..b7dc95bafe 100644 --- a/packages/swarmplot/src/types.ts +++ b/packages/swarmplot/src/types.ts @@ -4,9 +4,18 @@ import { ForceX, ForceY, ForceCollide } from 'd3-force' import { PropertyAccessor, ValueFormat, Theme, ModernMotionProps, Box, Margin } from '@nivo/core' import { InheritedColorConfig, OrdinalColorScaleConfig } from '@nivo/colors' import { AxisProps, CanvasAxisProps } from '@nivo/axes' -import { ScaleLinear, ScaleLinearSpec, ScaleTime, ScaleTimeSpec, TicksSpec } from '@nivo/scales' +import { + ScaleLinear, + ScaleLinearSpec, + ScaleLog, + ScaleLogSpec, + ScaleTime, + ScaleTimeSpec, + TicksSpec, +} from '@nivo/scales' import { AnnotationMatcher } from '@nivo/annotations' import { ScaleOrdinal } from 'd3-scale' +import { LegendProps } from '@nivo/legends' export interface ComputedDatum { id: string @@ -33,14 +42,11 @@ export type SimulationForces = { collision: ForceCollide> } -export type SwarmPlotLayerId = 'grid' | 'axes' | 'circles' | 'annotations' | 'mesh' +export type SwarmPlotLayerId = 'grid' | 'axes' | 'circles' | 'annotations' | 'mesh' | 'legends' export interface SwarmPlotCustomLayerProps< RawDatum, - Scale extends - | ScaleLinear - | ScaleTime - | ScaleOrdinal = ScaleLinear + Scale extends SwarmPlotValueScale | ScaleOrdinal > { nodes: ComputedDatum[] xScale: Scale @@ -54,18 +60,12 @@ export interface SwarmPlotCustomLayerProps< export type SwarmPlotCustomLayer< RawDatum, - Scale extends - | ScaleLinear - | ScaleTime - | ScaleOrdinal = ScaleLinear + Scale extends SwarmPlotValueScale | ScaleOrdinal > = React.FC> export type SwarmPlotLayer< RawDatum, - Scale extends - | ScaleLinear - | ScaleTime - | ScaleOrdinal = ScaleLinear + Scale extends SwarmPlotValueScale | ScaleOrdinal > = SwarmPlotLayerId | SwarmPlotCustomLayer export type SizeSpec = @@ -91,6 +91,17 @@ export type MouseHandlers = { onMouseLeave?: MouseHandler } +// replace in future by a type from @nivo/legends +export type SwarmPlotLegendData = { + id: string | number + label: string | number + color: string +} + +export type SwarmPlotValueScaleSpec = ScaleLinearSpec | ScaleTimeSpec | ScaleLogSpec + +export type SwarmPlotValueScale = ScaleLinear | ScaleTime | ScaleLog + export type SwarmPlotCommonProps = { data: RawDatum[] width: number @@ -99,7 +110,7 @@ export type SwarmPlotCommonProps = { groups: string[] id: PropertyAccessor value: PropertyAccessor - valueScale: ScaleLinearSpec | ScaleTimeSpec + valueScale: SwarmPlotValueScaleSpec valueFormat: ValueFormat groupBy: PropertyAccessor size: SizeSpec @@ -122,10 +133,9 @@ export type SwarmPlotCommonProps = { debugMesh: boolean tooltip: (props: ComputedDatum) => JSX.Element annotations: AnnotationMatcher>[] - layers: SwarmPlotLayer< - RawDatum, - ScaleLinear | ScaleTime | ScaleOrdinal - >[] + layers: SwarmPlotLayer>[] + legendLabel?: PropertyAccessor + legends: LegendProps[] animate: boolean motionConfig: ModernMotionProps['motionConfig'] role: string diff --git a/packages/swarmplot/stories/SwarmPlot.stories.tsx b/packages/swarmplot/stories/SwarmPlot.stories.tsx index 192d95c64e..14d91ddbce 100644 --- a/packages/swarmplot/stories/SwarmPlot.stories.tsx +++ b/packages/swarmplot/stories/SwarmPlot.stories.tsx @@ -133,3 +133,77 @@ stories.add('using time scale', () => ( layout="horizontal" /> )) + +stories.add('using log scale', () => { + // ensure that dataset has price as a non-negative value + const data = commonProps.data.map(datum => ({ + ...datum, + price: Math.max(1, datum.price), + })) + const customLegendLabels: { [key: string]: string } = { + 'group A': 'A', + 'group B': 'B', + 'group C': 'C', + } + return ( + customLegendLabels[datum.group as string]} + legends={[ + { + anchor: 'bottom-left', + direction: 'row', + itemHeight: 20, + itemWidth: 60, + translateY: 50, + }, + { + anchor: 'bottom-right', + direction: 'row', + itemHeight: 20, + itemWidth: 80, + translateY: 50, + data: [ + { + id: 'A', + label: 'custom', + color: '#000000', + }, + { + id: 'B', + label: 'legends', + color: '#888888', + }, + ], + }, + ]} + /> + ) +}) diff --git a/packages/swarmplot/stories/SwarmPlotCanvas.stories.tsx b/packages/swarmplot/stories/SwarmPlotCanvas.stories.tsx index b72cea30e8..4384a45290 100644 --- a/packages/swarmplot/stories/SwarmPlotCanvas.stories.tsx +++ b/packages/swarmplot/stories/SwarmPlotCanvas.stories.tsx @@ -61,3 +61,58 @@ stories.add('using annotations', () => ( ]} /> )) + +stories.add('using log scale', () => { + // ensure that dataset has price as a non-negative value + const data = commonProps.data + .map(datum => ({ + ...datum, + price: Math.max(1, datum.price), + })) + .filter(datum => datum.group != 'group G') + return ( + + ) +})