From 65fd5f3aa85f3a48e966ae4d2869a1d78a411fdd Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 18 Jun 2021 06:41:12 -0600 Subject: [PATCH] fix: infinite render in chart measure --- src/components/AxisLinear.tsx | 315 ++++++++++++------------ src/components/AxisLinear.useMeasure.ts | 276 ++++++++++++--------- src/primitives/Circle.tsx | 19 +- src/primitives/Group.tsx | 12 +- src/primitives/Line.tsx | 20 +- src/primitives/Path.tsx | 20 +- src/primitives/Rectangle.tsx | 64 ++--- src/primitives/Text.tsx | 20 +- src/utils/buildAxis.linear.ts | 12 +- 9 files changed, 416 insertions(+), 342 deletions(-) diff --git a/src/components/AxisLinear.tsx b/src/components/AxisLinear.tsx index 4e75900e..c6ba9b58 100644 --- a/src/components/AxisLinear.tsx +++ b/src/components/AxisLinear.tsx @@ -1,11 +1,11 @@ -import React from 'react' +import React from 'react'; // -import { translateX, translateY, translate } from '../utils/Utils' +import { translateX, translateY, translate } from '../utils/Utils'; -import Path from '../primitives/Path' -import Line from '../primitives/Line' -import Text from '../primitives/Text' -import Group from '../primitives/Group' +import Path from '../primitives/Path'; +import Line from '../primitives/Line'; +import Text from '../primitives/Text'; +import Group from '../primitives/Group'; import { positionTop, @@ -13,9 +13,9 @@ import { positionBottom, positionLeft, axisTypeOrdinal, -} from '../utils/Constants.js' -import useChartContext from '../hooks/useChartContext' -import useMeasure from './AxisLinear.useMeasure' +} from '../utils/Constants.js'; +import useChartContext from '../hooks/useChartContext'; +import useMeasure from './AxisLinear.useMeasure'; const defaultStyles = { line: { @@ -26,7 +26,7 @@ const defaultStyles = { fontSize: 10, fontFamily: 'sans-serif', }, -} +}; export default function AxisLinear(axis) { const { @@ -49,21 +49,31 @@ export default function AxisLinear(axis) { tickOffset, gridOffset, spacing, - id, - } = axis - const [rotation, setRotation] = React.useState(0) - const { gridWidth, gridHeight, dark } = useChartContext() + labelRotation, + } = axis; + const [showRotated, setShowRotated] = React.useState(false); + const [rotation, setRotation] = React.useState(0); + const { gridWidth, gridHeight, dark } = useChartContext(); - const elRef = React.useRef() + const elRef = React.useRef(); - useMeasure({ ...axis, elRef, rotation, gridWidth, gridHeight, setRotation }) + useMeasure({ + ...axis, + elRef, + rotation, + gridWidth, + gridHeight, + setRotation, + showRotated, + setShowRotated, + }); // Not ready? Render null if (!show) { - return null + return null; } - let axisPath + let axisPath; if (vertical) { if (position === positionLeft) { axisPath = ` @@ -71,14 +81,14 @@ export default function AxisLinear(axis) { H 0 V ${range1} H ${-tickSizeOuter} - ` + `; } else { axisPath = ` M ${tickSizeOuter}, ${range0} H 0 V ${range1} H ${tickSizeOuter} - ` + `; } } else if (position === positionBottom) { axisPath = ` @@ -86,165 +96,164 @@ export default function AxisLinear(axis) { V 0 H ${range1} V ${tickSizeOuter} - ` + `; } else { axisPath = ` M 0, ${-tickSizeOuter} V 0 H ${range1} V ${-tickSizeOuter} - ` + `; } - let showGridLine + let showGridLine; if (typeof showGrid === 'boolean') { - showGridLine = showGrid + showGridLine = showGrid; } else if (type === axisTypeOrdinal) { - showGridLine = false + showGridLine = false; } else { - showGridLine = true + showGridLine = true; } // Combine default styles with style props const axisStyles = { ...defaultStyles, ...styles, - } + }; - return ( - - { + const show = isRotated ? showRotated : !showRotated; + + return ( + - - {ticks.map((tick, i) => ( - - {/* Render the grid line */} - {showGridLine && ( - - )} - {/* Render the tick line */} - {showTicks ? ( - + > + + + {ticks.map((tick, i) => ( + + {/* Render the grid line */} + {showGridLine && ( - - {format(tick, i)} - - - {format(tick, i)} - - - ) : null} - - ))} + )} + {/* Render the tick line */} + {showTicks ? ( + + + + {format(tick, i)} + + + ) : null} + + ))} + + ); + }; + + return ( + + {renderAxis(false)} + {renderAxis(true)} - ) + ); } diff --git a/src/components/AxisLinear.useMeasure.ts b/src/components/AxisLinear.useMeasure.ts index b90d93ec..2f6ab8ba 100644 --- a/src/components/AxisLinear.useMeasure.ts +++ b/src/components/AxisLinear.useMeasure.ts @@ -1,25 +1,47 @@ -import React from 'react' -import useChartState from '../hooks/useChartState' -import useIsomorphicLayoutEffect from '../hooks/useIsomorphicLayoutEffect' +import React from 'react'; +import useChartState from '../hooks/useChartState'; +import useIsomorphicLayoutEffect from '../hooks/useIsomorphicLayoutEffect'; const getElBox = el => { - var rect = el.getBoundingClientRect() + var rect = el.getBoundingClientRect(); return { - top: rect.top, - right: rect.right, - bottom: rect.bottom, - left: rect.left, - width: rect.width, - height: rect.height, - x: rect.x, - y: rect.y, + top: Math.round(rect.top), + right: Math.round(rect.right), + bottom: Math.round(rect.bottom), + left: Math.round(rect.left), + width: Math.round(rect.width), + height: Math.round(rect.height), + x: Math.round(rect.x), + y: Math.round(rect.y), + }; +}; + +function useIsLooping() { + const callThreshold = 60; + const timeLimit = 1000; + const now = Date.now(); + + const ref = React.useRef([now]); + + ref.current.push(now); + + ref.current = ref.current.filter(d => d > now - timeLimit); + + while (ref.current.length > callThreshold) { + ref.current.shift(); } + + const isLooping = + ref.current.length >= callThreshold && now - ref.current[0] < timeLimit; + + return isLooping; } export default function useMeasure({ elRef, rotation, - setRotation, + showRotated, + setShowRotated, id, position, tickSizeInner, @@ -33,93 +55,105 @@ export default function useMeasure({ }) { const [axisDimension, setChartState] = useChartState( state => state.axisDimensions?.[position]?.[id] - ) + ); - const measureDimensions = React.useCallback(() => { - if (show) { - // Remeasure when show changes - } + const isLooping = useIsLooping(); + const measureDimensions = React.useCallback(() => { if (!elRef.current) { - if (axisDimension) { - // If the entire axis is hidden, then we need to remove the axis dimensions - setChartState(state => { - const newAxes = state.axisDimensions[position] || {} - delete newAxes[id] - return { - ...state, - axisDimensions: { - ...state.axisDimensions, - [position]: newAxes, - }, - } - }) - } - return + return; } - let gridSize = !vertical ? gridWidth : gridHeight - - const domainDims = getElBox(elRef.current.querySelector('.domain')) + // if (show) { + // // Remeasure when show changes + // } - const measureLabelDims = Array( - ...elRef.current.querySelectorAll('.tickLabel-measurer') - ).map(el => getElBox(el)) + let gridSize = !vertical ? gridWidth : gridHeight; - const realLabelDims = Array( - ...elRef.current.querySelectorAll('.tickLabel') - ).map(el => getElBox(el)) + const unrotatedLabelDims = Array( + ...elRef.current.querySelectorAll('.Axis.unrotated .tickLabel') + ).map(el => getElBox(el)); // Determine the largest labels on the axis - const widestMeasureLabel = measureLabelDims.reduce((label, d) => { - label = label || d + const widestLabel = unrotatedLabelDims.reduce((label, d) => { + label = label || d; if (d.width > 0 && d.width > label.width) { - label = d + label = d; } - return label - }, null) + return label; + }, null); - // Determine the largest labels on the axis - const [widestRealLabel, tallestRealLabel] = realLabelDims.reduce( - (labels, d) => { - let [largestW = d, largestH = d] = labels - if (d.width > 0 && d.width > largestW.width) { - largestW = d - } - if (d.height > 0 && d.height > largestH.height) { - largestH = d - } - return [largestW, largestH] - }, - [] - ) - - let smallestTickGap = gridSize + let smallestTickGap = gridSize; - if (measureLabelDims.length > 1) { - measureLabelDims.reduce((prev, current) => { + if (unrotatedLabelDims.length > 1) { + unrotatedLabelDims.reduce((prev, current) => { if (prev) { smallestTickGap = Math.min( smallestTickGap, vertical ? current.top - prev.top : current.left - prev.left - ) + ); } - return current - }, false) + return current; + }, false); } - // Rotate ticks for non-time horizontal axes - if (!vertical) { - const newRotation = - (widestMeasureLabel?.width || 0) + tickPadding > smallestTickGap - ? labelRotation - : 0 + const shouldRotate = + (widestLabel?.width || 0) + tickPadding > smallestTickGap; - if (newRotation !== rotation) { - setRotation(position === 'top' ? -newRotation : newRotation) + if (!isLooping) { + // Rotate ticks for non-time horizontal axes + if (!vertical) { + setShowRotated(shouldRotate); } } + }, [ + axisDimension, + elRef, + gridHeight, + gridWidth, + id, + labelRotation, + position, + rotation, + setChartState, + show, + tickPadding, + tickSizeInner, + tickSizeOuter, + vertical, + ]); + + // Measure after if needed + useIsomorphicLayoutEffect(() => { + // React.useEffect(() => { + // let timeout = setTimeout(() => { + measureDimensions(); + // }, 100); + + // return () => { + // clearTimeout(timeout); + // }; + }); + + useIsomorphicLayoutEffect(() => { + if (!elRef.current) { + if (axisDimension) { + // If the entire axis is hidden, then we need to remove the axis dimensions + setChartState(state => { + const newAxes = state.axisDimensions[position] || {}; + delete newAxes[id]; + return { + ...state, + axisDimensions: { + ...state.axisDimensions, + [position]: newAxes, + }, + }; + }); + } + return; + } const newDimensions = { width: 0, @@ -128,63 +162,92 @@ export default function useMeasure({ bottom: 0, left: 0, right: 0, - } + }; + + const domainDims = getElBox( + elRef.current.querySelector( + `.Axis.${showRotated ? 'rotated' : 'unrotated'} .domain` + ) + ); + + const measureDims = showRotated + ? Array( + ...elRef.current.querySelectorAll('.Axis.rotated .tickLabel') + ).map(el => getElBox(el)) + : Array( + ...elRef.current.querySelectorAll('.Axis.unrotated .tickLabel') + ).map(el => getElBox(el)); + + // Determine the largest labels on the axis + const [widestRealLabel, tallestRealLabel] = measureDims.reduce( + (labels, d) => { + let [largestW = d, largestH = d] = labels; + if (d.width > 0 && d.width > largestW.width) { + largestW = d; + } + if (d.height > 0 && d.height > largestH.height) { + largestH = d; + } + return [largestW, largestH]; + }, + [] + ); // Axis overflow measurements if (!vertical) { - if (realLabelDims.length) { - const leftMostLabelDim = realLabelDims.reduce((d, labelDim) => + if (measureDims.length) { + const leftMostLabelDim = measureDims.reduce((d, labelDim) => labelDim.left < d.left ? labelDim : d - ) - const rightMostLabelDim = realLabelDims.reduce((d, labelDim) => + ); + const rightMostLabelDim = measureDims.reduce((d, labelDim) => labelDim.right > d.right ? labelDim : d - ) + ); newDimensions.left = Math.round( Math.max(0, domainDims.left - leftMostLabelDim?.left) - ) + ); newDimensions.right = Math.round( Math.max(0, rightMostLabelDim?.right - domainDims.right) - ) + ); } newDimensions.height = Math.round( Math.max(tickSizeInner, tickSizeOuter) + tickPadding + (tallestRealLabel?.height ?? 0) - ) + ); } else { - if (realLabelDims.length) { - const topMostLabelDim = realLabelDims.reduce((d, labelDim) => + if (measureDims.length) { + const topMostLabelDim = measureDims.reduce((d, labelDim) => labelDim.top < d.top ? labelDim : d - ) + ); - const bottomMostLabelDim = realLabelDims.reduce((d, labelDim) => + const bottomMostLabelDim = measureDims.reduce((d, labelDim) => labelDim.bottom > d.bottom ? labelDim : d - ) + ); newDimensions.top = Math.round( Math.max(0, domainDims.top - topMostLabelDim?.top) - ) + ); newDimensions.bottom = Math.round( Math.max(0, bottomMostLabelDim?.bottom - domainDims.bottom) - ) + ); } newDimensions.width = Math.round( Math.max(tickSizeInner, tickSizeOuter) + tickPadding + (widestRealLabel?.width ?? 0) - ) + ); } // Only update the axisDimensions if something has changed if ( !axisDimension || Object.keys(newDimensions).some(key => { - return newDimensions[key] !== axisDimension[key] + return newDimensions[key] !== axisDimension[key]; }) ) { setChartState(state => ({ @@ -196,28 +259,7 @@ export default function useMeasure({ [id]: newDimensions, }, }, - })) + })); } - }, [ - axisDimension, - elRef, - gridHeight, - gridWidth, - id, - labelRotation, - position, - rotation, - setChartState, - setRotation, - show, - tickPadding, - tickSizeInner, - tickSizeOuter, - vertical, - ]) - - // Measure after if needed - useIsomorphicLayoutEffect(() => { - measureDimensions() - }) + }); } diff --git a/src/primitives/Circle.tsx b/src/primitives/Circle.tsx index bb9b1ef4..c14bdbeb 100644 --- a/src/primitives/Circle.tsx +++ b/src/primitives/Circle.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React from 'react'; // const defaultStyle = { @@ -7,15 +7,18 @@ const defaultStyle = { stroke: '#000000', fill: '#000000', opacity: 1, -} +}; -export default function Circle({ x, y, r, style, ...rest }) { +const Circle = React.forwardRef< + SVGCircleElement, + React.ComponentProps<'circle'> +>(({ style, ...rest }, ref) => { const resolvedStyle = { ...defaultStyle, ...style, - } + }; - return ( - - ) -} + return ; +}); + +export default Circle; diff --git a/src/primitives/Group.tsx b/src/primitives/Group.tsx index 042aa11d..f7efa88d 100644 --- a/src/primitives/Group.tsx +++ b/src/primitives/Group.tsx @@ -1,5 +1,9 @@ -import React from 'react' +import React from 'react'; -export default React.forwardRef(function Group(props, ref) { - return -}) +const Group = React.forwardRef>( + (props, ref) => { + return ; + } +); + +export default Group; diff --git a/src/primitives/Line.tsx b/src/primitives/Line.tsx index 6cac1668..441e0782 100644 --- a/src/primitives/Line.tsx +++ b/src/primitives/Line.tsx @@ -1,17 +1,21 @@ -import React from 'react' +import React from 'react'; // const defaultStyle = { strokeWidth: 1, fill: 'transparent', opacity: 1, -} +}; -export default function Line({ style, ...rest }) { - const resolvedStyle = { - ...defaultStyle, - ...style, +const Line = React.forwardRef>( + ({ style, ...rest }, ref) => { + const resolvedStyle = { + ...defaultStyle, + ...style, + }; + + return ; } +); - return -} +export default Line; diff --git a/src/primitives/Path.tsx b/src/primitives/Path.tsx index fe1294fd..773727d9 100644 --- a/src/primitives/Path.tsx +++ b/src/primitives/Path.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React from 'react'; // const defaultStyle = { @@ -6,13 +6,17 @@ const defaultStyle = { stroke: '#6b6b6b', fill: 'transparent', opacity: 1, -} +}; -export default function Path({ style, ...rest }) { - const resolvedStyle = { - ...defaultStyle, - ...style, +const Path = React.forwardRef>( + ({ style, ...rest }, ref) => { + const resolvedStyle = { + ...defaultStyle, + ...style, + }; + + return ; } +); - return -} +export default Path; diff --git a/src/primitives/Rectangle.tsx b/src/primitives/Rectangle.tsx index 34bc89e0..583ff497 100644 --- a/src/primitives/Rectangle.tsx +++ b/src/primitives/Rectangle.tsx @@ -9,36 +9,40 @@ const defaultStyle = { ry: 0, }; -export default function Rectangle({ - style, - opacity = 1, - x1, - y1, - x2, - y2, - ...rest -}) { - const resolvedStyle = { - ...defaultStyle, - ...style, - }; +type Props = React.ComponentProps<'rect'> & { + x1: number; + x2: number; + y1: number; + y2: number; +}; + +const Rectangle = React.forwardRef( + ({ style, opacity = 1, x1, y1, x2, y2, ...rest }, ref) => { + const resolvedStyle = { + ...defaultStyle, + ...style, + }; + + const xStart = Math.min(x1, x2); + const yStart = Math.min(y1, y2); + const xEnd = Math.max(x1, x2); + const yEnd = Math.max(y1, y2); - const xStart = Math.min(x1, x2); - const yStart = Math.min(y1, y2); - const xEnd = Math.max(x1, x2); - const yEnd = Math.max(y1, y2); + const height = Math.max(yEnd - yStart, 0); + const width = Math.max(xEnd - xStart, 0); - const height = Math.max(yEnd - yStart, 0); - const width = Math.max(xEnd - xStart, 0); + return ( + + ); + } +); - return ( - - ); -} +export default Rectangle; diff --git a/src/primitives/Text.tsx b/src/primitives/Text.tsx index 3be1569b..1ec3afaa 100644 --- a/src/primitives/Text.tsx +++ b/src/primitives/Text.tsx @@ -1,17 +1,21 @@ -import React from 'react' +import React from 'react'; // const defaultStyle = { fontFamily: 'Helvetica', fontSize: 10, opacity: 1, -} +}; -export default function Text({ style, opacity = 1, ...rest }) { - const resolvedStyle = { - ...defaultStyle, - ...style, +const Text = React.forwardRef>( + ({ style, ...rest }, ref) => { + const resolvedStyle = { + ...defaultStyle, + ...style, + }; + + return ; } +); - return -} +export default Text; diff --git a/src/utils/buildAxis.linear.ts b/src/utils/buildAxis.linear.ts index d22f1033..60aa4505 100644 --- a/src/utils/buildAxis.linear.ts +++ b/src/utils/buildAxis.linear.ts @@ -30,8 +30,8 @@ const scales = { [axisTypeOrdinal]: scaleBand, }; -const detectVertical = (d) => [positionLeft, positionRight].indexOf(d) > -1; -const detectRTL = (d) => [positionTop, positionRight].indexOf(d) > -1; +const detectVertical = d => [positionLeft, positionRight].indexOf(d) > -1; +const detectRTL = d => [positionTop, positionRight].indexOf(d) > -1; export default function buildAxisLinear({ axis: { @@ -58,7 +58,7 @@ export default function buildAxisLinear({ outerPadding = 0.1, showGrid = null, showTicks = true, - filterTicks = (d) => d, + filterTicks = d => d, show = true, stacked = false, id: userId, @@ -174,7 +174,7 @@ export default function buildAxisLinear({ let cursorSize = 0; let stepSize = 0; - let seriesBandScale = (d) => d; + let seriesBandScale = d => d; let seriesBarSize = 1; if (type === axisTypeOrdinal || primary) { @@ -188,7 +188,7 @@ export default function buildAxisLinear({ current.datums.length > prev.length ? current.datums : prev, [] ) - .map((d) => d.primary) + .map(d => d.primary) ) .rangeRound(range, 0.1) .padding(0); @@ -207,7 +207,7 @@ export default function buildAxisLinear({ seriesBandScale = scaleBand() .paddingInner(innerPadding / 2) .domain( - materializedData.filter((d) => d.Component === Bar).map((d, i) => i) + materializedData.filter(d => d.Component === Bar).map((d, i) => i) ) .rangeRound([0, barSize]);