diff --git a/packages/arcs/package.json b/packages/arcs/package.json index 6479bcc8a..5494bc7bc 100644 --- a/packages/arcs/package.json +++ b/packages/arcs/package.json @@ -28,6 +28,7 @@ "!dist/tsconfig.tsbuildinfo" ], "dependencies": { + "@nivo/colors": "0.67.0", "d3-shape": "^1.3.5", "react-spring": "9.0.0-rc.3" }, diff --git a/packages/arcs/src/canvas.ts b/packages/arcs/src/canvas.ts new file mode 100644 index 000000000..5cfd64ab2 --- /dev/null +++ b/packages/arcs/src/canvas.ts @@ -0,0 +1,48 @@ +import { + // @ts-ignore + textPropsByEngine, + CompleteTheme, +} from '@nivo/core' +import { DatumWithArcAndColor } from './types' +import { ArcLabel } from './useArcLabels' +import { ArcLinkLabel } from './links' + +export const drawCanvasArcLabels = ( + ctx: CanvasRenderingContext2D, + labels: ArcLabel[], + theme: CompleteTheme +) => { + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.font = `${theme.labels.text.fontSize}px ${theme.labels.text.fontFamily}` + + labels.forEach(label => { + ctx.fillStyle = label.textColor + ctx.fillText(`${label.label}`, label.x, label.y) + }) +} + +export const drawCanvasArcLinkLabels = ( + ctx: CanvasRenderingContext2D, + labels: ArcLinkLabel[], + theme: CompleteTheme, + strokeWidth: number +) => { + ctx.textBaseline = 'middle' + ctx.font = `${theme.labels.text.fontSize}px ${theme.labels.text.fontFamily}` + + labels.forEach(label => { + ctx.fillStyle = label.textColor + ctx.textAlign = textPropsByEngine.canvas.align[label.textAnchor] + ctx.fillText(`${label.label}`, label.x, label.y) + + ctx.beginPath() + ctx.strokeStyle = label.linkColor + ctx.lineWidth = strokeWidth + label.points.forEach((point, index) => { + if (index === 0) ctx.moveTo(point.x, point.y) + else ctx.lineTo(point.x, point.y) + }) + ctx.stroke() + }) +} diff --git a/packages/arcs/src/centers.ts b/packages/arcs/src/centers.ts index 0b5fd0626..3231e841d 100644 --- a/packages/arcs/src/centers.ts +++ b/packages/arcs/src/centers.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { useTransition, to, SpringValue } from 'react-spring' import { // @ts-ignore @@ -10,7 +11,6 @@ import { } from '@nivo/core' import { Arc, DatumWithArc } from './types' import { ArcTransitionMode, TransitionExtra, useArcTransitionMode } from './arcTransitionMode' -import { useMemo } from 'react' export const computeArcCenter = ( arc: Arc, @@ -83,6 +83,12 @@ export const useArcCentersTransition = { + x: number + y: number + data: Datum +} + /** * Compute an array of arc centers from an array of data containing arcs. * @@ -105,19 +111,15 @@ export const useArcCenters = < // 0.0: inner radius // 0.5: center // 1.0: outer radius - offset: number + offset?: number // arcs with a length below this (end angle - start angle in degrees) // are gonna be excluded, this can be typically used to avoid having // overlapping labels. - skipAngle: number + skipAngle?: number // this can be used to append extra properties to the centers, // can be used to compute a color/label for example. - computeExtraProps: (datum: Datum) => ExtraProps -}): ({ - x: number - y: number - data: Datum -} & ExtraProps)[] => + computeExtraProps?: (datum: Datum) => ExtraProps +}): (ArcCenter & ExtraProps)[] => useMemo( () => data diff --git a/packages/arcs/src/index.ts b/packages/arcs/src/index.ts index 31073311e..2eeabfdde 100644 --- a/packages/arcs/src/index.ts +++ b/packages/arcs/src/index.ts @@ -1,8 +1,11 @@ export * from './arcTransitionMode' +export * from './canvas' export * from './centers' export * from './interactivity' export * from './interpolateArc' +export * from './links' export * from './types' export * from './useAnimatedArc' export * from './useArcGenerator' +export * from './useArcLabels' export * from './useArcsTransition' diff --git a/packages/arcs/src/links.ts b/packages/arcs/src/links.ts new file mode 100644 index 000000000..217dec213 --- /dev/null +++ b/packages/arcs/src/links.ts @@ -0,0 +1,204 @@ +import { useCallback, useMemo } from 'react' +import { + // @ts-ignore + positionFromAngle, + // @ts-ignore + radiansToDegrees, + // @ts-ignore + getLabelGenerator, + useTheme, +} from '@nivo/core' +import { InheritedColorConfig, useInheritedColor } from '@nivo/colors' +import { DatumWithArc, DatumWithArcAndColor } from './types' +import { getNormalizedAngle } from './utils' + +interface Point { + x: number + y: number +} + +export interface ArcLink { + side: 'before' | 'after' + points: [Point, Point, Point] + data: Datum +} + +/** + * Compute the link of a single arc, returning its points, + * please not that points coordinates are relative to + * the center of the arc. + */ +export const computeArcLink = ( + datum: Datum, + offset: number, + diagonalLength: number, + straightLength: number +): ArcLink => { + const centerAngle = getNormalizedAngle( + datum.arc.startAngle + (datum.arc.endAngle - datum.arc.startAngle) / 2 - Math.PI / 2 + ) + const point0: Point = positionFromAngle(centerAngle, datum.arc.outerRadius + offset) + const point1: Point = positionFromAngle( + centerAngle, + datum.arc.outerRadius + offset + diagonalLength + ) + + let side: ArcLink['side'] + let point2: Point + if (centerAngle < Math.PI / 2 || centerAngle > Math.PI * 1.5) { + side = 'after' + point2 = { + x: point1.x + straightLength, + y: point1.y, + } + } else { + side = 'before' + point2 = { + x: point1.x - straightLength, + y: point1.y, + } + } + + return { + side, + points: [point0, point1, point2], + data: datum, + } +} + +/** + * Compute links for an array of data containing arcs. + * + * This is typically used to create labels for arcs. + */ +export const useArcLinks = < + Datum extends DatumWithArc, + ExtraProps extends Record = Record +>({ + data, + skipAngle = 0, + offset = 0.5, + diagonalLength, + straightLength, + computeExtraProps = () => ({} as ExtraProps), +}: { + data: Datum[] + // arcs with a length below this (end angle - start angle in degrees) + // are gonna be excluded, this can be typically used to avoid having + // overlapping labels. + skipAngle?: number + // offset from arc outer radius in pixels + offset?: number + // length of the diagonal segment of the link + diagonalLength: number + // length of the straight segment of the link + straightLength: number + // this can be used to append extra properties to the links, + // can be used to compute a color/label for example. + computeExtraProps?: (datum: ArcLink) => ExtraProps +}): (ArcLink & ExtraProps)[] => { + const links: ArcLink[] = useMemo( + () => + data + // filter out arcs with a length below `skipAngle` + .filter( + datum => + Math.abs(radiansToDegrees(datum.arc.endAngle - datum.arc.startAngle)) >= + skipAngle + ) + // compute the link for each eligible arc + .map(datum => computeArcLink(datum, offset, diagonalLength, straightLength)), + [data, skipAngle, offset, diagonalLength, straightLength] + ) + + // splitting memoization of links and extra props can be more efficient, + // this way if only `computeExtraProps` changes, we skip links computation. + return useMemo( + () => + links.map(link => ({ + ...computeExtraProps(link), + ...link, + })), + [links, computeExtraProps] + ) +} + +export interface ArcLinkLabel extends ArcLink { + x: number + y: number + label: string + linkColor: string + textAnchor: 'start' | 'end' + textColor: string +} + +/** + * Compute arc link labels, please note that the datum should + * contain a color in order to be able to compute the link/label text color. + * + * Please see `useArcLinks` for a more detailed explanation + * about the parameters. + */ +export const useArcLinkLabels = ({ + data, + skipAngle, + offset, + diagonalLength, + straightLength, + textOffset = 0, + label, + linkColor, + textColor, +}: { + data: Datum[] + skipAngle?: number + offset?: number + diagonalLength: number + straightLength: number + textOffset: number + // @todo come up with proper typing for label accessors, probably in `core` + label: any + linkColor: InheritedColorConfig + textColor: InheritedColorConfig +}) => { + const getLabel = useMemo(() => getLabelGenerator(label), [label]) + + const theme = useTheme() + const getLinkColor = useInheritedColor(linkColor, theme) + const getTextColor = useInheritedColor(textColor, theme) + + const computeExtraProps = useCallback( + (link: ArcLink) => { + const position = { + x: link.points[2].x, + y: link.points[2].y, + } + let textAnchor: ArcLinkLabel['textAnchor'] + if (link.side === 'before') { + position.x -= textOffset + textAnchor = 'end' + } else { + position.x += textOffset + textAnchor = 'start' + } + + return { + ...position, + label: getLabel(link.data), + linkColor: getLinkColor(link.data), + textAnchor, + textColor: getTextColor(link.data), + } + }, + [getLabel, getLinkColor, getTextColor] + ) + + return useArcLinks, keyof ArcLink>>({ + data, + skipAngle, + offset, + diagonalLength, + straightLength, + computeExtraProps, + }) +} diff --git a/packages/arcs/src/types.ts b/packages/arcs/src/types.ts index cf27a2478..74755ba91 100644 --- a/packages/arcs/src/types.ts +++ b/packages/arcs/src/types.ts @@ -16,4 +16,8 @@ export interface DatumWithArc { arc: Arc } +export interface DatumWithArcAndColor extends DatumWithArc { + color: string +} + export type ArcGenerator = D3Arc diff --git a/packages/arcs/src/useArcGenerator.ts b/packages/arcs/src/useArcGenerator.ts index fe9a8e625..fd2560081 100644 --- a/packages/arcs/src/useArcGenerator.ts +++ b/packages/arcs/src/useArcGenerator.ts @@ -2,6 +2,15 @@ import { useMemo } from 'react' import { arc as d3Arc } from 'd3-shape' import { ArcGenerator, Arc } from './types' +/** + * Memoize a d3 arc generator. + * + * Please note that both inner/outer radius should come + * aren't static and should come from the arc itself, + * while it requires more props on the arcs, it provides + * more flexibility because it's not limited to pie then + * but can also works with charts such as sunbursts. + */ export const useArcGenerator = ({ cornerRadius = 0, padAngle = 0, diff --git a/packages/arcs/src/useArcLabels.ts b/packages/arcs/src/useArcLabels.ts new file mode 100644 index 000000000..dcdef3aad --- /dev/null +++ b/packages/arcs/src/useArcLabels.ts @@ -0,0 +1,58 @@ +import { useCallback, useMemo } from 'react' +import { + // @ts-ignore + getLabelGenerator, + useTheme, +} from '@nivo/core' +import { InheritedColorConfig, useInheritedColor } from '@nivo/colors' +import { DatumWithArcAndColor } from './types' +import { useArcCenters, ArcCenter } from './centers' + +export interface ArcLabel extends ArcCenter { + label: string + textColor: string +} + +/** + * Compute arc labels, please note that the datum should + * contain a color in order to be able to compute the label text color. + * + * Please see `useArcCenters` for a more detailed explanation + * about the parameters. + */ +export const useArcLabels = ({ + data, + offset, + skipAngle, + label, + textColor, +}: { + data: Datum[] + offset?: number + skipAngle?: number + // @todo come up with proper typing for label accessors, probably in `core` + label: any + textColor: InheritedColorConfig +}) => { + const getLabel = useMemo(() => getLabelGenerator(label), [label]) + + const theme = useTheme() + const getTextColor = useInheritedColor(textColor, theme) + + const computeExtraProps = useCallback( + (datum: Datum) => { + return { + label: getLabel(datum), + textColor: getTextColor(datum), + } + }, + [getLabel, getTextColor] + ) + + return useArcCenters, keyof ArcCenter>>({ + data, + offset, + skipAngle, + computeExtraProps, + }) +} diff --git a/packages/arcs/src/utils.ts b/packages/arcs/src/utils.ts new file mode 100644 index 000000000..00da6e69e --- /dev/null +++ b/packages/arcs/src/utils.ts @@ -0,0 +1,12 @@ +/** + * Make sure an angle (expressed in radians) + * always fall in the range 0~2*PI. + */ +export const getNormalizedAngle = (angle: number) => { + let normalizedAngle = angle % (Math.PI * 2) + if (normalizedAngle < 0) { + normalizedAngle += Math.PI * 2 + } + + return normalizedAngle +} diff --git a/packages/core/src/lib/bridge.js b/packages/core/src/lib/bridge.js index 7bbbb9dd9..d12a95466 100644 --- a/packages/core/src/lib/bridge.js +++ b/packages/core/src/lib/bridge.js @@ -13,6 +13,9 @@ export const textPropsByEngine = { left: 'start', center: 'middle', right: 'end', + start: 'start', + middle: 'middle', + end: 'end', }, baseline: { top: 'text-before-edge', @@ -25,6 +28,9 @@ export const textPropsByEngine = { left: 'left', center: 'center', right: 'right', + start: 'left', + middle: 'center', + end: 'right', }, baseline: { top: 'top', diff --git a/packages/pie/src/PieCanvas.tsx b/packages/pie/src/PieCanvas.tsx index a6cb2b978..26e59fbd3 100644 --- a/packages/pie/src/PieCanvas.tsx +++ b/packages/pie/src/PieCanvas.tsx @@ -1,77 +1,27 @@ -import React, { createElement, useCallback, useEffect, useMemo, useRef } from 'react' +import React, { createElement, useEffect, useMemo, useRef } from 'react' import { // @ts-ignore getRelativeCursor, - // @ts-ignore - textPropsByEngine, - // @ts-ignore - getLabelGenerator, useDimensions, useTheme, Container, - Theme, } from '@nivo/core' // @ts-ignore import { renderLegendToCanvas } from '@nivo/legends' import { useInheritedColor, InheritedColorConfig } from '@nivo/colors' import { useTooltip } from '@nivo/tooltip' -import { Arc, findArcUnderCursor, useArcCenters } from '@nivo/arcs' -import { useNormalizedData, usePieFromBox, usePieRadialLabels } from './hooks' -import { ComputedDatum, PieCanvasProps, RadialLabelData } from './types' +import { + Arc, + findArcUnderCursor, + useArcLabels, + drawCanvasArcLabels, + useArcLinkLabels, + drawCanvasArcLinkLabels, +} from '@nivo/arcs' +import { useNormalizedData, usePieFromBox } from './hooks' +import { ComputedDatum, PieCanvasProps } from './types' import { defaultProps } from './props' -const drawSliceLabels = ( - ctx: CanvasRenderingContext2D, - labels: { - x: number - y: number - label: string - color: string - data: ComputedDatum - }[], - theme: Theme -) => { - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.font = `${theme.labels!.text!.fontSize}px ${theme.labels!.text!.fontFamily}` - - labels.forEach(label => { - ctx.save() - ctx.translate(label.x, label.y) - ctx.fillStyle = label.color - ctx.fillText(`${label.label}`, 0, 0) - ctx.restore() - }) -} - -const drawRadialLabels = ( - ctx: CanvasRenderingContext2D, - labels: RadialLabelData[], - theme: Theme, - linkStrokeWidth: number -) => { - ctx.textBaseline = 'middle' - ctx.font = `${theme.labels!.text!.fontSize}px ${theme.labels!.text!.fontFamily}` - - labels.forEach(label => { - ctx.save() - ctx.translate(label.position.x, label.position.y) - ctx.fillStyle = label.textColor - ctx.textAlign = textPropsByEngine.canvas.align[label.align] - ctx.fillText(`${label.text}`, 0, 0) - ctx.restore() - - ctx.beginPath() - ctx.strokeStyle = label.linkColor - ctx.lineWidth = linkStrokeWidth - label.line.forEach((point, index) => { - if (index === 0) ctx.moveTo(point.x, point.y) - else ctx.lineTo(point.x, point.y) - }) - if (linkStrokeWidth > 0) ctx.stroke() - }) -} - const InnerPieCanvas = ({ data, id = defaultProps.id, @@ -168,43 +118,24 @@ const InnerPieCanvas = ({ const getBorderColor = useInheritedColor>(borderColor, theme) - const radialLabels = usePieRadialLabels({ - enable: enableRadialLabels, - dataWithArc, - label: radialLabel, - textXOffset: radialLabelsTextXOffset, - textColor: radialLabelsTextColor, - radius, + const radialLabels = useArcLinkLabels>({ + data: dataWithArc, skipAngle: radialLabelsSkipAngle, - linkOffset: radialLabelsLinkOffset, - linkDiagonalLength: radialLabelsLinkDiagonalLength, - linkHorizontalLength: radialLabelsLinkHorizontalLength, + offset: radialLabelsLinkOffset, + diagonalLength: radialLabelsLinkDiagonalLength, + straightLength: radialLabelsLinkHorizontalLength, + label: radialLabel, linkColor: radialLabelsLinkColor, + textOffset: radialLabelsTextXOffset, + textColor: radialLabelsTextColor, }) - const getSliceLabel = useMemo(() => getLabelGenerator(sliceLabel), [sliceLabel]) - const getSliceLabelColor = useInheritedColor>( - sliceLabelsTextColor, - theme - ) - const computeSliceLabel = useCallback( - (datum: ComputedDatum) => ({ - label: getSliceLabel(datum), - color: getSliceLabelColor(datum), - }), - [getSliceLabel, getSliceLabelColor] - ) - const sliceLabels = useArcCenters< - ComputedDatum, - { - label: string - color: string - } - >({ + const sliceLabels = useArcLabels>({ data: dataWithArc, skipAngle: sliceLabelsSkipAngle, offset: sliceLabelsRadiusOffset, - computeExtraProps: computeSliceLabel, + label: sliceLabel, + textColor: sliceLabelsTextColor, }) useEffect(() => { @@ -244,11 +175,16 @@ const InnerPieCanvas = ({ }) if (enableRadialLabels === true) { - drawRadialLabels(ctx, radialLabels, theme, radialLabelsLinkStrokeWidth) + drawCanvasArcLinkLabels>( + ctx, + radialLabels, + theme, + radialLabelsLinkStrokeWidth + ) } if (enableSliceLabels === true) { - drawSliceLabels(ctx, sliceLabels, theme) + drawCanvasArcLabels>(ctx, sliceLabels, theme) } // legends assume a box rather than a center,