From d0cd500381294f0682eabd803b8c0ec3d652a713 Mon Sep 17 00:00:00 2001 From: plouc Date: Fri, 7 Jan 2022 21:50:13 +0900 Subject: [PATCH] feat(heatmap): migrate canvas implementation to TypeScript --- packages/heatmap/src/HeatMap.tsx | 34 ++- packages/heatmap/src/HeatMapCanvas.tsx | 288 +++++++++++------- packages/heatmap/src/HeatMapCellCircle.tsx | 95 ++---- packages/heatmap/src/HeatMapCellRect.tsx | 113 +++---- packages/heatmap/src/HeatMapCells.tsx | 233 +++++++------- packages/heatmap/src/HeatMapCells_old.tsx | 38 --- packages/heatmap/src/HeatMapTooltip.tsx | 20 +- .../heatmap/src/ResponsiveHeatMapCanvas.tsx | 16 +- packages/heatmap/src/canvas.tsx | 56 ++-- packages/heatmap/src/compute.ts | 11 +- packages/heatmap/src/defaults.ts | 23 +- packages/heatmap/src/hooks.ts | 180 +++++------ packages/heatmap/src/index.ts | 5 +- packages/heatmap/src/props.js | 100 ------ packages/heatmap/src/types.ts | 86 +++--- website/src/data/components/heatmap/props.ts | 14 +- website/src/pages/chord/index.tsx | 3 +- website/src/pages/heatmap/canvas.tsx | 121 +++++--- website/src/pages/heatmap/index.tsx | 123 ++------ 19 files changed, 704 insertions(+), 855 deletions(-) delete mode 100644 packages/heatmap/src/HeatMapCells_old.tsx delete mode 100644 packages/heatmap/src/props.js diff --git a/packages/heatmap/src/HeatMap.tsx b/packages/heatmap/src/HeatMap.tsx index 666e6a2e88..c73d83f076 100644 --- a/packages/heatmap/src/HeatMap.tsx +++ b/packages/heatmap/src/HeatMap.tsx @@ -7,7 +7,7 @@ import { HeatMapDatum, HeatMapCommonProps, HeatMapSvgProps, - LayerId, + LayerId, CustomLayerProps, } from './types' import { useHeatMap } from './hooks' import { svgDefaultProps } from './defaults' @@ -28,13 +28,15 @@ const InnerHeatMap = ({ width, height, margin: partialMargin, - forceSquare = svgDefaultProps.forceSquare, + // forceSquare = svgDefaultProps.forceSquare, xInnerPadding = svgDefaultProps.xInnerPadding, xOuterPadding = svgDefaultProps.xOuterPadding, yInnerPadding = svgDefaultProps.yInnerPadding, yOuterPadding = svgDefaultProps.yOuterPadding, - sizeVariation = svgDefaultProps.sizeVariation, - cellComponent = svgDefaultProps.cellComponent, + // sizeVariation = svgDefaultProps.sizeVariation, + cellComponent = svgDefaultProps.cellComponent as NonNullable< + HeatMapSvgProps['cellComponent'] + >, opacity = svgDefaultProps.opacity, activeOpacity = svgDefaultProps.activeOpacity, inactiveOpacity = svgDefaultProps.inactiveOpacity, @@ -48,10 +50,10 @@ const InnerHeatMap = ({ axisBottom = svgDefaultProps.axisBottom, axisLeft = svgDefaultProps.axisLeft, enableLabels = svgDefaultProps.enableLabels, - label = svgDefaultProps.label, - labelTextColor = svgDefaultProps.labelTextColor, - colors = svgDefaultProps.colors, - nanColor = svgDefaultProps.nanColor, + label = svgDefaultProps.label as HeatMapCommonProps['label'], + labelTextColor = svgDefaultProps.labelTextColor as HeatMapCommonProps['labelTextColor'], + colors = svgDefaultProps.colors as HeatMapCommonProps['colors'], + emptyColor = svgDefaultProps.emptyColor, legends = svgDefaultProps.legends, annotations = svgDefaultProps.annotations as HeatMapCommonProps['annotations'], isInteractive = svgDefaultProps.isInteractive, @@ -72,7 +74,7 @@ const InnerHeatMap = ({ partialMargin ) - const { xScale, yScale, cells, colorScale } = useHeatMap({ + const { xScale, yScale, cells, colorScale, activeCell, setActiveCell } = useHeatMap({ data, valueFormat, width: innerWidth, @@ -82,12 +84,14 @@ const InnerHeatMap = ({ yInnerPadding, yOuterPadding, colors, + emptyColor, opacity, activeOpacity, inactiveOpacity, borderColor, label, labelTextColor, + hoverTarget, }) const layerById: Record = { @@ -131,9 +135,11 @@ const InnerHeatMap = ({ cells={cells} + cellComponent={cellComponent} borderRadius={borderRadius} borderWidth={borderWidth} isInteractive={isInteractive} + setActiveCell={setActiveCell} onMouseEnter={onMouseEnter} onMouseMove={onMouseMove} onMouseLeave={onMouseLeave} @@ -145,7 +151,7 @@ const InnerHeatMap = ({ ) } - if (layers.includes('legends')) { + if (layers.includes('legends') && colorScale !== null) { layerById.legends = ( {legends.map((legend, index) => ( @@ -171,6 +177,12 @@ const InnerHeatMap = ({ ) } + const customLayerProps: CustomLayerProps = { + cells, + activeCell, + setActiveCell + } + return ( ({ > {layers.map((layer, i) => { if (typeof layer === 'function') { - return {createElement(layer, {})} + return {createElement(layer, customLayerProps)} } return layerById?.[layer] ?? null diff --git a/packages/heatmap/src/HeatMapCanvas.tsx b/packages/heatmap/src/HeatMapCanvas.tsx index 7c0d9eecb0..9264015a99 100644 --- a/packages/heatmap/src/HeatMapCanvas.tsx +++ b/packages/heatmap/src/HeatMapCanvas.tsx @@ -1,52 +1,72 @@ -import { useEffect, useRef, useCallback } from 'react' -import { - getRelativeCursor, - isCursorInRect, - useDimensions, - useTheme, - withContainer, -} from '@nivo/core' -import { renderAxesToCanvas } from '@nivo/axes' +import { useEffect, useRef, useCallback, createElement } from 'react' +import { getRelativeCursor, isCursorInRect, useDimensions, useTheme, Container } from '@nivo/core' +import { renderAxesToCanvas, renderGridLinesToCanvas } from '@nivo/axes' import { useTooltip } from '@nivo/tooltip' import { useHeatMap } from './hooks' -import { HeatMapDefaultProps, HeatMapPropTypes } from './props' import { renderRect, renderCircle } from './canvas' -import { HeatMapTooltip } from './HeatMapTooltip' +import { canvasDefaultProps } from './defaults' +import { + CellCanvasRenderer, + DefaultHeatMapDatum, + HeatMapCanvasProps, + HeatMapCommonProps, + HeatMapDatum, + CellShape, +} from './types' + +type InnerNetworkCanvasProps = Omit< + HeatMapCanvasProps, + 'renderWrapper' | 'theme' +> -const HeatMapCanvas = ({ +const InnerHeatMapCanvas = ({ data, - keys, - indexBy, - minValue, - maxValue, + layers = canvasDefaultProps.layers, + minValue: _minValue = canvasDefaultProps.minValue, + maxValue: _maxValue = canvasDefaultProps.maxValue, + valueFormat, width, height, margin: partialMargin, - forceSquare, - padding, - sizeVariation, - cellShape, - cellOpacity, - cellBorderColor, - axisTop, - axisRight, - axisBottom, - axisLeft, - enableLabels, - label, - labelTextColor, - colors, - nanColor, - isInteractive, + // forceSquare = canvasDefaultProps.forceSquare, + xInnerPadding = canvasDefaultProps.xInnerPadding, + xOuterPadding = canvasDefaultProps.xOuterPadding, + yInnerPadding = canvasDefaultProps.yInnerPadding, + yOuterPadding = canvasDefaultProps.yOuterPadding, + // sizeVariation = canvasDefaultProps.sizeVariation, + renderCell: _renderCell = canvasDefaultProps.renderCell as CellShape, + opacity = canvasDefaultProps.opacity, + activeOpacity = canvasDefaultProps.activeOpacity, + inactiveOpacity = canvasDefaultProps.inactiveOpacity, + // borderWidth = canvasDefaultProps.borderWidth, + borderColor = canvasDefaultProps.borderColor as HeatMapCommonProps['borderColor'], + enableGridX = canvasDefaultProps.enableGridX, + enableGridY = canvasDefaultProps.enableGridY, + axisTop = canvasDefaultProps.axisTop, + axisRight = canvasDefaultProps.axisRight, + axisBottom = canvasDefaultProps.axisBottom, + axisLeft = canvasDefaultProps.axisLeft, + enableLabels = canvasDefaultProps.enableLabels, + label = canvasDefaultProps.label as HeatMapCommonProps['label'], + labelTextColor = canvasDefaultProps.labelTextColor as HeatMapCommonProps['labelTextColor'], + colors = canvasDefaultProps.colors as HeatMapCommonProps['colors'], + emptyColor = canvasDefaultProps.emptyColor, + // legends = canvasDefaultProps.legends, + // annotations = canvasDefaultProps.annotations as HeatMapCommonProps['annotations'], + isInteractive = canvasDefaultProps.isInteractive, + // onMouseEnter, + // onMouseMove, + // onMouseLeave, onClick, - hoverTarget, - cellHoverOpacity, - cellHoverOthersOpacity, - tooltipFormat, - tooltip, - pixelRatio, -}) => { - const canvasEl = useRef(null) + hoverTarget = canvasDefaultProps.hoverTarget, + tooltip = canvasDefaultProps.tooltip as HeatMapCommonProps['tooltip'], + role, + ariaLabel, + ariaLabelledBy, + ariaDescribedBy, + pixelRatio = canvasDefaultProps.pixelRatio, +}: InnerNetworkCanvasProps) => { + const canvasEl = useRef(null) const { margin, innerWidth, innerHeight, outerWidth, outerHeight } = useDimensions( width, @@ -54,67 +74,93 @@ const HeatMapCanvas = ({ partialMargin ) - const { cells, xScale, yScale, offsetX, offsetY, currentCellId, setCurrentCellId } = useHeatMap( - { - data, - keys, - indexBy, - minValue, - maxValue, - width: innerWidth, - height: innerHeight, - padding, - forceSquare, - sizeVariation, - colors, - nanColor, - cellOpacity, - cellBorderColor, - label, - labelTextColor, - hoverTarget, - cellHoverOpacity, - cellHoverOthersOpacity, - } - ) + const { xScale, yScale, cells, activeCell, setActiveCell } = useHeatMap({ + data, + valueFormat, + width: innerWidth, + height: innerHeight, + xInnerPadding, + xOuterPadding, + yInnerPadding, + yOuterPadding, + colors, + emptyColor, + opacity, + activeOpacity, + inactiveOpacity, + borderColor, + label, + labelTextColor, + hoverTarget, + }) const theme = useTheme() + let renderCell: CellCanvasRenderer + if (typeof _renderCell === 'function') { + renderCell = _renderCell + } else if (_renderCell === 'circle') { + renderCell = renderCircle + } else { + renderCell = renderRect + } + useEffect(() => { - canvasEl.current.width = outerWidth * pixelRatio - canvasEl.current.height = outerHeight * pixelRatio + if (canvasEl.current === null) return const ctx = canvasEl.current.getContext('2d') + if (!ctx) return + + canvasEl.current.width = outerWidth * pixelRatio + canvasEl.current.height = outerHeight * pixelRatio ctx.scale(pixelRatio, pixelRatio) ctx.fillStyle = theme.background ctx.fillRect(0, 0, outerWidth, outerHeight) - ctx.translate(margin.left + offsetX, margin.top + offsetY) - - renderAxesToCanvas(ctx, { - xScale, - yScale, - width: innerWidth - offsetX * 2, - height: innerHeight - offsetY * 2, - top: axisTop, - right: axisRight, - bottom: axisBottom, - left: axisLeft, - theme, - }) + ctx.translate(margin.left, margin.top) // + offsetX, margin.top + offsetY) + + layers.forEach(layer => { + if (layer === 'grid') { + ctx.lineWidth = theme.grid.line.strokeWidth as number + ctx.strokeStyle = theme.grid.line.stroke as string - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - - let renderCell - if (cellShape === 'rect') { - renderCell = renderRect - } else { - renderCell = renderCircle - } - cells.forEach(cell => { - renderCell(ctx, { enableLabels, theme }, cell) + if (enableGridX) { + renderGridLinesToCanvas(ctx, { + width: innerWidth, + height: innerHeight, + scale: xScale, + axis: 'x', + }) + } + if (enableGridY) { + renderGridLinesToCanvas(ctx, { + width: innerWidth, + height: innerHeight, + scale: yScale, + axis: 'y', + }) + } + } else if (layer === 'axes') { + renderAxesToCanvas(ctx, { + xScale, + yScale, + width: innerWidth, // - offsetX * 2, + height: innerHeight, // - offsetY * 2, + top: axisTop, + right: axisRight, + bottom: axisBottom, + left: axisLeft, + theme, + }) + } else if (layer === 'cells') { + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + cells.forEach(cell => { + renderCell(ctx, { cell, enableLabels, theme }) + }) + } }) }, [ canvasEl, @@ -124,9 +170,9 @@ const HeatMapCanvas = ({ innerWidth, innerHeight, margin, - offsetX, - offsetY, - cellShape, + renderCell, + enableGridX, + enableGridY, axisTop, axisRight, axisBottom, @@ -142,12 +188,14 @@ const HeatMapCanvas = ({ const handleMouseHover = useCallback( event => { + if (canvasEl.current === null) return + const [x, y] = getRelativeCursor(canvasEl.current, event) const cell = cells.find(c => isCursorInRect( - c.x + margin.left + offsetX - c.width / 2, - c.y + margin.top + offsetY - c.height / 2, + c.x + margin.left - c.width / 2, // + offsetX - c.width / 2, + c.y + margin.top - c.height / 2, //+ offsetY - c.height / 2, c.width, c.height, x, @@ -155,13 +203,10 @@ const HeatMapCanvas = ({ ) ) if (cell !== undefined) { - setCurrentCellId(cell.id) - showTooltipFromEvent( - , - event - ) + setActiveCell(cell) + showTooltipFromEvent(createElement(tooltip, { cell }), event) } else { - setCurrentCellId(null) + setActiveCell(null) hideTooltip() } }, @@ -169,29 +214,27 @@ const HeatMapCanvas = ({ canvasEl, cells, margin, - offsetX, - offsetY, - setCurrentCellId, + // offsetX, + // offsetY, + setActiveCell, showTooltipFromEvent, hideTooltip, tooltip, - tooltipFormat, ] ) const handleMouseLeave = useCallback(() => { - setCurrentCellId(null) + setActiveCell(null) hideTooltip() - }, [setCurrentCellId, hideTooltip]) + }, [setActiveCell, hideTooltip]) const handleClick = useCallback( event => { - if (currentCellId === null) return + if (activeCell === null) return - const currentCell = cells.find(cell => cell.id === currentCellId) - currentCell && onClick(currentCell, event) + onClick?.(activeCell, event) }, - [cells, currentCellId, onClick] + [cells, activeCell, onClick] ) return ( @@ -207,13 +250,26 @@ const HeatMapCanvas = ({ onMouseMove={isInteractive ? handleMouseHover : undefined} onMouseLeave={isInteractive ? handleMouseLeave : undefined} onClick={isInteractive ? handleClick : undefined} + role={role} + aria-label={ariaLabel} + aria-labelledby={ariaLabelledBy} + aria-describedby={ariaDescribedBy} /> ) } -HeatMapCanvas.propTypes = HeatMapPropTypes - -const WrappedHeatMapCanvas = withContainer(HeatMapCanvas) -WrappedHeatMapCanvas.defaultProps = HeatMapDefaultProps - -export default WrappedHeatMapCanvas +export const HeatMapCanvas = < + Datum extends HeatMapDatum = DefaultHeatMapDatum, + ExtraProps extends object = Record +>({ + theme, + isInteractive = canvasDefaultProps.isInteractive, + animate = canvasDefaultProps.animate, + motionConfig = canvasDefaultProps.motionConfig, + renderWrapper, + ...otherProps +}: HeatMapCanvasProps) => ( + + isInteractive={isInteractive} {...otherProps} /> + +) diff --git a/packages/heatmap/src/HeatMapCellCircle.tsx b/packages/heatmap/src/HeatMapCellCircle.tsx index 53f1331b57..210f11a85c 100644 --- a/packages/heatmap/src/HeatMapCellCircle.tsx +++ b/packages/heatmap/src/HeatMapCellCircle.tsx @@ -1,90 +1,61 @@ -import { memo } from 'react' -import PropTypes from 'prop-types' -import { useSpring, animated } from '@react-spring/web' -import { useTheme, useMotionConfig } from '@nivo/core' +import { memo, useMemo } from 'react' +import { animated, to } from '@react-spring/web' +import { useTheme } from '@nivo/core' +import { HeatMapDatum, CellComponentProps } from './types' -const HeatMapCellCircle = ({ - data, - label, - x, - y, - width, - height, - color, - opacity, +const NonMemoizedHeatMapCellCircle = ({ + cell, borderWidth, - borderColor, - enableLabel, - textColor, - onHover, - onLeave, + animatedProps, + onMouseEnter, + onMouseMove, + onMouseLeave, onClick, -}) => { + enableLabels, +}: CellComponentProps) => { const theme = useTheme() - const { animate, config: springConfig } = useMotionConfig() - const animatedProps = useSpring({ - transform: `translate(${x}, ${y})`, - radius: Math.min(width, height) / 2, - color, - opacity, - textColor, - borderWidth, - borderColor, - config: springConfig, - immediate: !animate, - }) + const handlers = useMemo( + () => ({ + onMouseEnter: onMouseEnter ? onMouseEnter(cell) : undefined, + onMouseMove: onMouseMove ? onMouseMove(cell) : undefined, + onMouseLeave: onMouseLeave ? onMouseLeave(cell) : undefined, + onClick: onClick ? onClick(cell) : undefined, + }), + [cell, onMouseEnter, onMouseMove, onMouseLeave] + ) return ( onClick(data, event) : undefined} + opacity={animatedProps.opacity} + {...handlers} + transform={to([animatedProps.x, animatedProps.y], (x, y) => `translate(${x}, ${y})`)} > - {enableLabel && ( + {enableLabels && ( - {label} + {cell.label} )} ) } -HeatMapCellCircle.propTypes = { - data: PropTypes.object.isRequired, - label: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - x: PropTypes.number.isRequired, - y: PropTypes.number.isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - color: PropTypes.string.isRequired, - opacity: PropTypes.number.isRequired, - borderWidth: PropTypes.number.isRequired, - borderColor: PropTypes.string.isRequired, - enableLabel: PropTypes.bool.isRequired, - textColor: PropTypes.string.isRequired, - onHover: PropTypes.func, - onLeave: PropTypes.func, - onClick: PropTypes.func, -} - -export default memo(HeatMapCellCircle) +export const HeatMapCellCircle = memo( + NonMemoizedHeatMapCellCircle +) as typeof NonMemoizedHeatMapCellCircle diff --git a/packages/heatmap/src/HeatMapCellRect.tsx b/packages/heatmap/src/HeatMapCellRect.tsx index edf9609dcd..501f061475 100644 --- a/packages/heatmap/src/HeatMapCellRect.tsx +++ b/packages/heatmap/src/HeatMapCellRect.tsx @@ -1,96 +1,71 @@ -import { memo } from 'react' -import PropTypes from 'prop-types' -import { useSpring, animated } from '@react-spring/web' -import { useMotionConfig, useTheme } from '@nivo/core' +import { memo, useMemo } from 'react' +import { animated, to } from '@react-spring/web' +import { useTheme } from '@nivo/core' +import { CellComponentProps, HeatMapDatum } from './types' -const HeatMapCellRect = ({ - data, - label, - x, - y, - width, - height, - color, - opacity, +const NonMemoizedHeatMapCellRect = ({ + cell, borderWidth, - borderColor, - enableLabel, - textColor, - onHover, - onLeave, + borderRadius, + animatedProps, + onMouseEnter, + onMouseMove, + onMouseLeave, onClick, -}) => { + enableLabels, +}: CellComponentProps) => { const theme = useTheme() - const { animate, config: springConfig } = useMotionConfig() - const animatedProps = useSpring({ - transform: `translate(${x}, ${y})`, - width, - height, - xOffset: width * -0.5, - yOffset: height * -0.5, - color, - opacity, - textColor, - borderWidth, - borderColor, - config: springConfig, - immediate: !animate, - }) + const handlers = useMemo( + () => ({ + onMouseEnter: onMouseEnter ? onMouseEnter(cell) : undefined, + onMouseMove: onMouseMove ? onMouseMove(cell) : undefined, + onMouseLeave: onMouseLeave ? onMouseLeave(cell) : undefined, + onClick: onClick ? onClick(cell) : undefined, + }), + [cell, onMouseEnter, onMouseMove, onMouseLeave] + ) return ( onClick(data, event) : undefined} + opacity={animatedProps.opacity} + {...handlers} + transform={to( + [animatedProps.x, animatedProps.y, animatedProps.scale], + (x, y, scale) => `translate(${x}, ${y}) scale(${scale})` + )} > `translate(${width * -0.5}, ${height * -0.5})` + )} + key={cell.id} + fill={animatedProps.color} width={animatedProps.width} height={animatedProps.height} - fill={animatedProps.color} - fillOpacity={animatedProps.opacity} - strokeWidth={animatedProps.borderWidth} stroke={animatedProps.borderColor} - strokeOpacity={animatedProps.opacity} + strokeWidth={borderWidth} + rx={borderRadius} + ry={borderRadius} /> - {enableLabel && ( + {enableLabels && ( - {label} + {cell.label} )} ) } -HeatMapCellRect.propTypes = { - data: PropTypes.object.isRequired, - label: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - x: PropTypes.number.isRequired, - y: PropTypes.number.isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - color: PropTypes.string.isRequired, - opacity: PropTypes.number.isRequired, - borderWidth: PropTypes.number.isRequired, - borderColor: PropTypes.string.isRequired, - enableLabel: PropTypes.bool.isRequired, - textColor: PropTypes.string.isRequired, - onHover: PropTypes.func, - onLeave: PropTypes.func, - onClick: PropTypes.func, -} - -export default memo(HeatMapCellRect) +export const HeatMapCellRect = memo(NonMemoizedHeatMapCellRect) as typeof NonMemoizedHeatMapCellRect diff --git a/packages/heatmap/src/HeatMapCells.tsx b/packages/heatmap/src/HeatMapCells.tsx index c310344c5f..d3a90e8479 100644 --- a/packages/heatmap/src/HeatMapCells.tsx +++ b/packages/heatmap/src/HeatMapCells.tsx @@ -1,167 +1,158 @@ -import { createElement, MouseEvent, useCallback } from 'react' -import { useTransition, animated, to } from '@react-spring/web' -import { useTheme, useMotionConfig } from '@nivo/core' +import { createElement, MouseEvent, useMemo } from 'react' +import { useTransition } from '@react-spring/web' +import { useMotionConfig } from '@nivo/core' import { useTooltip } from '@nivo/tooltip' -import { ComputedCell, HeatMapDatum, HeatMapSvgProps } from './types' +import { + CellComponent, + ComputedCell, + HeatMapDatum, + HeatMapSvgProps, + CellAnimatedProps, +} from './types' +import { HeatMapCellRect } from './HeatMapCellRect' +import { HeatMapCellCircle } from './HeatMapCellCircle' interface HeatMapCellsProps { cells: ComputedCell[] + cellComponent: NonNullable['cellComponent']> borderRadius: NonNullable['borderRadius']> borderWidth: NonNullable['borderWidth']> + isInteractive: NonNullable['isInteractive']> + setActiveCell: (cell: ComputedCell | null) => void onMouseEnter: HeatMapSvgProps['onMouseEnter'] onMouseMove: HeatMapSvgProps['onMouseMove'] onMouseLeave: HeatMapSvgProps['onMouseLeave'] onClick: HeatMapSvgProps['onClick'] tooltip: NonNullable['tooltip']> - isInteractive: NonNullable['isInteractive']> enableLabels: NonNullable['enableLabels']> } -/* -let cellComponent -if (cellShape === 'rect') { - cellComponent = HeatMapCellRect -} else if (cellShape === 'circle') { - cellComponent = HeatMapCellCircle -} else { - cellComponent = cellShape -} -*/ +const enterTransition = (cell: ComputedCell) => ({ + x: cell.x, + y: cell.y, + width: cell.width, + height: cell.height, + color: cell.color, + opacity: 0, + borderColor: cell.borderColor, + labelTextColor: cell.labelTextColor, + scale: 0, +}) + +const regularTransition = (cell: ComputedCell) => ({ + x: cell.x, + y: cell.y, + width: cell.width, + height: cell.height, + color: cell.color, + opacity: cell.opacity, + borderColor: cell.borderColor, + labelTextColor: cell.labelTextColor, + scale: 1, +}) + +const exitTransition = (cell: ComputedCell) => ({ + x: cell.x, + y: cell.y, + width: cell.width, + height: cell.height, + color: cell.color, + opacity: 0, + borderColor: cell.borderColor, + labelTextColor: cell.labelTextColor, + scale: 0, +}) export const HeatMapCells = ({ cells, + cellComponent, borderRadius, borderWidth, + isInteractive, + setActiveCell, onMouseEnter, onMouseMove, onMouseLeave, onClick, tooltip, - isInteractive, enableLabels, }: HeatMapCellsProps) => { - const theme = useTheme() const { animate, config: springConfig } = useMotionConfig() - const transition = useTransition< - ComputedCell, - { - x: number - y: number - width: number - height: number - color: string - opacity: number - borderColor: string - labelTextColor: string - } - >(cells, { - keys: cell => cell.id, - initial: cell => ({ - x: cell.x, - y: cell.y, - width: cell.width, - height: cell.height, - color: cell.color, - borderColor: cell.borderColor, - labelTextColor: cell.labelTextColor, - }), - update: cell => ({ - x: cell.x, - y: cell.y, - width: cell.width, - height: cell.height, - color: cell.color, - borderColor: cell.borderColor, - labelTextColor: cell.labelTextColor, - }), + const transition = useTransition, CellAnimatedProps>(cells, { + keys: (cell: ComputedCell) => cell.id, + initial: regularTransition, + from: enterTransition, + enter: regularTransition, + update: regularTransition, + leave: exitTransition, config: springConfig, immediate: !animate, }) const { showTooltipFromEvent, hideTooltip } = useTooltip() - const handleMouseEnter = useCallback( - (cell: ComputedCell, event: MouseEvent) => { + const handleMouseEnter = useMemo(() => { + if (!isInteractive) return undefined + + return (cell: ComputedCell) => (event: MouseEvent) => { showTooltipFromEvent(createElement(tooltip, { cell }), event) + setActiveCell(cell) onMouseEnter?.(cell, event) - }, - [showTooltipFromEvent, tooltip, onMouseEnter] - ) + } + }, [isInteractive, showTooltipFromEvent, tooltip, setActiveCell, onMouseEnter]) - const handleMouseMove = useCallback( - (cell: ComputedCell, event: MouseEvent) => { + const handleMouseMove = useMemo(() => { + if (!isInteractive) return undefined + + return (cell: ComputedCell) => (event: MouseEvent) => { showTooltipFromEvent(createElement(tooltip, { cell }), event) onMouseMove?.(cell, event) - }, - [showTooltipFromEvent, tooltip, onMouseMove] - ) + } + }, [isInteractive, showTooltipFromEvent, tooltip, onMouseMove]) - const handleMouseLeave = useCallback( - (cell: ComputedCell, event: MouseEvent) => { + const handleMouseLeave = useMemo(() => { + if (!isInteractive) return undefined + + return (cell: ComputedCell) => (event: MouseEvent) => { hideTooltip() + setActiveCell(null) onMouseLeave?.(cell, event) - }, - [hideTooltip, onMouseLeave] - ) + } + }, [isInteractive, hideTooltip, setActiveCell, onMouseLeave]) + + const handleClick = useMemo(() => { + if (!isInteractive) return undefined + + return (cell: ComputedCell) => (event: MouseEvent) => { + onClick?.(cell, event) + } + }, [isInteractive, onClick]) + + let Cell: CellComponent + if (cellComponent === 'rect') { + Cell = HeatMapCellRect + } else if (cellComponent === 'circle') { + Cell = HeatMapCellCircle + } else { + Cell = cellComponent + } return ( - - {transition((animatedProps, cell) => { - return ( - { - return `translate(${x - width / 2}, ${y - height / 2})` - } - )} - > - handleMouseEnter(cell, event) : undefined - } - onMouseMove={ - isInteractive ? event => handleMouseMove(cell, event) : undefined - } - onMouseLeave={ - isInteractive ? event => handleMouseLeave(cell, event) : undefined - } - onClick={isInteractive ? event => onClick?.(cell, event) : undefined} - /> - {enableLabels && ( - - {cell.label} - - )} - - ) - })} - + <> + {transition((animatedProps, cell) => + createElement(Cell, { + cell, + borderRadius, + borderWidth, + animatedProps, + enableLabels, + onMouseEnter: handleMouseEnter, + onMouseMove: handleMouseMove, + onMouseLeave: handleMouseLeave, + onClick: handleClick, + }) + )} + ) } diff --git a/packages/heatmap/src/HeatMapCells_old.tsx b/packages/heatmap/src/HeatMapCells_old.tsx deleted file mode 100644 index 2c96ec6d0a..0000000000 --- a/packages/heatmap/src/HeatMapCells_old.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { createElement } from 'react' - -const HeatMapCells = ({ - cells, - cellComponent, - cellBorderWidth, - getCellBorderColor, - enableLabels, - getLabelTextColor, - handleCellHover, - handleCellLeave, - onClick, -}) => { - return cells.map(cell => - createElement(cellComponent, { - key: cell.id, - data: cell, - label: cell.label, - x: cell.x, - y: cell.y, - width: cell.width, - height: cell.height, - color: cell.color, - opacity: cell.opacity, - borderWidth: cellBorderWidth, - borderColor: getCellBorderColor(cell), - enableLabel: enableLabels, - textColor: getLabelTextColor(cell), - onHover: handleCellHover ? event => handleCellHover(cell, event) : undefined, - onLeave: handleCellLeave, - onClick, - }) - ) -} - -HeatMapCells.propTypes = {} - -export default HeatMapCells diff --git a/packages/heatmap/src/HeatMapTooltip.tsx b/packages/heatmap/src/HeatMapTooltip.tsx index a42bc47199..1948887bf0 100644 --- a/packages/heatmap/src/HeatMapTooltip.tsx +++ b/packages/heatmap/src/HeatMapTooltip.tsx @@ -2,13 +2,17 @@ import { memo } from 'react' import { BasicTooltip } from '@nivo/tooltip' import { HeatMapDatum, TooltipProps } from './types' -const NonMemoizedHeatMapTooltip = ({ cell }: TooltipProps) => ( - -) +const NonMemoizedHeatMapTooltip = ({ cell }: TooltipProps) => { + if (cell.formattedValue === null) return null + + return ( + + ) +} export const HeatMapTooltip = memo(NonMemoizedHeatMapTooltip) as typeof NonMemoizedHeatMapTooltip diff --git a/packages/heatmap/src/ResponsiveHeatMapCanvas.tsx b/packages/heatmap/src/ResponsiveHeatMapCanvas.tsx index f8f7168693..e04fe1eb2c 100644 --- a/packages/heatmap/src/ResponsiveHeatMapCanvas.tsx +++ b/packages/heatmap/src/ResponsiveHeatMapCanvas.tsx @@ -1,10 +1,16 @@ import { ResponsiveWrapper } from '@nivo/core' -import HeatMapCanvas from './HeatMapCanvas' +import { DefaultHeatMapDatum, HeatMapCanvasProps, HeatMapDatum } from './types' +import { HeatMapCanvas } from './HeatMapCanvas' -const ResponsiveHeatMapCanvas = props => ( +export const ResponsiveHeatMapCanvas = < + Datum extends HeatMapDatum = DefaultHeatMapDatum, + ExtraProps extends object = Record +>( + props: Omit, 'height' | 'width'> +) => ( - {({ width, height }) => } + {({ width, height }) => ( + width={width} height={height} {...props} /> + )} ) - -export default ResponsiveHeatMapCanvas diff --git a/packages/heatmap/src/canvas.tsx b/packages/heatmap/src/canvas.tsx index f8e33c2114..0787e3a0f0 100644 --- a/packages/heatmap/src/canvas.tsx +++ b/packages/heatmap/src/canvas.tsx @@ -1,21 +1,12 @@ -/** - * Render heatmap rect cell. - * - * @param {Object} ctx - * @param {boolean} enableLabels - * @param {number} x - * @param {number} y - * @param {number} width - * @param {number} height - * @param {string} color - * @param {number} opacity - * @param {string} labelTextColor - * @param {number | string} label - */ -export const renderRect = ( - ctx, - { enableLabels, theme }, - { x, y, width, height, color, opacity, labelTextColor, label } +import { CellCanvasRendererProps, HeatMapDatum } from './types' + +export const renderRect = ( + ctx: CanvasRenderingContext2D, + { + cell: { x, y, width, height, color, opacity, labelTextColor, label }, + enableLabels, + theme, + }: CellCanvasRendererProps ) => { ctx.save() ctx.globalAlpha = opacity @@ -23,7 +14,7 @@ export const renderRect = ( ctx.fillStyle = color ctx.fillRect(x - width / 2, y - height / 2, width, height) - if (enableLabels === true) { + if (enableLabels) { ctx.fillStyle = labelTextColor ctx.font = `${theme.labels.text.fontSize}px ${theme.labels.text.fontFamily}` ctx.fillText(label, x, y) @@ -32,24 +23,13 @@ export const renderRect = ( ctx.restore() } -/** - * Render heatmap circle cell. - * - * @param {Object} ctx - * @param {boolean} enableLabels - * @param {number} x - * @param {number} y - * @param {number} width - * @param {number} height - * @param {string} color - * @param {number} opacity - * @param {string} labelTextColor - * @param {number | string} label - */ -export const renderCircle = ( - ctx, - { enableLabels, theme }, - { x, y, width, height, color, opacity, labelTextColor, label } +export const renderCircle = ( + ctx: CanvasRenderingContext2D, + { + cell: { x, y, width, height, color, opacity, labelTextColor, label }, + enableLabels, + theme, + }: CellCanvasRendererProps ) => { ctx.save() ctx.globalAlpha = opacity @@ -61,7 +41,7 @@ export const renderCircle = ( ctx.arc(x, y, radius, 0, 2 * Math.PI) ctx.fill() - if (enableLabels === true) { + if (enableLabels) { ctx.fillStyle = labelTextColor ctx.font = `${theme.labels.text.fontSize}px ${theme.labels.text.fontFamily}` ctx.fillText(label, x, y) diff --git a/packages/heatmap/src/compute.ts b/packages/heatmap/src/compute.ts index 38924fc82a..4bdf097a21 100644 --- a/packages/heatmap/src/compute.ts +++ b/packages/heatmap/src/compute.ts @@ -30,12 +30,17 @@ export const computeCells = { xValuesSet.add(datum.x) - allValues.push(datum.y) + + let value: number | null = null + if (datum.y !== undefined && datum.y !== null) { + allValues.push(datum.y) + value = datum.y + } cells.push({ id: `${serie.id}.${datum.x}`, serieId: serie.id, - value: datum.y, + value, data: datum, }) }) @@ -63,7 +68,7 @@ export const computeCells = , - 'formattedValue' | 'color' | 'opacity' | 'borderColor' + 'formattedValue' | 'color' | 'opacity' | 'borderColor' | 'label' | 'labelTextColor' >[] = cells.map(cell => ({ ...cell, x: xScale(cell.data.x)! + cellWidth / 2, diff --git a/packages/heatmap/src/defaults.ts b/packages/heatmap/src/defaults.ts index 3019e3f46b..5bfd3f61b4 100644 --- a/packages/heatmap/src/defaults.ts +++ b/packages/heatmap/src/defaults.ts @@ -5,6 +5,7 @@ export const commonDefaultProps: Omit< HeatMapCommonProps, | 'margin' | 'theme' + | 'valueFormat' | 'onClick' | 'renderWrapper' | 'role' @@ -26,28 +27,24 @@ export const commonDefaultProps: Omit< yOuterPadding: 0, sizeVariation: 0, - opacity: 0.85, + opacity: 1, activeOpacity: 1, - inactiveOpacity: 0.35, + inactiveOpacity: 0.15, borderWidth: 0, - borderColor: { from: 'color' }, + borderColor: { from: 'color', modifiers: [['darker', 0.8]] }, enableGridX: false, enableGridY: false, - axisTop: {}, - axisRight: null, - axisBottom: null, - axisLeft: {}, enableLabels: true, label: 'formattedValue', - labelTextColor: { from: 'color', modifiers: [['darker', 1.4]] }, + labelTextColor: { from: 'color', modifiers: [['darker', 2]] }, colors: { type: 'sequential', scheme: 'brown_blueGreen', }, - nanColor: '#000000', + emptyColor: '#00000000', legends: [], annotations: [], @@ -62,12 +59,20 @@ export const commonDefaultProps: Omit< export const svgDefaultProps = { ...commonDefaultProps, + axisTop: {}, + axisRight: null, + axisBottom: null, + axisLeft: {}, borderRadius: 0, cellComponent: 'rect', } export const canvasDefaultProps = { ...commonDefaultProps, + axisTop: {}, + axisRight: null, + axisBottom: null, + axisLeft: {}, renderCell: 'rect', pixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, } diff --git a/packages/heatmap/src/hooks.ts b/packages/heatmap/src/hooks.ts index c13de0c4d0..bb4dc84608 100644 --- a/packages/heatmap/src/hooks.ts +++ b/packages/heatmap/src/hooks.ts @@ -1,12 +1,7 @@ -import { useMemo, useCallback } from 'react' -import { - useTheme, - usePropertyAccessor, - useValueFormatter, - // @ts-ignore - getLabelGenerator, -} from '@nivo/core' -import { useInheritedColor, useContinuousColorScale } from '@nivo/colors' +import { useMemo, useCallback, useState } from 'react' +import { useTheme, usePropertyAccessor, useValueFormatter } from '@nivo/core' +import { useInheritedColor, getContinuousColorScale } from '@nivo/colors' +import { AnnotationMatcher, useAnnotations } from '@nivo/annotations' import { ComputedCell, DefaultHeatMapDatum, @@ -16,8 +11,6 @@ import { } from './types' import { commonDefaultProps } from './defaults' import { computeCells } from './compute' -import { ComputedNode, InputNode } from '@nivo/network' -import { AnnotationMatcher, useAnnotations } from '@nivo/annotations' export const useComputeCells = ({ data, @@ -50,21 +43,44 @@ export const useComputeCells = { return column * cellWidth + cellWidth * 0.5 + padding * column + padding } const computeY = (row: number, cellHeight: number, padding: number) => { return row * cellHeight + cellHeight * 0.5 + padding * row + padding } +*/ const isHoverTargetByType = { - cell: (cell: ComputedCell, current: ComputedCell) => - cell.xKey === current.xKey && cell.yKey === current.yKey, - row: (cell: ComputedCell, current: ComputedCell) => cell.yKey === current.yKey, - column: (cell: ComputedCell, current: ComputedCell) => - cell.xKey === current.xKey, - rowColumn: (cell: ComputedCell, current: ComputedCell) => - cell.xKey === current.xKey || cell.yKey === current.yKey, + cell: ( + cell: Omit< + ComputedCell, + 'formattedValue' | 'color' | 'opacity' | 'borderColor' | 'label' | 'labelTextColor' + >, + current: ComputedCell + ) => cell.id === current.id, + row: ( + cell: Omit< + ComputedCell, + 'formattedValue' | 'color' | 'opacity' | 'borderColor' | 'label' | 'labelTextColor' + >, + current: ComputedCell + ) => cell.serieId === current.serieId, + column: ( + cell: Omit< + ComputedCell, + 'formattedValue' | 'color' | 'opacity' | 'borderColor' | 'label' | 'labelTextColor' + >, + current: ComputedCell + ) => cell.data.x === current.data.x, + rowColumn: ( + cell: Omit< + ComputedCell, + 'formattedValue' | 'color' | 'opacity' | 'borderColor' | 'label' | 'labelTextColor' + >, + current: ComputedCell + ) => cell.serieId === current.serieId || cell.data.x === current.data.x, } /* @@ -130,21 +146,21 @@ export const useHeatMap = < valueFormat, width, height, - forceSquare = commonDefaultProps.forceSquare, + // forceSquare = commonDefaultProps.forceSquare, xOuterPadding = commonDefaultProps.xOuterPadding, xInnerPadding = commonDefaultProps.xInnerPadding, yOuterPadding = commonDefaultProps.yOuterPadding, yInnerPadding = commonDefaultProps.yInnerPadding, - sizeVariation, + // sizeVariation, colors = commonDefaultProps.colors as HeatMapCommonProps['colors'], - nanColor, - opacity, - activeOpacity, - inactiveOpacity, + emptyColor = commonDefaultProps.emptyColor, + opacity = commonDefaultProps.opacity, + activeOpacity = commonDefaultProps.activeOpacity, + inactiveOpacity = commonDefaultProps.inactiveOpacity, borderColor = commonDefaultProps.borderColor as HeatMapCommonProps['borderColor'], label = commonDefaultProps.label as HeatMapCommonProps['label'], labelTextColor = commonDefaultProps.labelTextColor as HeatMapCommonProps['labelTextColor'], - hoverTarget, + hoverTarget = commonDefaultProps.hoverTarget, }: { data: HeatMapDataProps['data'] minValue?: HeatMapCommonProps['minValue'] @@ -159,7 +175,7 @@ export const useHeatMap = < yInnerPadding?: HeatMapCommonProps['yInnerPadding'] sizeVariation?: HeatMapCommonProps['sizeVariation'] colors?: HeatMapCommonProps['colors'] - nanColor?: HeatMapCommonProps['nanColor'] + emptyColor?: HeatMapCommonProps['emptyColor'] opacity?: HeatMapCommonProps['opacity'] activeOpacity?: HeatMapCommonProps['activeOpacity'] inactiveOpacity?: HeatMapCommonProps['inactiveOpacity'] @@ -168,6 +184,8 @@ export const useHeatMap = < labelTextColor?: HeatMapCommonProps['labelTextColor'] hoverTarget?: HeatMapCommonProps['hoverTarget'] }) => { + const [activeCell, setActiveCell] = useState | null>(null) + const { cells, xScale, yScale, minValue, maxValue } = useComputeCells({ data, width, @@ -178,15 +196,33 @@ export const useHeatMap = < yInnerPadding, }) - const colorScale = useContinuousColorScale(colors, { - min: minValue, - max: maxValue, - }) + const colorScale = useMemo(() => { + if (typeof colors === 'function') return null + + return getContinuousColorScale(colors, { + min: minValue, + max: maxValue, + }) + }, [colors, minValue, maxValue]) + + const activeIds = useMemo(() => { + if (!activeCell) return [] + + const isHoverTarget = isHoverTargetByType[hoverTarget] + + return cells.filter(cell => isHoverTarget(cell, activeCell)).map(cell => cell.id) + }, [cells, activeCell, hoverTarget]) const getColor = useCallback( - (cell: Omit, 'color' | 'opacity' | 'borderColor'>) => - colorScale(cell.value), - [colorScale] + (cell: Omit, 'color' | 'opacity' | 'borderColor'>) => { + if (cell.value !== null) { + if (typeof colors === 'function') return colors(cell) + if (colorScale !== null) return colorScale(cell.value) + } + + return emptyColor + }, + [colors, colorScale, emptyColor] ) const theme = useTheme() const getBorderColor = useInheritedColor(borderColor, theme) @@ -194,31 +230,40 @@ export const useHeatMap = < const formatValue = useValueFormatter(valueFormat) const getLabel = usePropertyAccessor(label) - const computedCells = cells.map(cell => { - const computedCell = { - ...cell, - formattedValue: formatValue(cell.value), - opacity: 1, - } as ComputedCell - - computedCell.label = getLabel(computedCell) - computedCell.color = getColor(computedCell) - computedCell.borderColor = getBorderColor(computedCell) - computedCell.labelTextColor = getLabelTextColor(computedCell) - - return computedCell - }) + const computedCells = useMemo( + () => + cells.map(cell => { + let computedOpacity = opacity + if (activeIds.length > 0) { + computedOpacity = activeIds.includes(cell.id) ? activeOpacity : inactiveOpacity + } + + const computedCell = { + ...cell, + formattedValue: cell.value !== null ? formatValue(cell.value) : null, + opacity: computedOpacity, + } as ComputedCell + + computedCell.label = getLabel(computedCell) + computedCell.color = getColor(computedCell) + computedCell.borderColor = getBorderColor(computedCell) + computedCell.labelTextColor = getLabelTextColor(computedCell) + + return computedCell + }), + [cells, getColor, getBorderColor, getLabelTextColor, formatValue, getLabel, activeIds] + ) return { cells: computedCells, xScale, yScale, colorScale, + activeCell, + setActiveCell, } /* - const [currentCellId, setCurrentCellId] = useState(null) - const layoutConfig = useMemo(() => { const columns = keys.length const rows = data.length @@ -253,9 +298,6 @@ export const useHeatMap = < } }, [sizeVariation, values]) - const getCellBorderColor = useInheritedColor(cellBorderColor, theme) - const getLabelTextColor = useInheritedColor(labelTextColor, theme) - const cells = useMemo( () => computeCells({ @@ -287,40 +329,6 @@ export const useHeatMap = < getLabelTextColor, ] ) - - const cellsWithCurrent = useMemo(() => { - if (currentCellId === null) return cells - - const isHoverTarget = isHoverTargetByType[hoverTarget] - const currentCell = cells.find(cell => cell.id === currentCellId) - - return cells.map(cell => { - const opacity = isHoverTarget(cell, currentCell) - ? cellHoverOpacity - : cellHoverOthersOpacity - - if (opacity === cell.opacity) return cell - - return { - ...cell, - opacity, - } - }) - }, [cells, currentCellId, hoverTarget, cellHoverOpacity, cellHoverOthersOpacity]) - - return { - cells: cellsWithCurrent, - getIndex, - xScale: scales.x, - yScale: scales.y, - ...layoutConfig, - sizeScale, - currentCellId, - setCurrentCellId, - colorScale, - getCellBorderColor, - getLabelTextColor, - } */ } diff --git a/packages/heatmap/src/index.ts b/packages/heatmap/src/index.ts index 49a5f147eb..bd3a78a025 100644 --- a/packages/heatmap/src/index.ts +++ b/packages/heatmap/src/index.ts @@ -1,7 +1,6 @@ export * from './HeatMap' export * from './ResponsiveHeatMap' -export { default as HeatMapCanvas } from './HeatMapCanvas' -export { default as ResponsiveHeatMapCanvas } from './ResponsiveHeatMapCanvas' +export * from './HeatMapCanvas' +export * from './ResponsiveHeatMapCanvas' export * from './hooks' -export * from './props' export * from './defaults' diff --git a/packages/heatmap/src/props.js b/packages/heatmap/src/props.js deleted file mode 100644 index e09c6c74ca..0000000000 --- a/packages/heatmap/src/props.js +++ /dev/null @@ -1,100 +0,0 @@ -import PropTypes from 'prop-types' -import { quantizeColorScalePropType, noop } from '@nivo/core' -import { inheritedColorPropType } from '@nivo/colors' -import { axisPropType } from '@nivo/axes' - -export const HeatMapPropTypes = { - data: PropTypes.arrayOf(PropTypes.object).isRequired, - indexBy: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, - keys: PropTypes.arrayOf(PropTypes.string).isRequired, - - minValue: PropTypes.oneOfType([PropTypes.oneOf(['auto']), PropTypes.number]).isRequired, - maxValue: PropTypes.oneOfType([PropTypes.oneOf(['auto']), PropTypes.number]).isRequired, - - forceSquare: PropTypes.bool.isRequired, - sizeVariation: PropTypes.number.isRequired, - padding: PropTypes.number.isRequired, - - cellShape: PropTypes.oneOfType([PropTypes.oneOf(['rect', 'circle']), PropTypes.func]) - .isRequired, - cellOpacity: PropTypes.number.isRequired, - cellBorderWidth: PropTypes.number.isRequired, - cellBorderColor: inheritedColorPropType.isRequired, - - axisTop: axisPropType, - axisRight: axisPropType, - axisBottom: axisPropType, - axisLeft: axisPropType, - - enableGridX: PropTypes.bool.isRequired, - enableGridY: PropTypes.bool.isRequired, - - enableLabels: PropTypes.bool.isRequired, - label: PropTypes.func.isRequired, - labelTextColor: inheritedColorPropType.isRequired, - - colors: quantizeColorScalePropType.isRequired, - nanColor: PropTypes.string, - - isInteractive: PropTypes.bool, - onClick: PropTypes.func.isRequired, - hoverTarget: PropTypes.oneOf(['cell', 'row', 'column', 'rowColumn']).isRequired, - cellHoverOpacity: PropTypes.number.isRequired, - cellHoverOthersOpacity: PropTypes.number.isRequired, - tooltipFormat: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), - tooltip: PropTypes.func, - - pixelRatio: PropTypes.number.isRequired, -} - -export const HeatMapSvgPropTypes = { - ...HeatMapPropTypes, - role: PropTypes.string.isRequired, -} - -export const HeatMapDefaultProps = { - indexBy: 'id', - - minValue: 'auto', - maxValue: 'auto', - - forceSquare: false, - sizeVariation: 0, - padding: 0, - - // cells - cellShape: 'rect', - cellOpacity: 0.85, - cellBorderWidth: 0, - cellBorderColor: { from: 'color' }, - - // axes & grid - axisTop: {}, - axisLeft: {}, - enableGridX: false, - enableGridY: false, - - // labels - enableLabels: true, - label: (datum, key) => datum[key], - labelTextColor: { from: 'color', modifiers: [['darker', 1.4]] }, - - // theming - colors: 'nivo', - nanColor: '#000000', - - // interactivity - isInteractive: true, - onClick: noop, - hoverTarget: 'rowColumn', - cellHoverOpacity: 1, - cellHoverOthersOpacity: 0.35, - - // canvas specific - pixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, -} - -export const HeatMapSvgDefaultProps = { - ...HeatMapDefaultProps, - role: 'img', -} diff --git a/packages/heatmap/src/types.ts b/packages/heatmap/src/types.ts index 77078a6d4b..d7c2488c36 100644 --- a/packages/heatmap/src/types.ts +++ b/packages/heatmap/src/types.ts @@ -3,24 +3,25 @@ import { AnimatedProps } from '@react-spring/web' import { Box, Theme, + CompleteTheme, Dimensions, ModernMotionProps, PropertyAccessor, ValueFormat, } from '@nivo/core' -import { AxisProps } from '@nivo/axes' +import { AxisProps, CanvasAxisProp } from '@nivo/axes' import { InheritedColorConfig, ContinuousColorScaleConfig } from '@nivo/colors' import { AnchoredContinuousColorsLegendProps } from '@nivo/legends' import { AnnotationMatcher } from '@nivo/annotations' export interface HeatMapDatum { x: string | number - y: number | null | undefined + y?: number | null | undefined } export interface DefaultHeatMapDatum { x: string - y: number | null | undefined + y?: number | null | undefined } export type HeatMapSerie = { @@ -31,8 +32,8 @@ export type HeatMapSerie export interface ComputedCell { id: string serieId: string - value: number - formattedValue: string + value: number | null + formattedValue: string | null data: Datum x: number y: number @@ -50,17 +51,13 @@ export interface CellAnimatedProps { y: number width: number height: number + scale: number color: string opacity: number - textColor: string borderColor: string + labelTextColor: string } -export type CellCanvasRenderer = ( - ctx: CanvasRenderingContext2D, - cell: ComputedCell -) => void - export interface HeatMapDataProps { data: HeatMapSerie[] } @@ -68,6 +65,8 @@ export interface HeatMapDataProps { cells: ComputedCell[] + activeCell: ComputedCell | null + setActiveCell: (cell: ComputedCell | null) => void } export type CustomLayer = FunctionComponent> export type CustomCanvasLayer = ( @@ -82,14 +81,27 @@ export type TooltipComponent = FunctionComponent { cell: ComputedCell - animated: AnimatedProps - onClick?: (cell: ComputedCell, event: MouseEvent) => void - onMouseEnter?: (cell: ComputedCell, event: MouseEvent) => void - onMouseMove?: (cell: ComputedCell, event: MouseEvent) => void - onMouseLeave?: (cell: ComputedCell, event: MouseEvent) => void + borderWidth: number + borderRadius: number + animatedProps: AnimatedProps + onMouseEnter?: (cell: ComputedCell) => (event: MouseEvent) => void + onMouseMove?: (cell: ComputedCell) => (event: MouseEvent) => void + onMouseLeave?: (cell: ComputedCell) => (event: MouseEvent) => void + onClick?: (cell: ComputedCell) => (event: MouseEvent) => void + enableLabels: boolean } export type CellComponent = FunctionComponent> +export interface CellCanvasRendererProps { + cell: ComputedCell + enableLabels: boolean + theme: CompleteTheme +} +export type CellCanvasRenderer = ( + ctx: CanvasRenderingContext2D, + props: CellCanvasRendererProps +) => void + export type CellShape = 'rect' | 'circle' export type HeatMapCommonProps = { @@ -114,10 +126,6 @@ export type HeatMapCommonProps = { enableGridX: boolean enableGridY: boolean - axisTop: AxisProps | null - axisRight: AxisProps | null - axisBottom: AxisProps | null - axisLeft: AxisProps | null theme: Theme colors: @@ -128,7 +136,7 @@ export type HeatMapCommonProps = { 'color' | 'opacity' | 'borderColor' | 'labelTextColor' > ) => string) - nanColor: string + emptyColor: string enableLabels: boolean label: PropertyAccessor< @@ -158,21 +166,31 @@ export type HeatMapSvgProps > & HeatMapDataProps & - Dimensions & { - borderRadius?: number - layers?: (LayerId | CustomLayer)[] - cellComponent?: CellShape | CellComponent - onMouseEnter?: (cell: ComputedCell, event: MouseEvent) => void - onMouseMove?: (cell: ComputedCell, event: MouseEvent) => void - onMouseLeave?: (cell: ComputedCell, event: MouseEvent) => void - } + Dimensions & + Partial<{ + axisTop: AxisProps | null + axisRight: AxisProps | null + axisBottom: AxisProps | null + axisLeft: AxisProps | null + borderRadius: number + layers: (LayerId | CustomLayer)[] + cellComponent: CellShape | CellComponent + onMouseEnter: (cell: ComputedCell, event: MouseEvent) => void + onMouseMove: (cell: ComputedCell, event: MouseEvent) => void + onMouseLeave: (cell: ComputedCell, event: MouseEvent) => void + }> export type HeatMapCanvasProps = Partial< HeatMapCommonProps > & HeatMapDataProps & - Dimensions & { - layers?: (LayerId | CustomCanvasLayer)[] - renderCell?: CellShape | CellCanvasRenderer - pixelRatio?: number - } + Dimensions & + Partial<{ + axisTop: CanvasAxisProp | null + axisRight: CanvasAxisProp | null + axisBottom: CanvasAxisProp | null + axisLeft: CanvasAxisProp | null + layers: (LayerId | CustomCanvasLayer)[] + renderCell: CellShape | CellCanvasRenderer + pixelRatio: number + }> diff --git a/website/src/data/components/heatmap/props.ts b/website/src/data/components/heatmap/props.ts index 8b9eabbc1e..be3409396f 100644 --- a/website/src/data/components/heatmap/props.ts +++ b/website/src/data/components/heatmap/props.ts @@ -156,25 +156,25 @@ const props: ChartProperty[] = [ }, }, { - key: 'cellOpacity', + key: 'opacity', group: 'Style', - defaultValue: defaults.cellOpacity, + defaultValue: defaults.opacity, type: 'number', control: { type: 'opacity' }, }, { - key: 'activeCellOpacity', + key: 'activeOpacity', group: 'Style', flavors: ['svg', 'canvas'], - defaultValue: defaults.cellOpacity, + defaultValue: defaults.activeOpacity, type: 'number', control: { type: 'opacity' }, }, { - key: 'inactiveCellOpacity', + key: 'inactiveOpacity', group: 'Style', flavors: ['svg', 'canvas'], - defaultValue: defaults.cellOpacity, + defaultValue: defaults.inactiveOpacity, type: 'number', control: { type: 'opacity' }, }, @@ -495,7 +495,7 @@ const props: ChartProperty[] = [ }, }), ...commonAccessibilityProps(allFlavors), - ...motionProperties(['svg'], defaults, 'react-spring'), + ...motionProperties(['svg', 'canvas'], defaults, 'react-spring'), ] export const groups = groupProperties(props) diff --git a/website/src/pages/chord/index.tsx b/website/src/pages/chord/index.tsx index 181692124f..735b232d85 100644 --- a/website/src/pages/chord/index.tsx +++ b/website/src/pages/chord/index.tsx @@ -139,9 +139,10 @@ const Chord = () => { }) }} onRibbonClick={ribbon => { + console.log(ribbon) logAction({ type: 'click', - label: `[ribbon] ${ribbon.source.label} —> ${ribbon.target.label}`, + label: `[ribbon] ${ribbon.source.label} (${ribbon.source.formattedValue}) → ${ribbon.target.label} (${ribbon.target.formattedValue})`, color: ribbon.source.color, data: ribbon, }) diff --git a/website/src/pages/heatmap/canvas.tsx b/website/src/pages/heatmap/canvas.tsx index d5df0bb277..642ce1ad63 100644 --- a/website/src/pages/heatmap/canvas.tsx +++ b/website/src/pages/heatmap/canvas.tsx @@ -1,33 +1,62 @@ import React from 'react' -import { ResponsiveHeatMapCanvas } from '@nivo/heatmap' +import { graphql, useStaticQuery } from 'gatsby' import isFunction from 'lodash/isFunction' +import { + ResponsiveHeatMapCanvas, + canvasDefaultProps as defaults, +} from '@nivo/heatmap' +import { generateXYSeries, sets } from '@nivo/generators' import { ComponentTemplate } from '../../components/components/ComponentTemplate' import meta from '../../data/components/heatmap/meta.yml' import mapper from '../../data/components/heatmap/mapper' import { groups } from '../../data/components/heatmap/props' -import { generateHeavyDataSet } from '../../data/components/heatmap/generator' -import { graphql, useStaticQuery } from 'gatsby' -const initialProperties = { - indexBy: 'country', +const getData = () => + generateXYSeries({ + serieIds: sets.countryCodes.slice(0, 26), + x: { + values: sets.names, + }, + y: { + length: NaN, + min: -100_000, + max: 100_000, + round: true, + }, + }) +const initialProperties = { margin: { - top: 100, - right: 60, - bottom: 100, + top: 70, + right: 90, + bottom: 120, left: 60, }, + minValue: defaults.minValue, + maxValue: defaults.maxValue, + valueFormat: { format: '>-.2s', enabled: true }, + pixelRatio: typeof window !== 'undefined' && window.devicePixelRatio ? window.devicePixelRatio : 1, - minValue: 'auto', - maxValue: 'auto', - forceSquare: false, + forceSquare: defaults.forceSquare, sizeVariation: 0, - padding: 0, - colors: 'BrBG', + xOuterPadding: defaults.xOuterPadding, + xInnerPadding: defaults.xInnerPadding, + yOuterPadding: defaults.yOuterPadding, + yInnerPadding: defaults.yInnerPadding, + + colors: { + type: 'diverging', + scheme: 'red_yellow_blue', + divergeAt: 0.5, + minValue: -100_000, + maxValue: 100_000, + }, + enableGridX: false, + enableGridY: true, axisTop: { enable: true, orient: 'top', @@ -35,7 +64,7 @@ const initialProperties = { tickPadding: 5, tickRotation: -90, legend: '', - legendOffset: 36, + legendOffset: 46, }, axisRight: { enable: true, @@ -45,10 +74,10 @@ const initialProperties = { tickRotation: 0, legend: 'country', legendPosition: 'middle', - legendOffset: 40, + legendOffset: 70, }, axisBottom: { - enable: true, + enable: false, orient: 'bottom', tickSize: 5, tickPadding: 5, @@ -65,34 +94,46 @@ const initialProperties = { tickRotation: 0, legend: 'country', legendPosition: 'middle', - legendOffset: -40, + legendOffset: -42, }, - enableGridX: false, - enableGridY: true, - - cellShape: 'rect', - cellOpacity: 1, - cellBorderWidth: 0, - cellBorderColor: { - from: 'color', - modifiers: [['darker', 0.4]], - }, + renderCell: 'rect', + opacity: defaults.opacity, + activeOpacity: defaults.activeOpacity, + inactiveOpacity: defaults.inactiveOpacity, + borderRadius: defaults.borderRadius, + borderWidth: defaults.borderWidth, + borderColor: defaults.borderColor, enableLabels: false, - labelTextColor: { - from: 'color', - modifiers: [['darker', 1.4]], - }, + labelTextColor: defaults.labelTextColor, + + legends: [ + { + anchor: 'bottom', + translateX: 0, + translateY: 30, + length: 400, + thickness: 8, + direction: 'row', + tickPosition: 'after', + tickSize: 3, + tickSpacing: 4, + tickOverlap: false, + tickFormat: { format: '>-.2s', enabled: true }, + title: 'Value →', + titleAlign: 'start', + titleOffset: 4, + }, + ], + + annotations: [], animate: true, - motionStiffness: 120, - motionDamping: 9, + motionConfig: defaults.motionConfig, isInteractive: true, hoverTarget: 'rowColumn', - cellHoverOpacity: 1, - cellHoverOthersOpacity: 0.5, } const HeatMapCanvas = () => { @@ -127,22 +168,20 @@ const HeatMapCanvas = () => { ? 'Custom(props) => (…)' : properties.cellShape, })} - generateData={generateHeavyDataSet} - getDataSize={data => data.data.length * data.keys.length} - getTabData={data => data.data} + generateData={getData} + getDataSize={data => data.length * data[0].data.length} image={image} > {(properties, data, theme, logAction) => { return ( { logAction({ type: 'click', - label: `[cell] ${cell.yKey}.${cell.xKey}: ${cell.value}`, + label: `${cell.serieId} → ${cell.data.x}: ${cell.formattedValue}`, color: cell.color, data: cell, }) diff --git a/website/src/pages/heatmap/index.tsx b/website/src/pages/heatmap/index.tsx index 911de3ce03..03068fb316 100644 --- a/website/src/pages/heatmap/index.tsx +++ b/website/src/pages/heatmap/index.tsx @@ -1,86 +1,21 @@ import React from 'react' +import { graphql, useStaticQuery } from 'gatsby' +import isFunction from 'lodash/isFunction' import { ResponsiveHeatMap, svgDefaultProps as defaults, -} from '../../../../packages/heatmap/dist/nivo-heatmap.cjs' -import { patternLinesDef } from '@nivo/core' -import isFunction from 'lodash/isFunction' +} from '@nivo/heatmap' +import { generateXYSeries } from '@nivo/generators' import { ComponentTemplate } from '../../components/components/ComponentTemplate' import meta from '../../data/components/heatmap/meta.yml' import mapper from '../../data/components/heatmap/mapper' import { groups } from '../../data/components/heatmap/props' -import { graphql, useStaticQuery } from 'gatsby' - -interface XYRangeStaticValues { - values: string[] | number[] -} - -interface XYRandomNumericValues { - length: number - min: number - max: number - round?: boolean -} - -type XYRangeValues = XYRangeStaticValues | XYRandomNumericValues - -const generateXYSeries = ({ - serieIds, - x, - y, -}: { - serieIds: string[] - x: XYRangeValues - y: XYRangeValues -}) => { - const xLength = 'length' in x ? x.length : x.values.length - - let getX: (index: number) => string | number - if ('values' in x) { - getX = (index: number) => x.values[index] - } else { - getX = () => { - let xValue = x.min + Math.random() * (x.max - x.min) - if (x.round) { - xValue = Math.round(xValue) - } - - return xValue - } - } - let getY: (index: number) => string | number - if ('values' in y) { - getY = (index: number) => y.values[index] - } else { - getY = () => { - let yValue = y.min + Math.random() * (y.max - y.min) - if (y.round) { - yValue = Math.round(yValue) - } - - return yValue - } - } - - return serieIds.map(serieId => { - return { - id: serieId, - data: Array.from({ length: xLength }).map((_, index) => { - return { - x: getX(index), - y: getY(index), - } - }), - } - }) -} - -const getData = () => { - return generateXYSeries({ +const getData = () => + generateXYSeries({ serieIds: ['Japan', 'France', 'US', 'Germany', 'Norway', 'Iceland', 'UK', 'Vietnam'], x: { - values: ['Plane', 'Train', 'Subway', 'Bus', 'Car', 'Moto', 'Bicycle', 'Others'], + values: ['Train', 'Subway', 'Bus', 'Car', 'Boat', 'Moto', 'Moped', 'Bicycle', 'Others'], }, y: { length: NaN, @@ -89,7 +24,6 @@ const getData = () => { round: true, }, }) -} const initialProperties = { margin: { @@ -106,9 +40,9 @@ const initialProperties = { forceSquare: defaults.forceSquare, sizeVariation: 0, xOuterPadding: defaults.xOuterPadding, - xInnerPadding: 0.05, + xInnerPadding: defaults.xInnerPadding, yOuterPadding: defaults.yOuterPadding, - yInnerPadding: 0.05, + yInnerPadding: defaults.yInnerPadding, enableGridX: defaults.enableGridX, enableGridY: defaults.enableGridY, @@ -160,32 +94,15 @@ const initialProperties = { maxValue: 100_000, }, cellComponent: 'rect', - cellOpacity: 1, - activeCellOpacity: 1, - inactiveCellOpacity: 1, - borderRadius: 2, - borderWidth: 1, - borderColor: { - from: 'color', - modifiers: [['darker', 0.6]], - }, + opacity: defaults.opacity, + activeOpacity: defaults.activeOpacity, + inactiveOpacity: defaults.inactiveOpacity, + borderRadius: defaults.borderRadius, + borderWidth: defaults.borderWidth, + borderColor: defaults.borderColor, - enableLabels: true, - labelTextColor: { - from: 'color', - modifiers: [['darker', 2]], - }, - - defs: [ - patternLinesDef('lines', { - background: 'inherit', - color: 'rgba(0, 0, 0, 0.1)', - rotation: -45, - lineWidth: 4, - spacing: 7, - }), - ], - fill: [{ match: d => false && d.value < 30, id: 'lines' }], + enableLabels: defaults.enableLabels, + labelTextColor: defaults.labelTextColor, legends: [ { @@ -206,13 +123,13 @@ const initialProperties = { }, ], - annotations: [], + annotations: defaults.annotations, animate: defaults.animate, motionConfig: defaults.motionConfig, isInteractive: defaults.isInteractive, - hoverTarget: 'cell', + hoverTarget: defaults.hoverTarget, } const HeatMap = () => { @@ -260,7 +177,7 @@ const HeatMap = () => { onClick={cell => { logAction({ type: 'click', - label: `${cell.serieId} ${cell.data.x}: ${cell.formattedValue}`, + label: `${cell.serieId} → ${cell.data.x}: ${cell.formattedValue}`, color: cell.color, data: cell, })