Add Wordcloud Component (#27)
parent
933833d331
commit
b586d52f72
@ -0,0 +1,20 @@ |
||||
.word:hover { |
||||
text-shadow: var(--primary-accent) 0 0 calc(20rem / 16); |
||||
text-anchor: "middle"; |
||||
cursor: default; |
||||
} |
||||
|
||||
.tooltip { |
||||
font-family: "Inconsolata", monospace; |
||||
font-weight: bold; |
||||
top: 0; |
||||
left: 0; |
||||
position: absolute; |
||||
background-color: var(--label); |
||||
color: var(--primary-background); |
||||
box-shadow: 0px calc(1rem / 16) calc(2rem / 16) var(--card-background); |
||||
pointer-events: none; |
||||
padding: calc(10rem / 16); |
||||
font-size: calc(18rem / 16); |
||||
border-radius: calc(10rem / 16); |
||||
} |
@ -0,0 +1,210 @@ |
||||
import { localPoint } from "@visx/event"; |
||||
import { Point } from "@visx/point"; |
||||
import { scaleLog } from "@visx/scale"; |
||||
import { Text } from "@visx/text"; |
||||
import { TooltipWithBounds, useTooltip, withTooltip } from "@visx/tooltip"; |
||||
import { Wordcloud as VisxWordcloud } from "@visx/wordcloud"; |
||||
import React from "react"; |
||||
import { Color } from "utils/Color"; |
||||
|
||||
import styles from "./WordCloud.module.css"; |
||||
|
||||
interface WordCloudProps { |
||||
data: Array<WordData>; |
||||
/** Width of the graph, in px */ |
||||
width?: 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, in px.*/ |
||||
minFontSize?: number; |
||||
/** The desired font size of the largest word, in px. */ |
||||
maxFontSize?: 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, |
||||
minFontSize, |
||||
maxFontSize, |
||||
randomSeed, |
||||
spiral, |
||||
className, |
||||
}: WordCloudProps) => { |
||||
const { |
||||
tooltipData, |
||||
tooltipLeft, |
||||
tooltipTop, |
||||
tooltipOpen, |
||||
showTooltip, |
||||
hideTooltip, |
||||
} = useTooltip<WordData>(); |
||||
|
||||
return ( |
||||
<div className={className}> |
||||
<WordCloudWordsMemoized |
||||
width={width} |
||||
height={height} |
||||
data={data} |
||||
wordPadding={wordPadding} |
||||
fontWeight={fontWeight} |
||||
minFontSize={minFontSize} |
||||
maxFontSize={maxFontSize} |
||||
showTooltip={(data, left, top) => { |
||||
showTooltip({ |
||||
tooltipData: data, |
||||
tooltipLeft: left, |
||||
tooltipTop: top, |
||||
}); |
||||
}} |
||||
hideTooltip={hideTooltip} |
||||
tooltipLeft={tooltipLeft} |
||||
tooltipTop={tooltipTop} |
||||
randomSeed={randomSeed} |
||||
spiral={spiral} |
||||
/> |
||||
|
||||
{tooltipOpen && tooltipData ? ( |
||||
<TooltipWithBounds |
||||
// set this to random so it correctly updates with parent bounds
|
||||
key={Math.random()} |
||||
top={tooltipTop} |
||||
left={tooltipLeft} |
||||
className={styles.tooltip} |
||||
unstyled |
||||
applyPositionStyle |
||||
> |
||||
{tooltipData.text} ({tooltipData.value}) |
||||
</TooltipWithBounds> |
||||
) : null} |
||||
</div> |
||||
); |
||||
} |
||||
); |
||||
|
||||
/** The internal wordcloud component that actually lays out the word needs to be separate from the tooltip to prevent extra rerendering. */ |
||||
type WordCloudWordsProps = Omit<WordCloudProps, "className"> & { |
||||
showTooltip: ( |
||||
data: WordData, |
||||
tooltipLeft: number, |
||||
tooltipTop: number |
||||
) => void; |
||||
hideTooltip: () => void; |
||||
// tooltipLeft and tooltipTop are used for preventing unnessary renders
|
||||
tooltipLeft?: number; |
||||
tooltipTop?: number; |
||||
}; |
||||
const WordCloudWords: React.FC<WordCloudWordsProps> = ({ |
||||
data, |
||||
width = 1000, |
||||
height = 500, |
||||
wordPadding = 30, |
||||
fontWeight = 500, |
||||
minFontSize = 20, |
||||
maxFontSize = 150, |
||||
randomSeed = 0.5, |
||||
spiral = "rectangular", |
||||
showTooltip, |
||||
hideTooltip, |
||||
}) => { |
||||
const fontScale = scaleLog({ |
||||
domain: [ |
||||
Math.min(...data.map((w) => w.value)), |
||||
Math.max(...data.map((w) => w.value)), |
||||
], |
||||
range: [minFontSize, maxFontSize], |
||||
}); |
||||
const fontSizeSetter = (datum: WordData) => fontScale(datum.value); |
||||
const fixedValueGenerator = () => randomSeed; |
||||
return ( |
||||
<VisxWordcloud |
||||
words={data} |
||||
width={width} |
||||
height={height} |
||||
fontSize={fontSizeSetter} |
||||
font="Inconsolata, monospace" |
||||
padding={wordPadding} |
||||
spiral={spiral} |
||||
rotate={0} |
||||
random={fixedValueGenerator} |
||||
> |
||||
{(cloudWords) => |
||||
cloudWords.map((word, index) => { |
||||
return ( |
||||
<Text |
||||
key={`wordcloud-word-${word.text ?? ""}-${index}`} |
||||
fill={wordColors[index % wordColors.length]} |
||||
transform={`translate(${word.x ?? 0}, ${word.y ?? 0})`} |
||||
fontSize={word.size} |
||||
fontFamily={word.font} |
||||
fontWeight={fontWeight} |
||||
className={styles.word} |
||||
textAnchor="middle" |
||||
onMouseMove={ |
||||
((e: React.MouseEvent<SVGTextElement, MouseEvent>) => { |
||||
const eventSvgCoords = localPoint( |
||||
// ownerSVGElement is given by visx docs but not recognized by typescript
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
e.target.ownerSVGElement as Element, |
||||
e |
||||
) as Point; |
||||
|
||||
if (word.text) { |
||||
showTooltip( |
||||
{ text: word.text, value: data[index].value }, |
||||
eventSvgCoords.x - |
||||
word.text.length * TOOLTIP_HORIZONTAL_SHIFT_SCALER, |
||||
eventSvgCoords.y |
||||
); |
||||
} |
||||
}) as React.MouseEventHandler<SVGTextElement> |
||||
} |
||||
onMouseLeave={(_) => hideTooltip()} |
||||
> |
||||
{word.text} |
||||
</Text> |
||||
); |
||||
}) |
||||
} |
||||
</VisxWordcloud> |
||||
); |
||||
}; |
||||
|
||||
const shouldNotRerender = ( |
||||
prevProps: WordCloudWordsProps, |
||||
nextProps: WordCloudWordsProps |
||||
) => { |
||||
if ( |
||||
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); |
Loading…
Reference in new issue