diff --git a/examples/codesandbox/src/charts/Chord.tsx b/examples/codesandbox/src/charts/Chord.tsx index 9c46c7dc9b..aece9fbdeb 100644 --- a/examples/codesandbox/src/charts/Chord.tsx +++ b/examples/codesandbox/src/charts/Chord.tsx @@ -11,8 +11,8 @@ export function Chord() { const [data, flavor] = useChart(() => generateChordData({ size: 7 })) if (flavor === 'canvas') { - return + return } - return + return } diff --git a/packages/chord/index.d.ts b/packages/chord/_old_index.d.ts similarity index 100% rename from packages/chord/index.d.ts rename to packages/chord/_old_index.d.ts diff --git a/packages/chord/package.json b/packages/chord/package.json index 51c7d28824..7623a38d6e 100644 --- a/packages/chord/package.json +++ b/packages/chord/package.json @@ -21,11 +21,12 @@ ], "main": "./dist/nivo-chord.cjs.js", "module": "./dist/nivo-chord.es.js", + "typings": "./dist/types/index.d.ts", "files": [ "README.md", "LICENSE.md", - "index.d.ts", - "dist/" + "dist/", + "!dist/tsconfig.tsbuildinfo" ], "dependencies": { "@nivo/arcs": "0.77.0", @@ -38,11 +39,11 @@ "react-motion": "^0.5.2" }, "devDependencies": { - "@nivo/core": "0.77.0" + "@nivo/core": "0.77.0", + "@types/d3-chord": "^3.0.1" }, "peerDependencies": { "@nivo/core": "0.77.0", - "prop-types": ">= 15.5.10 < 16.0.0", "react": ">= 16.14.0 < 18.0.0" }, "publishConfig": { diff --git a/packages/chord/src/Chord.js b/packages/chord/src/Chord.tsx similarity index 57% rename from packages/chord/src/Chord.js rename to packages/chord/src/Chord.tsx index 0bbeb153a7..ba09631b6e 100644 --- a/packages/chord/src/Chord.js +++ b/packages/chord/src/Chord.tsx @@ -1,51 +1,55 @@ -import { Fragment } from 'react' -import { withContainer, SvgWrapper, useDimensions, useTheme } from '@nivo/core' +import { createElement, Fragment, ReactNode } from 'react' +import { Container, SvgWrapper, useDimensions, useTheme } from '@nivo/core' import { useInheritedColor } from '@nivo/colors' import { BoxLegendSvg } from '@nivo/legends' -import { ChordPropTypes, ChordDefaultProps } from './props' -import { useChord, useChordSelection, useChordLayerContext } from './hooks' -import ChordRibbons from './ChordRibbons' -import ChordArcs from './ChordArcs' -import ChordLabels from './ChordLabels' +import { svgDefaultProps } from './defaults' +import { useChord, useChordSelection, useCustomLayerProps } from './hooks' +import { ChordRibbons } from './ChordRibbons' +import { ChordArcs } from './ChordArcs' +import { ChordLabels } from './ChordLabels' +import { ChordSvgProps, LayerId } from './types' -const Chord = ({ - margin: partialMargin, - width, - height, +type InnerChordProps = Omit +const InnerChord = ({ + data, keys, - matrix, label, valueFormat, - innerRadiusRatio, - innerRadiusOffset, - padAngle, - - layers, - - colors, - - arcBorderWidth, - arcBorderColor, - arcOpacity, - arcHoverOpacity, - arcHoverOthersOpacity, - arcTooltip, - - ribbonBorderWidth, - ribbonBorderColor, - ribbonBlendMode, - ribbonOpacity, - ribbonHoverOpacity, - ribbonHoverOthersOpacity, - ribbonTooltip, - - enableLabel, - labelOffset, - labelRotation, - labelTextColor, - - isInteractive, + + margin: partialMargin, + width, + height, + + innerRadiusRatio = svgDefaultProps.innerRadiusRatio, + innerRadiusOffset = svgDefaultProps.innerRadiusOffset, + padAngle = svgDefaultProps.padAngle, + + layers = svgDefaultProps.layers, + + colors = svgDefaultProps.colors, + + arcBorderWidth = svgDefaultProps.arcBorderWidth, + arcBorderColor = svgDefaultProps.arcBorderColor, + arcOpacity = svgDefaultProps.arcOpacity, + arcHoverOpacity = svgDefaultProps.arcHoverOpacity, + arcHoverOthersOpacity = svgDefaultProps.arcHoverOthersOpacity, + arcTooltip = svgDefaultProps.arcTooltip, + + ribbonBorderWidth = svgDefaultProps.ribbonBorderWidth, + ribbonBorderColor = svgDefaultProps.ribbonBorderColor, + ribbonBlendMode = svgDefaultProps.ribbonBlendMode, + ribbonOpacity = svgDefaultProps.ribbonOpacity, + ribbonHoverOpacity = svgDefaultProps.ribbonHoverOpacity, + ribbonHoverOthersOpacity = svgDefaultProps.ribbonHoverOthersOpacity, + ribbonTooltip = svgDefaultProps.ribbonTooltip, + + enableLabel = svgDefaultProps.enableLabel, + labelOffset = svgDefaultProps.labelOffset, + labelRotation = svgDefaultProps.labelRotation, + labelTextColor = svgDefaultProps.labelTextColor, + + isInteractive = svgDefaultProps.isInteractive, onArcMouseEnter, onArcMouseMove, onArcMouseLeave, @@ -55,9 +59,13 @@ const Chord = ({ onRibbonMouseLeave, onRibbonClick, - legends, - role, -}) => { + legends = svgDefaultProps.legends, + + role = svgDefaultProps.role, + ariaLabel, + ariaLabelledBy, + ariaDescribedBy, +}: InnerChordProps) => { const { margin, innerWidth, innerHeight, outerWidth, outerHeight } = useDimensions( width, height, @@ -65,8 +73,8 @@ const Chord = ({ ) const { center, radius, arcGenerator, ribbonGenerator, arcs, ribbons } = useChord({ + data, keys, - matrix, label, valueFormat, width: innerWidth, @@ -93,7 +101,7 @@ const Chord = ({ const getArcBorderColor = useInheritedColor(arcBorderColor, theme) const getRibbonBorderColor = useInheritedColor(ribbonBorderColor, theme) - const layerContext = useChordLayerContext({ + const customLayerProps = useCustomLayerProps({ center, radius, arcs, @@ -110,8 +118,15 @@ const Chord = ({ color: arc.color, })) - const layerById = { - ribbons: ( + const layerById: Record = { + ribbons: null, + arcs: null, + labels: null, + legends: null, + } + + if (layers.includes('ribbons')) { + layerById.ribbons = ( - ), - arcs: ( + ) + } + + if (layers.includes('arcs')) { + layerById.arcs = ( - ), - labels: null, - legends: ( - - {legends.map((legend, i) => ( - - ))} - - ), + ) } - if (enableLabel === true) { + if (layers.includes('labels') && enableLabel) { layerById.labels = ( 0) { + layerById.legends = ( + + {legends.map((legend, i) => ( + + ))} + + ) + } + return ( {layers.map((layer, i) => { - if (layerById[layer] !== undefined) { - return layerById[layer] - } if (typeof layer === 'function') { - return {layer(layerContext)} + return {createElement(layer, customLayerProps)} } - return null + return layerById?.[layer] ?? null })} ) } -Chord.propTypes = ChordPropTypes -Chord.defaultProps = ChordDefaultProps - -export default withContainer(Chord) +export const Chord = ({ + isInteractive = svgDefaultProps.isInteractive, + animate = svgDefaultProps.animate, + motionConfig = svgDefaultProps.motionConfig, + theme, + renderWrapper, + ...otherProps +}: ChordSvgProps) => ( + + + +) diff --git a/packages/chord/src/ChordArc.js b/packages/chord/src/ChordArc.tsx similarity index 68% rename from packages/chord/src/ChordArc.js rename to packages/chord/src/ChordArc.tsx index 0bc39fe07c..b85bea1e05 100644 --- a/packages/chord/src/ChordArc.js +++ b/packages/chord/src/ChordArc.tsx @@ -1,8 +1,25 @@ -import { createElement, memo, useMemo } from 'react' -import PropTypes from 'prop-types' +import { createElement, memo, useMemo, MouseEvent } from 'react' import { useTooltip } from '@nivo/tooltip' +import { ArcDatum, ChordCommonProps } from './types' -const ChordArc = memo( +interface ChordArcProps { + arc: ArcDatum + startAngle: number + endAngle: number + arcGenerator: any + borderWidth: number + getBorderColor: (arc: ArcDatum) => string + opacity: number + setCurrent: (arc: ArcDatum | null) => void + isInteractive: ChordCommonProps['isInteractive'] + onMouseEnter?: ChordCommonProps['onArcMouseEnter'] + onMouseMove?: ChordCommonProps['onArcMouseMove'] + onMouseLeave?: ChordCommonProps['onArcMouseLeave'] + onClick?: ChordCommonProps['onArcClick'] + tooltip: ChordCommonProps['arcTooltip'] +} + +export const ChordArc = memo( ({ arc, startAngle, @@ -18,35 +35,42 @@ const ChordArc = memo( onMouseLeave, onClick, tooltip, - }) => { + }: ChordArcProps) => { const { showTooltipFromEvent, hideTooltip } = useTooltip() const handleMouseEnter = useMemo(() => { if (!isInteractive) return undefined - return event => { + + return (event: MouseEvent) => { setCurrent(arc) showTooltipFromEvent(createElement(tooltip, { arc }), event) onMouseEnter && onMouseEnter(arc, event) } }, [isInteractive, showTooltipFromEvent, tooltip, arc, onMouseEnter]) + const handleMouseMove = useMemo(() => { if (!isInteractive) return undefined - return event => { + + return (event: MouseEvent) => { showTooltipFromEvent(createElement(tooltip, { arc }), event) onMouseMove && onMouseMove(arc, event) } }, [isInteractive, showTooltipFromEvent, tooltip, arc, onMouseMove]) + const handleMouseLeave = useMemo(() => { if (!isInteractive) return undefined - return event => { + + return (event: MouseEvent) => { setCurrent(null) hideTooltip() onMouseLeave && onMouseLeave(arc, event) } }, [isInteractive, hideTooltip, arc, onMouseLeave]) + const handleClick = useMemo(() => { if (!isInteractive || !onClick) return undefined - return event => onClick(arc, event) + + return (event: MouseEvent) => onClick(arc, event) }, [isInteractive, arc, onClick]) return ( @@ -65,23 +89,3 @@ const ChordArc = memo( ) } ) - -ChordArc.displayName = 'ChordArc' -ChordArc.propTypes = { - arc: PropTypes.object.isRequired, - startAngle: PropTypes.number.isRequired, - endAngle: PropTypes.number.isRequired, - arcGenerator: PropTypes.func.isRequired, - borderWidth: PropTypes.number.isRequired, - getBorderColor: PropTypes.func.isRequired, - opacity: PropTypes.number.isRequired, - setCurrent: PropTypes.func.isRequired, - isInteractive: PropTypes.bool.isRequired, - onMouseEnter: PropTypes.func, - onMouseMove: PropTypes.func, - onMouseLeave: PropTypes.func, - onClick: PropTypes.func, - tooltip: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired, -} - -export default ChordArc diff --git a/packages/chord/src/ChordArcTooltip.js b/packages/chord/src/ChordArcTooltip.js deleted file mode 100644 index 8452fa9516..0000000000 --- a/packages/chord/src/ChordArcTooltip.js +++ /dev/null @@ -1,21 +0,0 @@ -import { memo } from 'react' -import PropTypes from 'prop-types' -import { BasicTooltip } from '@nivo/tooltip' - -const ChordArcTooltip = memo(({ arc }) => { - return ( - - ) -}) - -ChordArcTooltip.displayName = 'ChordArcTooltip' -ChordArcTooltip.propTypes = { - arc: PropTypes.object.isRequired, -} - -export default ChordArcTooltip diff --git a/packages/chord/src/ChordArcTooltip.tsx b/packages/chord/src/ChordArcTooltip.tsx new file mode 100644 index 0000000000..9d8cb24d49 --- /dev/null +++ b/packages/chord/src/ChordArcTooltip.tsx @@ -0,0 +1,7 @@ +import { memo } from 'react' +import { BasicTooltip } from '@nivo/tooltip' +import { ArcTooltipComponentProps } from './types' + +export const ChordArcTooltip = memo(({ arc }: ArcTooltipComponentProps) => ( + +)) diff --git a/packages/chord/src/ChordArcs.js b/packages/chord/src/ChordArcs.tsx similarity index 56% rename from packages/chord/src/ChordArcs.js rename to packages/chord/src/ChordArcs.tsx index 25c6f7cc93..2d31ba2cc9 100644 --- a/packages/chord/src/ChordArcs.js +++ b/packages/chord/src/ChordArcs.tsx @@ -1,11 +1,26 @@ import { memo } from 'react' -import PropTypes from 'prop-types' import { TransitionMotion, spring } from 'react-motion' -import { interpolateColor, getInterpolatedColor } from '@nivo/colors' +import { interpolateColor } from '@nivo/colors' import { useMotionConfig } from '@nivo/core' -import ChordArc from './ChordArc' +import { ChordArc } from './ChordArc' +import { ArcDatum, ChordCommonProps } from './types' -const ChordArcs = memo( +interface ChordArcsProps { + arcs: ArcDatum[] + arcGenerator: any + borderWidth: ChordCommonProps['arcBorderWidth'] + getBorderColor: (arc: ArcDatum) => string + getOpacity: (arc: ArcDatum) => number + setCurrent: (arc: ArcDatum | null) => void + isInteractive: ChordCommonProps['isInteractive'] + onMouseEnter?: ChordCommonProps['onArcMouseEnter'] + onMouseMove?: ChordCommonProps['onArcMouseMove'] + onMouseLeave?: ChordCommonProps['onArcMouseLeave'] + onClick?: ChordCommonProps['onArcClick'] + tooltip: ChordCommonProps['arcTooltip'] +} + +export const ChordArcs = memo( ({ arcs, borderWidth, @@ -19,33 +34,35 @@ const ChordArcs = memo( onMouseLeave, onClick, tooltip, - }) => { + }: ChordArcsProps) => { const { animate, springConfig: _springConfig } = useMotionConfig() - if (animate !== true) { - return arcs.map(arc => { - return ( - - ) - }) + if (!animate) { + return ( + <> + {arcs.map(arc => { + return ( + + ) + })} + + ) } const springConfig = { @@ -71,8 +88,6 @@ const ChordArcs = memo( {interpolatedStyles => ( <> {interpolatedStyles.map(({ key, style, data: arc }) => { - const color = getInterpolatedColor(style) - return ( { - const [x, y] = getRelativeCursor(canvasEl, event) - const centerX = margin.left + center[0] - const centerY = margin.top + center[1] - - return findArcUnderCursor(centerX, centerY, radius, innerRadius, arcs, x, y) -} - -const ChordCanvas = memo( - ({ - pixelRatio, - margin: partialMargin, - width, - height, - keys, - matrix, - label, - valueFormat, - innerRadiusRatio, - innerRadiusOffset, - padAngle, - layers, - colors, - arcBorderWidth, - arcBorderColor, - arcOpacity, - arcHoverOpacity, - arcHoverOthersOpacity, - arcTooltip, - ribbonBorderWidth, - ribbonBorderColor, - ribbonOpacity, - ribbonHoverOpacity, - ribbonHoverOthersOpacity, - enableLabel, - labelOffset, - labelRotation, - labelTextColor, - isInteractive, - onArcMouseEnter, - onArcMouseMove, - onArcMouseLeave, - onArcClick, - legends, - }) => { - const canvasEl = useRef(null) - const { innerWidth, innerHeight, outerWidth, outerHeight, margin } = useDimensions( - width, - height, - partialMargin - ) - - const { center, radius, innerRadius, arcGenerator, ribbonGenerator, arcs, ribbons } = - useChord({ - keys, - matrix, - label, - valueFormat, - width: innerWidth, - height: innerHeight, - innerRadiusRatio, - innerRadiusOffset, - padAngle, - colors, - }) - - const { currentArc, setCurrentArc, getArcOpacity, getRibbonOpacity } = useChordSelection({ - arcs, - arcOpacity, - arcHoverOpacity, - arcHoverOthersOpacity, - ribbons, - ribbonOpacity, - ribbonHoverOpacity, - ribbonHoverOthersOpacity, - }) - - const theme = useTheme() - const getLabelTextColor = useInheritedColor(labelTextColor, theme) - const getArcBorderColor = useInheritedColor(arcBorderColor, theme) - const getRibbonBorderColor = useInheritedColor(ribbonBorderColor, theme) - - const layerContext = useChordLayerContext({ - center, - radius, - arcs, - arcGenerator, - ribbons, - ribbonGenerator, - }) - - useEffect(() => { - canvasEl.current.width = outerWidth * pixelRatio - canvasEl.current.height = outerHeight * pixelRatio - - const ctx = canvasEl.current.getContext('2d') - - ctx.scale(pixelRatio, pixelRatio) - - ctx.fillStyle = theme.background - ctx.fillRect(0, 0, outerWidth, outerHeight) - - if (radius <= 0) return - - layers.forEach(layer => { - if (layer === 'ribbons') { - ctx.save() - ctx.translate(margin.left + center[0], margin.top + center[1]) - - ribbonGenerator.context(ctx) - ribbons.forEach(ribbon => { - ctx.save() - - ctx.globalAlpha = getRibbonOpacity(ribbon) - ctx.fillStyle = ribbon.source.color - ctx.beginPath() - ribbonGenerator(ribbon) - ctx.fill() - - if (ribbonBorderWidth > 0) { - ctx.strokeStyle = getRibbonBorderColor({ - ...ribbon, - color: ribbon.source.color, - }) - ctx.lineWidth = ribbonBorderWidth - ctx.stroke() - } - - ctx.restore() - }) - - ctx.restore() - } - - if (layer === 'arcs') { - ctx.save() - ctx.translate(margin.left + center[0], margin.top + center[1]) - - arcGenerator.context(ctx) - arcs.forEach(arc => { - ctx.save() - - ctx.globalAlpha = getArcOpacity(arc) - ctx.fillStyle = arc.color - ctx.beginPath() - arcGenerator(arc) - ctx.fill() - - if (arcBorderWidth > 0) { - ctx.strokeStyle = getArcBorderColor(arc) - ctx.lineWidth = arcBorderWidth - ctx.stroke() - } - - ctx.restore() - }) - - ctx.restore() - } - - if (layer === 'labels' && enableLabel === true) { - ctx.save() - ctx.translate(margin.left + center[0], margin.top + center[1]) - - ctx.font = `${theme.labels.text.fontSize}px ${ - theme.labels.text.fontFamily || 'sans-serif' - }` - - arcs.forEach(arc => { - const angle = midAngle(arc) - const props = getPolarLabelProps(radius + labelOffset, angle, labelRotation) - - ctx.save() - ctx.translate(props.x, props.y) - ctx.rotate(degreesToRadians(props.rotate)) - - ctx.textAlign = props.align - ctx.textBaseline = props.baseline - ctx.fillStyle = getLabelTextColor(arc, theme) - ctx.fillText(arc.label, 0, 0) - - ctx.restore() - }) - - ctx.restore() - } - - if (layer === 'legends') { - ctx.save() - ctx.translate(margin.left, margin.top) - - const legendData = arcs.map(arc => ({ - id: arc.id, - label: arc.label, - color: arc.color, - })) - - legends.forEach(legend => { - renderLegendToCanvas(ctx, { - ...legend, - data: legendData, - containerWidth: innerWidth, - containerHeight: innerHeight, - theme, - }) - }) - - ctx.restore() - } - - if (typeof layer === 'function') { - layer(ctx, layerContext) - } - }) - }, [ - canvasEl, - innerWidth, - innerHeight, - outerWidth, - outerHeight, - margin, - pixelRatio, - theme, - layers, - arcs, - arcGenerator, - getArcOpacity, - arcBorderWidth, - getArcBorderColor, - ribbons, - ribbonGenerator, - getRibbonOpacity, - ribbonBorderWidth, - getRibbonBorderColor, - enableLabel, - labelOffset, - labelRotation, - getLabelTextColor, - legends, - layerContext, - ]) - - const { showTooltipFromEvent, hideTooltip } = useTooltip() - - const handleMouseHover = useCallback( - event => { - const arc = getArcFromMouseEvent({ - event, - canvasEl: canvasEl.current, - center, - margin, - radius, - innerRadius, - arcs, - }) - - if (arc) { - setCurrentArc(arc) - showTooltipFromEvent(createElement(arcTooltip, { arc }), event) - !currentArc && onArcMouseEnter && onArcMouseEnter(arc, event) - onArcMouseMove && onArcMouseMove(arc, event) - currentArc && - currentArc.id !== arc.id && - onArcMouseLeave && - onArcMouseLeave(arc, event) - } else { - setCurrentArc(null) - hideTooltip() - currentArc && onArcMouseLeave && onArcMouseLeave(currentArc, event) - } - }, - [ - canvasEl, - center, - margin, - radius, - innerRadius, - arcs, - setCurrentArc, - showTooltipFromEvent, - hideTooltip, - onArcMouseEnter, - onArcMouseMove, - onArcMouseLeave, - ] - ) - - const handleMouseLeave = useCallback(() => { - setCurrentArc(null) - hideTooltip() - }, [setCurrentArc, hideTooltip]) - - const handleClick = useCallback( - event => { - if (!onArcClick) return - - const arc = getArcFromMouseEvent({ - event, - canvasEl: canvasEl.current, - center, - margin, - radius, - innerRadius, - arcs, - }) - - arc && onArcClick(arc, event) - }, - [canvasEl, center, margin, radius, innerRadius, arcs, onArcClick] - ) - - return ( - - ) - } -) - -ChordCanvas.propTypes = ChordCanvasPropTypes -ChordCanvas.defaultProps = ChordCanvasDefaultProps - -export default withContainer(ChordCanvas) diff --git a/packages/chord/src/ChordCanvas.tsx b/packages/chord/src/ChordCanvas.tsx new file mode 100644 index 0000000000..190450d4b1 --- /dev/null +++ b/packages/chord/src/ChordCanvas.tsx @@ -0,0 +1,381 @@ +import { createElement, useRef, useEffect, useCallback, MouseEvent } from 'react' +import { + useDimensions, + useTheme, + midAngle, + getPolarLabelProps, + degreesToRadians, + getRelativeCursor, + Margin, + Container, +} from '@nivo/core' +import { findArcUnderCursor } from '@nivo/arcs' +import { useInheritedColor } from '@nivo/colors' +import { renderLegendToCanvas } from '@nivo/legends' +import { useTooltip } from '@nivo/tooltip' +import { useChord, useChordSelection, useCustomLayerProps } from './hooks' +import { ArcDatum, ChordCanvasProps } from './types' +import { canvasDefaultProps } from './defaults' + +const getArcFromMouseEvent = ({ + event, + canvasEl, + center, + margin, + radius, + innerRadius, + arcs, +}: { + event: MouseEvent + canvasEl: HTMLCanvasElement + center: [number, number] + margin: Margin + radius: number + innerRadius: number + arcs: ArcDatum[] +}) => { + const [x, y] = getRelativeCursor(canvasEl, event) + const centerX = margin.left + center[0] + const centerY = margin.top + center[1] + + return findArcUnderCursor(centerX, centerY, radius, innerRadius, arcs, x, y) +} + +type InnerChordCanvasProps = Omit + +const InnerChordCanvas = ({ + pixelRatio = canvasDefaultProps.pixelRatio, + margin: partialMargin, + data, + keys, + width, + height, + label = canvasDefaultProps.label, + valueFormat, + innerRadiusRatio = canvasDefaultProps.innerRadiusRatio, + innerRadiusOffset = canvasDefaultProps.innerRadiusOffset, + padAngle = canvasDefaultProps.padAngle, + layers = canvasDefaultProps.layers, + colors = canvasDefaultProps.colors, + arcBorderWidth = canvasDefaultProps.arcBorderWidth, + arcBorderColor = canvasDefaultProps.arcBorderColor, + arcOpacity = canvasDefaultProps.arcOpacity, + arcHoverOpacity = canvasDefaultProps.arcHoverOpacity, + arcHoverOthersOpacity = canvasDefaultProps.arcHoverOthersOpacity, + arcTooltip = canvasDefaultProps.arcTooltip, + ribbonBorderWidth = canvasDefaultProps.ribbonBorderWidth, + ribbonBorderColor = canvasDefaultProps.ribbonBorderColor, + ribbonOpacity = canvasDefaultProps.ribbonOpacity, + ribbonHoverOpacity = canvasDefaultProps.ribbonHoverOpacity, + ribbonHoverOthersOpacity = canvasDefaultProps.arcHoverOthersOpacity, + enableLabel = canvasDefaultProps.enableLabel, + labelOffset = canvasDefaultProps.labelOffset, + labelRotation = canvasDefaultProps.labelRotation, + labelTextColor = canvasDefaultProps.labelTextColor, + isInteractive = canvasDefaultProps.isInteractive, + onArcMouseEnter, + onArcMouseMove, + onArcMouseLeave, + onArcClick, + legends = canvasDefaultProps.legends, +}: InnerChordCanvasProps) => { + const canvasEl = useRef(null) + + const { innerWidth, innerHeight, outerWidth, outerHeight, margin } = useDimensions( + width, + height, + partialMargin + ) + + const { center, radius, innerRadius, arcGenerator, ribbonGenerator, arcs, ribbons } = useChord({ + data, + keys, + label, + valueFormat, + width: innerWidth, + height: innerHeight, + innerRadiusRatio, + innerRadiusOffset, + padAngle, + colors, + }) + + const { currentArc, setCurrentArc, getArcOpacity, getRibbonOpacity } = useChordSelection({ + arcs, + arcOpacity, + arcHoverOpacity, + arcHoverOthersOpacity, + ribbons, + ribbonOpacity, + ribbonHoverOpacity, + ribbonHoverOthersOpacity, + }) + + const theme = useTheme() + const getLabelTextColor = useInheritedColor(labelTextColor, theme) + const getArcBorderColor = useInheritedColor(arcBorderColor, theme) + const getRibbonBorderColor = useInheritedColor(ribbonBorderColor, theme) + + const layerContext = useCustomLayerProps({ + center, + radius, + arcs, + arcGenerator, + ribbons, + ribbonGenerator, + }) + + useEffect(() => { + if (canvasEl.current === null) return + + canvasEl.current.width = outerWidth * pixelRatio + canvasEl.current.height = outerHeight * pixelRatio + + const ctx = canvasEl.current.getContext('2d')! + + ctx.scale(pixelRatio, pixelRatio) + + ctx.fillStyle = theme.background + ctx.fillRect(0, 0, outerWidth, outerHeight) + + if (radius <= 0) return + + layers.forEach(layer => { + if (layer === 'ribbons') { + ctx.save() + ctx.translate(margin.left + center[0], margin.top + center[1]) + + ribbonGenerator.context(ctx) + ribbons.forEach(ribbon => { + ctx.save() + + ctx.globalAlpha = getRibbonOpacity(ribbon) + ctx.fillStyle = ribbon.source.color + ctx.beginPath() + ribbonGenerator(ribbon) + ctx.fill() + + if (ribbonBorderWidth > 0) { + ctx.strokeStyle = getRibbonBorderColor({ + ...ribbon, + color: ribbon.source.color, + }) + ctx.lineWidth = ribbonBorderWidth + ctx.stroke() + } + + ctx.restore() + }) + + ctx.restore() + } + + if (layer === 'arcs') { + ctx.save() + ctx.translate(margin.left + center[0], margin.top + center[1]) + + arcGenerator.context(ctx) + arcs.forEach(arc => { + ctx.save() + + ctx.globalAlpha = getArcOpacity(arc) + ctx.fillStyle = arc.color + ctx.beginPath() + arcGenerator(arc) + ctx.fill() + + if (arcBorderWidth > 0) { + ctx.strokeStyle = getArcBorderColor(arc) + ctx.lineWidth = arcBorderWidth + ctx.stroke() + } + + ctx.restore() + }) + + ctx.restore() + } + + if (layer === 'labels' && enableLabel === true) { + ctx.save() + ctx.translate(margin.left + center[0], margin.top + center[1]) + + ctx.font = `${theme.labels.text.fontSize}px ${ + theme.labels.text.fontFamily || 'sans-serif' + }` + + arcs.forEach(arc => { + const angle = midAngle(arc) + const props = getPolarLabelProps(radius + labelOffset, angle, labelRotation) + + ctx.save() + ctx.translate(props.x, props.y) + ctx.rotate(degreesToRadians(props.rotate)) + + ctx.textAlign = props.align + ctx.textBaseline = props.baseline + ctx.fillStyle = getLabelTextColor(arc, theme) + ctx.fillText(arc.label, 0, 0) + + ctx.restore() + }) + + ctx.restore() + } + + if (layer === 'legends') { + ctx.save() + ctx.translate(margin.left, margin.top) + + const legendData = arcs.map(arc => ({ + id: arc.id, + label: arc.label, + color: arc.color, + })) + + legends.forEach(legend => { + renderLegendToCanvas(ctx, { + ...legend, + data: legendData, + containerWidth: innerWidth, + containerHeight: innerHeight, + theme, + }) + }) + + ctx.restore() + } + + if (typeof layer === 'function') { + layer(ctx, layerContext) + } + }) + }, [ + canvasEl, + innerWidth, + innerHeight, + outerWidth, + outerHeight, + margin, + pixelRatio, + theme, + layers, + arcs, + arcGenerator, + getArcOpacity, + arcBorderWidth, + getArcBorderColor, + ribbons, + ribbonGenerator, + getRibbonOpacity, + ribbonBorderWidth, + getRibbonBorderColor, + enableLabel, + labelOffset, + labelRotation, + getLabelTextColor, + legends, + layerContext, + ]) + + const { showTooltipFromEvent, hideTooltip } = useTooltip() + + const handleMouseHover = useCallback( + event => { + if (canvasEl.current === null) return + + const arc = getArcFromMouseEvent({ + event, + canvasEl: canvasEl.current, + center, + margin, + radius, + innerRadius, + arcs, + }) + + if (arc) { + setCurrentArc(arc) + showTooltipFromEvent(createElement(arcTooltip, { arc }), event) + !currentArc && onArcMouseEnter && onArcMouseEnter(arc, event) + onArcMouseMove && onArcMouseMove(arc, event) + currentArc && + currentArc.id !== arc.id && + onArcMouseLeave && + onArcMouseLeave(arc, event) + } else { + setCurrentArc(null) + hideTooltip() + currentArc && onArcMouseLeave && onArcMouseLeave(currentArc, event) + } + }, + [ + canvasEl, + center, + margin, + radius, + innerRadius, + arcs, + setCurrentArc, + showTooltipFromEvent, + hideTooltip, + onArcMouseEnter, + onArcMouseMove, + onArcMouseLeave, + ] + ) + + const handleMouseLeave = useCallback(() => { + setCurrentArc(null) + hideTooltip() + }, [setCurrentArc, hideTooltip]) + + const handleClick = useCallback( + event => { + if (canvasEl.current === null || !onArcClick) return + + const arc = getArcFromMouseEvent({ + event, + canvasEl: canvasEl.current, + center, + margin, + radius, + innerRadius, + arcs, + }) + + arc && onArcClick(arc, event) + }, + [canvasEl, center, margin, radius, innerRadius, arcs, onArcClick] + ) + + return ( + + ) +} + +export const ChordCanvas = ({ + theme, + isInteractive = canvasDefaultProps.isInteractive, + animate = canvasDefaultProps.animate, + motionConfig = canvasDefaultProps.motionConfig, + renderWrapper, + ...otherProps +}: ChordCanvasProps) => ( + + + +) diff --git a/packages/chord/src/ChordLabels.js b/packages/chord/src/ChordLabels.tsx similarity index 88% rename from packages/chord/src/ChordLabels.js rename to packages/chord/src/ChordLabels.tsx index 5dfb21954a..ae7194e20d 100644 --- a/packages/chord/src/ChordLabels.js +++ b/packages/chord/src/ChordLabels.tsx @@ -1,13 +1,21 @@ -import PropTypes from 'prop-types' +import { memo } from 'react' import { TransitionMotion, spring } from 'react-motion' import { midAngle, getPolarLabelProps, useTheme } from '@nivo/core' import { useMotionConfig } from '@nivo/core' +import { ArcDatum } from './types' -const ChordLabels = ({ arcs, radius, rotation, getColor }) => { +interface ChordLabelsProps { + arcs: ArcDatum[] + radius: number + rotation: number + getColor: (arc: ArcDatum) => string +} + +export const ChordLabels = memo(({ arcs, radius, rotation, getColor }: ChordLabelsProps) => { const theme = useTheme() const { animate, springConfig } = useMotionConfig() - if (animate !== true) { + if (animate) { return ( <> {arcs.map(arc => { @@ -75,13 +83,4 @@ const ChordLabels = ({ arcs, radius, rotation, getColor }) => { )} ) -} - -ChordLabels.propTypes = { - arcs: PropTypes.array.isRequired, - radius: PropTypes.number.isRequired, - rotation: PropTypes.number.isRequired, - getColor: PropTypes.func.isRequired, -} - -export default ChordLabels +}) diff --git a/packages/chord/src/ChordRibbon.js b/packages/chord/src/ChordRibbon.tsx similarity index 67% rename from packages/chord/src/ChordRibbon.js rename to packages/chord/src/ChordRibbon.tsx index 8f0e0f88bc..0ea6644826 100644 --- a/packages/chord/src/ChordRibbon.js +++ b/packages/chord/src/ChordRibbon.tsx @@ -1,9 +1,29 @@ -import { createElement, memo, useMemo } from 'react' -import PropTypes from 'prop-types' -import { blendModePropType } from '@nivo/core' +import { createElement, memo, useMemo, MouseEvent } from 'react' import { useTooltip } from '@nivo/tooltip' +import { ChordCommonProps, ChordSvgProps, RibbonDatum } from './types' -const ChordRibbon = memo( +interface ChordRibbonProps { + ribbon: RibbonDatum + ribbonGenerator: any + sourceStartAngle: number + sourceEndAngle: number + targetStartAngle: number + targetEndAngle: number + color: string + blendMode: NonNullable + opacity: number + borderWidth: number + getBorderColor: (ribbon: RibbonDatum) => string + setCurrent: (ribbon: RibbonDatum | null) => void + isInteractive: ChordCommonProps['isInteractive'] + tooltip: NonNullable + onMouseEnter: ChordSvgProps['onRibbonMouseEnter'] + onMouseMove: ChordSvgProps['onRibbonMouseMove'] + onMouseLeave: ChordSvgProps['onRibbonMouseLeave'] + onClick: ChordSvgProps['onRibbonClick'] +} + +export const ChordRibbon = memo( ({ ribbon, ribbonGenerator, @@ -23,35 +43,42 @@ const ChordRibbon = memo( onMouseLeave, onClick, tooltip, - }) => { + }: ChordRibbonProps) => { const { showTooltipFromEvent, hideTooltip } = useTooltip() const handleMouseEnter = useMemo(() => { if (!isInteractive) return undefined - return event => { + + return (event: MouseEvent) => { setCurrent(ribbon) showTooltipFromEvent(createElement(tooltip, { ribbon }), event) onMouseEnter && onMouseEnter(ribbon, event) } }, [isInteractive, showTooltipFromEvent, tooltip, ribbon, onMouseEnter]) + const handleMouseMove = useMemo(() => { if (!isInteractive) return undefined - return event => { + + return (event: MouseEvent) => { showTooltipFromEvent(createElement(tooltip, { ribbon }), event) onMouseMove && onMouseMove(ribbon, event) } }, [isInteractive, showTooltipFromEvent, tooltip, ribbon, onMouseMove]) + const handleMouseLeave = useMemo(() => { if (!isInteractive) return undefined - return event => { + + return (event: MouseEvent) => { setCurrent(null) hideTooltip() onMouseLeave && onMouseLeave(ribbon, event) } }, [isInteractive, hideTooltip, ribbon, onMouseLeave]) + const handleClick = useMemo(() => { if (!isInteractive || !onClick) return undefined - return event => onClick(ribbon, event) + + return (event: MouseEvent) => onClick(ribbon, event) }, [isInteractive, ribbon, onClick]) return ( @@ -80,27 +107,3 @@ const ChordRibbon = memo( ) } ) - -ChordRibbon.displayName = 'ChordRibbon' -ChordRibbon.propTypes = { - ribbon: PropTypes.object.isRequired, - ribbonGenerator: PropTypes.func.isRequired, - sourceStartAngle: PropTypes.number.isRequired, - sourceEndAngle: PropTypes.number.isRequired, - targetStartAngle: PropTypes.number.isRequired, - targetEndAngle: PropTypes.number.isRequired, - color: PropTypes.string.isRequired, - blendMode: blendModePropType.isRequired, - opacity: PropTypes.number.isRequired, - borderWidth: PropTypes.number.isRequired, - getBorderColor: PropTypes.func.isRequired, - setCurrent: PropTypes.func.isRequired, - isInteractive: PropTypes.bool.isRequired, - onMouseEnter: PropTypes.func, - onMouseMove: PropTypes.func, - onMouseLeave: PropTypes.func, - onClick: PropTypes.func, - tooltip: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired, -} - -export default ChordRibbon diff --git a/packages/chord/src/ChordRibbonTooltip.js b/packages/chord/src/ChordRibbonTooltip.js deleted file mode 100644 index 748113f8d2..0000000000 --- a/packages/chord/src/ChordRibbonTooltip.js +++ /dev/null @@ -1,33 +0,0 @@ -import { memo } from 'react' -import PropTypes from 'prop-types' -import { useTheme } from '@nivo/core' -import { TableTooltip, Chip } from '@nivo/tooltip' - -const ChordRibbonTooltip = memo(({ ribbon }) => { - const theme = useTheme() - - return ( - , - {ribbon.source.label}, - ribbon.source.formattedValue, - ], - [ - , - {ribbon.target.label}, - ribbon.target.formattedValue, - ], - ]} - /> - ) -}) - -ChordRibbonTooltip.displayName = 'ChordRibbonTooltip' -ChordRibbonTooltip.propTypes = { - ribbon: PropTypes.object.isRequired, -} - -export default ChordRibbonTooltip diff --git a/packages/chord/src/ChordRibbonTooltip.tsx b/packages/chord/src/ChordRibbonTooltip.tsx new file mode 100644 index 0000000000..f0efa76fff --- /dev/null +++ b/packages/chord/src/ChordRibbonTooltip.tsx @@ -0,0 +1,20 @@ +import { memo } from 'react' +import { TableTooltip, Chip } from '@nivo/tooltip' +import { RibbonTooltipComponentProps } from './types' + +export const ChordRibbonTooltip = memo(({ ribbon }: RibbonTooltipComponentProps) => ( + , + {ribbon.source.label}, + ribbon.source.formattedValue, + ], + [ + , + {ribbon.target.label}, + ribbon.target.formattedValue, + ], + ]} + /> +)) diff --git a/packages/chord/src/ChordRibbons.js b/packages/chord/src/ChordRibbons.tsx similarity index 87% rename from packages/chord/src/ChordRibbons.js rename to packages/chord/src/ChordRibbons.tsx index 1345fa45db..eca968551a 100644 --- a/packages/chord/src/ChordRibbons.js +++ b/packages/chord/src/ChordRibbons.tsx @@ -1,10 +1,10 @@ import { memo } from 'react' -import PropTypes from 'prop-types' import mapValues from 'lodash/mapValues' import { TransitionMotion, spring } from 'react-motion' -import { blendModePropType, midAngle, useMotionConfig } from '@nivo/core' +import { midAngle, useMotionConfig } from '@nivo/core' import { interpolateColor, getInterpolatedColor } from '@nivo/colors' -import ChordRibbon from './ChordRibbon' +import { ChordRibbon } from './ChordRibbon' +import { ChordCommonProps, ChordSvgProps, RibbonDatum } from './types' /** * Used to get ribbon angles, instead of using source and target arcs, @@ -68,7 +68,23 @@ const ribbonWillLeave = ...interpolateColor(ribbon.source.color, springConfig), }) -const ChordRibbons = memo( +interface ChordRibbonsProps { + ribbons: RibbonDatum[] + ribbonGenerator: any + borderWidth: ChordCommonProps['ribbonBorderWidth'] + getBorderColor: (ribbon: RibbonDatum) => string + getOpacity: (ribbon: RibbonDatum) => number + blendMode: NonNullable + isInteractive: ChordCommonProps['isInteractive'] + setCurrent: (ribbon: RibbonDatum | null) => void + tooltip: NonNullable + onMouseEnter: ChordSvgProps['onRibbonMouseEnter'] + onMouseMove: ChordSvgProps['onRibbonMouseMove'] + onMouseLeave: ChordSvgProps['onRibbonMouseLeave'] + onClick: ChordSvgProps['onRibbonClick'] +} + +export const ChordRibbons = memo( ({ ribbons, ribbonGenerator, @@ -83,7 +99,7 @@ const ChordRibbons = memo( onMouseLeave, onClick, tooltip, - }) => { + }: ChordRibbonsProps) => { const { animate, springConfig: _springConfig } = useMotionConfig() if (animate !== true) { @@ -180,22 +196,3 @@ const ChordRibbons = memo( ) } ) - -ChordRibbons.displayName = 'ChordRibbons' -ChordRibbons.propTypes = { - ribbons: PropTypes.array.isRequired, - ribbonGenerator: PropTypes.func.isRequired, - borderWidth: PropTypes.number.isRequired, - getBorderColor: PropTypes.func.isRequired, - getOpacity: PropTypes.func.isRequired, - blendMode: blendModePropType.isRequired, - isInteractive: PropTypes.bool.isRequired, - setCurrent: PropTypes.func.isRequired, - tooltip: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired, - onMouseEnter: PropTypes.func, - onMouseMove: PropTypes.func, - onMouseLeave: PropTypes.func, - onClick: PropTypes.func, -} - -export default ChordRibbons diff --git a/packages/chord/src/ResponsiveChord.js b/packages/chord/src/ResponsiveChord.js deleted file mode 100644 index ff023f5fb6..0000000000 --- a/packages/chord/src/ResponsiveChord.js +++ /dev/null @@ -1,10 +0,0 @@ -import { ResponsiveWrapper } from '@nivo/core' -import Chord from './Chord' - -const ResponsiveChord = props => ( - - {({ width, height }) => } - -) - -export default ResponsiveChord diff --git a/packages/chord/src/ResponsiveChord.tsx b/packages/chord/src/ResponsiveChord.tsx new file mode 100644 index 0000000000..e2d9df3fb7 --- /dev/null +++ b/packages/chord/src/ResponsiveChord.tsx @@ -0,0 +1,9 @@ +import { ResponsiveWrapper } from '@nivo/core' +import { Chord } from './Chord' +import { ChordSvgProps } from './types' + +export const ResponsiveChord = (props: Omit) => ( + + {({ width, height }) => } + +) diff --git a/packages/chord/src/ResponsiveChordCanvas.js b/packages/chord/src/ResponsiveChordCanvas.js deleted file mode 100644 index b684d67be9..0000000000 --- a/packages/chord/src/ResponsiveChordCanvas.js +++ /dev/null @@ -1,10 +0,0 @@ -import { ResponsiveWrapper } from '@nivo/core' -import ChordCanvas from './ChordCanvas' - -const ResponsiveChordCanvas = props => ( - - {({ width, height }) => } - -) - -export default ResponsiveChordCanvas diff --git a/packages/chord/src/ResponsiveChordCanvas.tsx b/packages/chord/src/ResponsiveChordCanvas.tsx new file mode 100644 index 0000000000..00a846555b --- /dev/null +++ b/packages/chord/src/ResponsiveChordCanvas.tsx @@ -0,0 +1,9 @@ +import { ResponsiveWrapper } from '@nivo/core' +import { ChordCanvas } from './ChordCanvas' +import { ChordCanvasProps } from './types' + +export const ResponsiveChordCanvas = (props: Omit) => ( + + {({ width, height }) => } + +) diff --git a/packages/chord/src/compute.js b/packages/chord/src/compute.ts similarity index 63% rename from packages/chord/src/compute.js rename to packages/chord/src/compute.ts index 1d6eac5cc4..a571b73bf1 100644 --- a/packages/chord/src/compute.js +++ b/packages/chord/src/compute.ts @@ -1,9 +1,22 @@ import { arc as d3Arc } from 'd3-shape' -import { chord as d3Chord, ribbon as d3Ribbon } from 'd3-chord' +import { chord as d3Chord, ChordLayout, ribbon as d3Ribbon } from 'd3-chord' +import { ArcDatum, ChordCommonProps, ChordDataProps } from './types' +import { OrdinalColorScale } from '@nivo/colors' -export const computeChordLayout = ({ padAngle }) => d3Chord().padAngle(padAngle) +export const computeChordLayout = ({ padAngle }: { padAngle: ChordCommonProps['padAngle'] }) => + d3Chord().padAngle(padAngle) -export const computeChordGenerators = ({ width, height, innerRadiusRatio, innerRadiusOffset }) => { +export const computeChordGenerators = ({ + width, + height, + innerRadiusRatio, + innerRadiusOffset, +}: { + width: number + height: number + innerRadiusRatio: ChordCommonProps['innerRadiusRatio'] + innerRadiusOffset: ChordCommonProps['innerRadiusOffset'] +}) => { const center = [width / 2, height / 2] const radius = Math.min(width, height) / 2 const innerRadius = radius * innerRadiusRatio @@ -18,13 +31,20 @@ export const computeChordGenerators = ({ width, height, innerRadiusRatio, innerR export const computeChordArcsAndRibbons = ({ chord, - getColor, + data, keys, - matrix, getLabel, formatValue, + getColor, +}: { + chord: ChordLayout + data: ChordDataProps['data'] + keys: ChordDataProps['keys'] + getLabel: (arc: ArcDatum) => string + formatValue: (valuee: number) => string + getColor: OrdinalColorScale }) => { - const ribbons = chord(matrix) + const ribbons = chord(data) const arcs = ribbons.groups.map(arc => { arc.id = keys[arc.index] diff --git a/packages/chord/src/defaults.ts b/packages/chord/src/defaults.ts new file mode 100644 index 0000000000..f642d6c175 --- /dev/null +++ b/packages/chord/src/defaults.ts @@ -0,0 +1,86 @@ +import { LayerId, ChordSvgProps, ChordCommonProps } from './types' +import { ChordArcTooltip } from './ChordArcTooltip' +import { ChordRibbonTooltip } from './ChordRibbonTooltip' + +export const commonDefaultProps: Omit< + ChordCommonProps, + | 'valueFormat' + | 'margin' + | 'theme' + | 'onArcMouseEnter' + | 'onArcMouseMove' + | 'onArcMouseLeave' + | 'onArcClick' + | 'onRibbonMouseEnter' + | 'onRibbonMouseMove' + | 'onRibbonMouseLeave' + | 'onRibbonClick' + | 'renderWrapper' + | 'ariaLabel' + | 'ariaLabelledBy' + | 'ariaDescribedBy' +> & { + layers: LayerId[] +} = { + layers: ['ribbons', 'arcs', 'labels', 'legends'], + + padAngle: 0, + innerRadiusRatio: 0.9, + innerRadiusOffset: 0, + + colors: { scheme: 'nivo' }, + + arcOpacity: 1, + arcHoverOpacity: 1, + arcHoverOthersOpacity: 0.15, + arcBorderWidth: 1, + arcBorderColor: { + from: 'color', + modifiers: [['darker', 0.4]], + }, + arcTooltip: ChordArcTooltip, + + ribbonOpacity: 0.5, + ribbonHoverOpacity: 0.85, + ribbonHoverOthersOpacity: 0.15, + ribbonBorderWidth: 1, + ribbonBorderColor: { + from: 'color', + modifiers: [['darker', 0.4]], + }, + ribbonBlendMode: 'normal', + + enableLabel: true, + label: 'id', + labelOffset: 12, + labelRotation: 0, + labelTextColor: { + from: 'color', + modifiers: [['darker', 1]], + }, + + isInteractive: true, + defaultActiveNodeIds: [], + + legends: [], + + animate: true, + motionConfig: 'gentle', + + role: 'img', +} + +export const svgDefaultProps = { + ...commonDefaultProps, + // arcComponent: + // ribbonComponent + ribbonBlendMode: 'normal' as NonNullable, + ribbonTooltip: ChordRibbonTooltip, +} + +export const canvasDefaultProps = { + ...commonDefaultProps, + // renderArc + // renderRibbon + pixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, +} diff --git a/packages/chord/src/hooks.js b/packages/chord/src/hooks.ts similarity index 56% rename from packages/chord/src/hooks.js rename to packages/chord/src/hooks.ts index 776952e7d4..2b54429a6f 100644 --- a/packages/chord/src/hooks.js +++ b/packages/chord/src/hooks.ts @@ -1,12 +1,24 @@ import { useMemo, useState } from 'react' import { useValueFormatter, getLabelGenerator } from '@nivo/core' -import { useOrdinalColorScale } from '@nivo/colors' +import { OrdinalColorScale, useOrdinalColorScale } from '@nivo/colors' import { computeChordLayout, computeChordGenerators, computeChordArcsAndRibbons } from './compute' +import { ArcDatum, ChordCommonProps, ChordDataProps, CustomLayerProps, RibbonDatum } from './types' +import { commonDefaultProps } from './defaults' -export const useChordLayout = ({ padAngle }) => +export const useChordLayout = ({ padAngle }: { padAngle: ChordCommonProps['padAngle'] }) => useMemo(() => computeChordLayout({ padAngle }), [padAngle]) -export const useChordGenerators = ({ width, height, innerRadiusRatio, innerRadiusOffset }) => +export const useChordGenerators = ({ + width, + height, + innerRadiusRatio, + innerRadiusOffset, +}: { + width: number + height: number + innerRadiusRatio: ChordCommonProps['innerRadiusRatio'] + innerRadiusOffset: ChordCommonProps['innerRadiusOffset'] +}) => useMemo( () => computeChordGenerators({ @@ -18,31 +30,56 @@ export const useChordGenerators = ({ width, height, innerRadiusRatio, innerRadiu [width, height, innerRadiusRatio, innerRadiusOffset] ) -export const useChordArcsAndRibbons = ({ chord, getColor, keys, matrix, getLabel, formatValue }) => +export const useChordArcsAndRibbons = ({ + chord, + getColor, + keys, + data, + getLabel, + formatValue, +}: { + chord: any + data: ChordDataProps['data'] + keys: ChordDataProps['keys'] + getLabel: (arc: ArcDatum) => string + formatValue: (value: number) => string + getColor: OrdinalColorScale +}) => useMemo( () => computeChordArcsAndRibbons({ chord, - getColor, + data, keys, - matrix, getLabel, formatValue, + getColor, }), - [chord, getColor, keys, matrix, getLabel, formatValue] + [chord, getColor, keys, data, getLabel, formatValue] ) export const useChord = ({ + data, keys, - matrix, - label, + label = commonDefaultProps.label, valueFormat, width, height, - innerRadiusRatio, - innerRadiusOffset, - padAngle, - colors, + innerRadiusRatio = commonDefaultProps.innerRadiusRatio, + innerRadiusOffset = commonDefaultProps.innerRadiusOffset, + padAngle = commonDefaultProps.padAngle, + colors = commonDefaultProps.colors, +}: { + data: ChordDataProps['data'] + keys: ChordDataProps['keys'] + label?: ChordCommonProps['label'] + valueFormat?: ChordCommonProps['valueFormat'] + width: number + height: number + innerRadiusRatio?: ChordCommonProps['innerRadiusRatio'] + innerRadiusOffset?: ChordCommonProps['innerRadiusOffset'] + padAngle?: ChordCommonProps['padAngle'] + colors?: ChordCommonProps['colors'] }) => { const chord = useChordLayout({ padAngle }) const { center, radius, innerRadius, arcGenerator, ribbonGenerator } = useChordGenerators({ @@ -52,14 +89,14 @@ export const useChord = ({ innerRadiusOffset, }) const getLabel = useMemo(() => getLabelGenerator(label), [label]) - const formatValue = useValueFormatter(valueFormat) + const formatValue = useValueFormatter(valueFormat) const getColor = useOrdinalColorScale(colors, 'id') const { arcs, ribbons } = useChordArcsAndRibbons({ chord, getColor, keys, - matrix, + data, getLabel, formatValue, }) @@ -79,16 +116,25 @@ export const useChord = ({ export const useChordSelection = ({ arcs, - arcOpacity, - arcHoverOpacity, - arcHoverOthersOpacity, + arcOpacity = commonDefaultProps.arcOpacity, + arcHoverOpacity = commonDefaultProps.arcHoverOpacity, + arcHoverOthersOpacity = commonDefaultProps.arcHoverOthersOpacity, ribbons, - ribbonOpacity, - ribbonHoverOpacity, - ribbonHoverOthersOpacity, + ribbonOpacity = commonDefaultProps.ribbonOpacity, + ribbonHoverOpacity = commonDefaultProps.ribbonHoverOpacity, + ribbonHoverOthersOpacity = commonDefaultProps.ribbonHoverOthersOpacity, +}: { + arcs: ArcDatum[] + arcOpacity?: ChordCommonProps['arcOpacity'] + arcHoverOpacity?: ChordCommonProps['arcHoverOpacity'] + arcHoverOthersOpacity?: ChordCommonProps['arcHoverOthersOpacity'] + ribbons: RibbonDatum[] + ribbonOpacity?: ChordCommonProps['ribbonOpacity'] + ribbonHoverOpacity?: ChordCommonProps['ribbonHoverOpacity'] + ribbonHoverOthersOpacity?: ChordCommonProps['ribbonHoverOthersOpacity'] }) => { - const [currentArc, setCurrentArc] = useState(null) - const [currentRibbon, setCurrentRibbon] = useState(null) + const [currentArc, setCurrentArc] = useState(null) + const [currentRibbon, setCurrentRibbon] = useState(null) const selection = useMemo(() => { const selectedArcIds = [] @@ -119,7 +165,7 @@ export const useChordSelection = ({ selection.selectedArcIds.length > 1 || selection.selectedRibbonIds.length > 0 const getArcOpacity = useMemo( - () => arc => { + () => (arc: ArcDatum) => { if (!hasSelection) return arcOpacity return selection.selectedArcIds.includes(arc.id) ? arcHoverOpacity @@ -128,7 +174,7 @@ export const useChordSelection = ({ [selection.selectedArcIds, arcOpacity, arcHoverOpacity, arcHoverOthersOpacity] ) const getRibbonOpacity = useMemo( - () => ribbon => { + () => (ribbon: RibbonDatum) => { if (!hasSelection) return ribbonOpacity return selection.selectedRibbonIds.includes(ribbon.id) ? ribbonHoverOpacity @@ -149,14 +195,21 @@ export const useChordSelection = ({ } } -export const useChordLayerContext = ({ +export const useCustomLayerProps = ({ center, radius, arcs, arcGenerator, ribbons, ribbonGenerator, -}) => +}: { + center: [number, number] + radius: number + arcs: ArcDatum[] + arcGenerator: any + ribbons: RibbonDatum[] + ribbonGenerator: any +}): CustomLayerProps => useMemo( () => ({ center, diff --git a/packages/chord/src/index.js b/packages/chord/src/index.js deleted file mode 100644 index 37355a35a8..0000000000 --- a/packages/chord/src/index.js +++ /dev/null @@ -1,7 +0,0 @@ -export { default as Chord } from './Chord' -export { default as ChordCanvas } from './ChordCanvas' -export { default as ResponsiveChord } from './ResponsiveChord' -export { default as ResponsiveChordCanvas } from './ResponsiveChordCanvas' -export * from './props' -export * from './compute' -export * from './hooks' diff --git a/packages/chord/src/index.ts b/packages/chord/src/index.ts new file mode 100644 index 0000000000..b9a1253486 --- /dev/null +++ b/packages/chord/src/index.ts @@ -0,0 +1,8 @@ +export * from './Chord' +export * from './ChordCanvas' +export * from './ResponsiveChord' +export * from './ResponsiveChordCanvas' +export * from './compute' +export * from './hooks' +export * from './types' +export * from './defaults' diff --git a/packages/chord/src/props.js b/packages/chord/src/props.js deleted file mode 100644 index 25c79d138b..0000000000 --- a/packages/chord/src/props.js +++ /dev/null @@ -1,126 +0,0 @@ -import PropTypes from 'prop-types' -import { blendModePropType, motionPropTypes } from '@nivo/core' -import { ordinalColorsPropType, inheritedColorPropType } from '@nivo/colors' -import { LegendPropShape } from '@nivo/legends' -import ChordArcTooltip from './ChordArcTooltip' -import ChordRibbonTooltip from './ChordRibbonTooltip' - -const commonPropTypes = { - keys: PropTypes.arrayOf(PropTypes.string).isRequired, - matrix: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)).isRequired, - valueFormat: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), - - padAngle: PropTypes.number.isRequired, - innerRadiusRatio: PropTypes.number.isRequired, - innerRadiusOffset: PropTypes.number.isRequired, - - layers: PropTypes.arrayOf( - PropTypes.oneOfType([ - PropTypes.oneOf(['ribbons', 'arcs', 'labels', 'legends']), - PropTypes.func, - ]) - ).isRequired, - - arcOpacity: PropTypes.number.isRequired, - arcHoverOpacity: PropTypes.number.isRequired, - arcHoverOthersOpacity: PropTypes.number.isRequired, - arcBorderWidth: PropTypes.number.isRequired, - arcBorderColor: inheritedColorPropType.isRequired, - onArcMouseEnter: PropTypes.func, - onArcMouseMove: PropTypes.func, - onArcMouseLeave: PropTypes.func, - onArcClick: PropTypes.func, - arcTooltip: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired, - - ribbonOpacity: PropTypes.number.isRequired, - ribbonHoverOpacity: PropTypes.number.isRequired, - ribbonHoverOthersOpacity: PropTypes.number.isRequired, - ribbonBorderWidth: PropTypes.number.isRequired, - ribbonBorderColor: inheritedColorPropType.isRequired, - ribbonBlendMode: blendModePropType.isRequired, - onRibbonMouseEnter: PropTypes.func, - onRibbonMouseMove: PropTypes.func, - onRibbonMouseLeave: PropTypes.func, - onRibbonClick: PropTypes.func, - ribbonTooltip: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired, - - enableLabel: PropTypes.bool.isRequired, - label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, - labelOffset: PropTypes.number.isRequired, - labelRotation: PropTypes.number.isRequired, - labelTextColor: inheritedColorPropType.isRequired, - - colors: ordinalColorsPropType.isRequired, - - isInteractive: PropTypes.bool.isRequired, - - legends: PropTypes.arrayOf(PropTypes.shape(LegendPropShape)).isRequired, -} - -export const ChordPropTypes = { - ...commonPropTypes, - ...motionPropTypes, - role: PropTypes.string.isRequired, -} - -export const ChordCanvasPropTypes = { - pixelRatio: PropTypes.number.isRequired, - ...commonPropTypes, -} - -const commonDefaultProps = { - padAngle: 0, - innerRadiusRatio: 0.9, - innerRadiusOffset: 0, - - layers: ['ribbons', 'arcs', 'labels', 'legends'], - - arcOpacity: 1, - arcHoverOpacity: 1, - arcHoverOthersOpacity: 0.15, - arcBorderWidth: 1, - arcBorderColor: { - from: 'color', - modifiers: [['darker', 0.4]], - }, - arcTooltip: ChordArcTooltip, - - ribbonOpacity: 0.5, - ribbonHoverOpacity: 0.85, - ribbonHoverOthersOpacity: 0.15, - ribbonBorderWidth: 1, - ribbonBorderColor: { - from: 'color', - modifiers: [['darker', 0.4]], - }, - ribbonBlendMode: 'normal', - ribbonTooltip: ChordRibbonTooltip, - - enableLabel: true, - label: 'id', - labelOffset: 12, - labelRotation: 0, - labelTextColor: { - from: 'color', - modifiers: [['darker', 1]], - }, - - colors: { scheme: 'nivo' }, - - legends: [], - - isInteractive: true, -} - -export const ChordDefaultProps = { - ...commonDefaultProps, - animate: true, - motionStiffness: 90, - motionDamping: 15, - role: 'img', -} - -export const ChordCanvasDefaultProps = { - ...commonDefaultProps, - pixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, -} diff --git a/packages/chord/src/types.ts b/packages/chord/src/types.ts new file mode 100644 index 0000000000..debb50b716 --- /dev/null +++ b/packages/chord/src/types.ts @@ -0,0 +1,131 @@ +import { AriaAttributes, MouseEvent, FunctionComponent } from 'react' +import { + Box, + Theme, + Dimensions, + ModernMotionProps, + CssMixBlendMode, + PropertyAccessor, + ValueFormat, +} from '@nivo/core' +import { InheritedColorConfig, OrdinalColorScaleConfig } from '@nivo/colors' +import { LegendProps } from '@nivo/legends' + +export type LayerId = 'ribbons' | 'arcs' | 'labels' | 'legends' +export interface CustomLayerProps { + center: [number, number] + radius: number + arcs: ArcDatum[] + arcGenerator: any + ribbons: RibbonDatum[] + ribbonGenerator: any +} +export type CustomLayer = FunctionComponent +export type CustomCanvasLayer = (ctx: CanvasRenderingContext2D, props: CustomLayerProps) => void + +export interface ChordDataProps { + data: number[][] + keys: string[] +} + +export interface ArcDatum { + id: string + index: number + label: string + value: number + formattedValue: number | string + startAngle: number + endAngle: number + color: string +} + +export interface RibbonSubject extends ArcDatum { + subindex: number +} + +export interface RibbonDatum { + id: string + source: RibbonSubject + target: RibbonSubject +} + +export interface ArcTooltipComponentProps { + arc: ArcDatum +} +export type ArcTooltipComponent = FunctionComponent + +export interface RibbonTooltipComponentProps { + ribbon: RibbonDatum +} +export type RibbonTooltipComponent = FunctionComponent + +export type ChordArcMouseHandler = (arc: any, event: MouseEvent) => void + +export type ChordRibbonMouseHandler = (ribbon: any, event: MouseEvent) => void + +export type ChordCommonProps = { + margin: Box + + label: PropertyAccessor + valueFormat: ValueFormat + + padAngle: number + innerRadiusRatio: number + innerRadiusOffset: number + + theme: Theme + colors: OrdinalColorScaleConfig + + arcOpacity: number + arcHoverOpacity: number + arcHoverOthersOpacity: number + arcBorderWidth: number + arcBorderColor: InheritedColorConfig + onArcMouseEnter: ChordArcMouseHandler + onArcMouseMove: ChordArcMouseHandler + onArcMouseLeave: ChordArcMouseHandler + onArcClick: ChordArcMouseHandler + arcTooltip: ArcTooltipComponent + + ribbonBlendMode: CssMixBlendMode + ribbonOpacity: number + ribbonHoverOpacity: number + ribbonHoverOthersOpacity: number + ribbonBorderWidth: number + ribbonBorderColor: InheritedColorConfig + + enableLabel: boolean + labelOffset: number + labelRotation: number + labelTextColor: InheritedColorConfig + + isInteractive: boolean + defaultActiveNodeIds: string[] + + legends: LegendProps[] + + renderWrapper: boolean + + role: string + ariaLabel: AriaAttributes['aria-label'] + ariaLabelledBy: AriaAttributes['aria-labelledby'] + ariaDescribedBy: AriaAttributes['aria-describedby'] +} & Required + +export type ChordSvgProps = Partial & + ChordDataProps & + Dimensions & { + onRibbonMouseEnter?: ChordRibbonMouseHandler + onRibbonMouseMove?: ChordRibbonMouseHandler + onRibbonMouseLeave?: ChordRibbonMouseHandler + onRibbonClick?: ChordRibbonMouseHandler + ribbonTooltip?: RibbonTooltipComponent + layers?: (LayerId | CustomLayer)[] + } + +export type ChordCanvasProps = Partial & + ChordDataProps & + Dimensions & { + layers?: (LayerId | CustomCanvasLayer)[] + pixelRatio?: number + } diff --git a/packages/chord/tsconfig.json b/packages/chord/tsconfig.json new file mode 100644 index 0000000000..855b4b2b74 --- /dev/null +++ b/packages/chord/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.types.json", + "compilerOptions": { + "outDir": "./dist/types", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/tsconfig.monorepo.json b/tsconfig.monorepo.json index 61ea607858..9dad0d9e0e 100644 --- a/tsconfig.monorepo.json +++ b/tsconfig.monorepo.json @@ -24,6 +24,7 @@ { "path": "./packages/bullet" }, { "path": "./packages/bump" }, { "path": "./packages/calendar" }, + { "path": "./packages/chord" }, { "path": "./packages/circle-packing" }, { "path": "./packages/funnel" }, { "path": "./packages/marimekko" }, diff --git a/website/src/data/components/chord/props.ts b/website/src/data/components/chord/props.ts index fc329f381b..2e3e23afb9 100644 --- a/website/src/data/components/chord/props.ts +++ b/website/src/data/components/chord/props.ts @@ -1,5 +1,4 @@ -// @ts-ignore -import { ChordDefaultProps as defaults } from '@nivo/chord' +import { commonDefaultProps as defaults } from '@nivo/chord' import { themeProperty, motionProperties, @@ -12,6 +11,19 @@ import { ChartProperty, Flavor } from '../../../types' const allFlavors: Flavor[] = ['svg', 'canvas', 'api'] const props: ChartProperty[] = [ + { + key: 'data', + group: 'Base', + help: 'The matrix used to compute the chord diagram.', + description: ` + The matrix used to compute the chord diagram, + it must be a square matrix, meaning each row length + must equal the row count. + `, + required: true, + type: 'number[][]', + flavors: allFlavors, + }, { key: 'keys', group: 'Base', @@ -40,19 +52,6 @@ const props: ChartProperty[] = [ flavors: allFlavors, type: 'string[]', }, - { - key: 'matrix', - group: 'Base', - help: 'The matrix used to compute the chord diagram.', - description: ` - The matrix used to compute the chord diagram, - it must be a square matrix, meaning each row length - must equal the row count. - `, - required: true, - type: 'Array', - flavors: allFlavors, - }, { key: 'valueFormat', group: 'Base', @@ -60,6 +59,7 @@ const props: ChartProperty[] = [ required: false, help: `Optional value formatter.`, flavors: allFlavors, + // control: { type: 'valueFormat'} }, ...chartDimensions(allFlavors), { diff --git a/website/src/pages/chord/canvas.js b/website/src/pages/chord/canvas.tsx similarity index 95% rename from website/src/pages/chord/canvas.js rename to website/src/pages/chord/canvas.tsx index d398e14040..8c23c21d34 100644 --- a/website/src/pages/chord/canvas.js +++ b/website/src/pages/chord/canvas.tsx @@ -1,6 +1,6 @@ import React from 'react' import { generateChordData } from '@nivo/generators' -import { ResponsiveChordCanvas } from '@nivo/chord' +import { ResponsiveChordCanvas, canvasDefaultProps } from '@nivo/chord' import { ComponentTemplate } from '../../components/components/ComponentTemplate' import meta from '../../data/components/chord/meta.yml' import mapper from '../../data/components/chord/mapper' @@ -112,12 +112,12 @@ const ChordCanvas = () => { properties={groups} initialProperties={initialProperties} propertiesMapper={mapper} + defaultProperties={canvasDefaultProps} codePropertiesMapper={(properties, data) => ({ keys: data.keys, ...properties, })} generateData={generateData} - dataKey="matrix" getDataSize={() => MATRIX_SIZE * MATRIX_SIZE + MATRIX_SIZE} getTabData={data => data.matrix} image={image} @@ -125,7 +125,7 @@ const ChordCanvas = () => { {(properties, data, theme, logAction) => { return ( { currentFlavor="svg" properties={groups} initialProperties={initialProperties} + defaultProperties={svgDefaultProps} propertiesMapper={mapper} codePropertiesMapper={(properties, data) => ({ keys: data.keys, ...properties, })} generateData={generateData} - dataKey="matrix" getTabData={data => data.matrix} image={image} > {(properties, data, theme, logAction) => { return (