import { localPoint } from "@visx/event"; import { Point } from "@visx/point"; import { scaleLog } from "@visx/scale"; import { Text } from "@visx/text"; import { useTooltip, withTooltip } from "@visx/tooltip"; import { Wordcloud as VisxWordcloud } from "@visx/wordcloud"; import React from "react"; import { Color } from "utils/Color"; import { inDevEnvironment } from "utils/inDevEnviroment"; import { useIsMobile } from "utils/isMobile"; import { TooltipWrapper } from "./TooltipWrapper"; import styles from "./WordCloud.module.css"; interface WordCloudProps { data: Array; /** Width of the graph, in px */ width?: number; /** The minimum width of the graph */ minWidth?: number; /** Height of the graph, in px */ height?: number; /** Minimum padding between words, in px */ wordPadding?: number; /** Weight of the font of the words */ fontWeight?: number; /** The desired font size of the smallest word on desktop, in px.*/ desktopMinFontSize?: number; /** The desired font size of the smallest word on mobile, in px.*/ mobileMinFontSize?: number; /** The desired font size of the largest word on desktop, in px. */ desktopMaxFontSize?: number; /** The desired font size of the largest word on mobile, in px. */ mobileMaxFontSize?: number; /** A random seed in the range [0, 1) used for placing the words, change this value to get an alternate placement of words */ randomSeed?: number; /** Type of spiral used for rendering the words, either rectangular or archimedean */ spiral?: "rectangular" | "archimedean"; /** ClassName of the wrapper of the wordcloud */ className?: string; } interface WordData { text: string; value: number; } const wordColors = [Color.primaryAccent, Color.primaryAccentLight]; const TOOLTIP_HORIZONTAL_SHIFT_SCALER = 12.0; export const WordCloud = withTooltip( ({ data, width, height, wordPadding, fontWeight, desktopMinFontSize, mobileMinFontSize, desktopMaxFontSize, mobileMaxFontSize, randomSeed, spiral, className, minWidth, }: WordCloudProps) => { const { tooltipData, tooltipLeft, tooltipTop, tooltipOpen, showTooltip, hideTooltip, } = useTooltip(); return (
{ showTooltip({ tooltipData: data, tooltipLeft: left, tooltipTop: top, }); }} hideTooltip={hideTooltip} tooltipLeft={tooltipLeft} tooltipTop={tooltipTop} randomSeed={randomSeed} spiral={spiral} isMobile={useIsMobile()} minWidth={minWidth} /> {tooltipOpen && tooltipData ? ( ) : null}
); } ); /** The internal wordcloud component that actually lays out the word needs to be separate from the tooltip to prevent extra rerendering. */ type WordCloudWordsProps = Omit & { showTooltip: ( data: WordData, tooltipLeft: number, tooltipTop: number ) => void; hideTooltip: () => void; // tooltipLeft and tooltipTop are used for preventing unnessary renders tooltipLeft?: number; tooltipTop?: number; isMobile: boolean; // passing in isMobile as a prop so we can rerender if this changes }; const WordCloudWords: React.FC = ({ data, width = 1000, minWidth = 500, height = 500, wordPadding = 20, fontWeight = 400, desktopMinFontSize = 15, desktopMaxFontSize = 100, mobileMinFontSize = 15, mobileMaxFontSize = 60, randomSeed = 0.5, spiral = "rectangular", showTooltip, hideTooltip, isMobile, }) => { width = width < minWidth ? minWidth : width; const minFontSize = isMobile ? mobileMinFontSize : desktopMinFontSize; const maxFontSize = isMobile ? mobileMaxFontSize : desktopMaxFontSize; const maxVal = Math.max(...data.map((w) => w.value)); const minVal = Math.min(...data.map((w) => w.value)); const fontScale = scaleLog({ domain: [minVal, maxVal], range: [minFontSize, maxFontSize], }); const fontSizeSetter = (datum: WordData) => fontScale(datum.value); const fixedValueGenerator = () => randomSeed; return ( {(cloudWords) => { if ( inDevEnvironment && cloudWords.length != 0 && // since on initial load the length is 0, but thats not an error cloudWords.length != data.length ) { console.error( `Not all words rendered for wordcloud! (${ data.length - cloudWords.length } words missing) Please try adjusting the min/max font size, the random seed, and the wordPadding` ); } return cloudWords.map((word, index) => { return ( ) => { // ownerSVGElement is given by visx docs but not recognized by typescript // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const eventElement = e.target.ownerSVGElement as Element; const eventSvgCoords = localPoint(eventElement, e) as Point; const rootSVGLeft = eventElement.parentElement?.parentElement?.getBoundingClientRect() .left ?? 0; const parentDivLeft = eventElement.parentElement?.parentElement?.parentElement?.getBoundingClientRect() .left ?? 0; // visx localPoint does not account for the horizontal shift due to centering of the parent element, // so manually add any shift from that const alignmentOffset = rootSVGLeft - parentDivLeft; if (word.text) { showTooltip( { text: word.text, value: data[index].value }, eventSvgCoords.x - word.text.length * TOOLTIP_HORIZONTAL_SHIFT_SCALER + alignmentOffset, eventSvgCoords.y ); } }) as React.MouseEventHandler } onMouseLeave={(_) => hideTooltip()} > {word.text} ); }); }} ); }; const shouldNotRerender = ( prevProps: WordCloudWordsProps, nextProps: WordCloudWordsProps ) => { if ( // if width changes, rerender, else don't rerender for a tooltip change prevProps.width === nextProps.width && prevProps.isMobile === nextProps.isMobile && (prevProps.tooltipLeft !== nextProps.tooltipLeft || prevProps.tooltipTop !== nextProps.tooltipTop || nextProps.tooltipLeft === undefined || nextProps.tooltipTop === undefined) ) { return true; // do not re-render } return false; // will re-render }; const WordCloudWordsMemoized = React.memo(WordCloudWords, shouldNotRerender);