Skip to content

Commit

Permalink
improve useResponsiveSVGSelection code. Fix types and some bugs. Hand…
Browse files Browse the repository at this point in the history
…le top-and-long words not being rendered.
  • Loading branch information
chrisrzhou committed Mar 16, 2019
1 parent 66e7f9d commit afd610f
Show file tree
Hide file tree
Showing 6 changed files with 162 additions and 96 deletions.
2 changes: 1 addition & 1 deletion docs/usage/size.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ By default, `ReactWordcloud` will inherit its parent container's size unless an
This is the default behavior. You can resize the Playground container to watch the wordcloud update when parent size changes.

<Playground>
<div style={{ backgroundColor: "#efefef", height: "100%", width: "100%" }}>
<div style={{ backgroundColor: "#efefef", height: "300px", width: "100%" }}>
<ReactWordcloud words={words} />
</div>
</Playground>
Expand Down
139 changes: 87 additions & 52 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,83 +4,118 @@ import ResizeObserver from 'resize-observer-polyfill';

import { MinMaxPair, Selection } from './types';

const { useEffect, useRef, useState } = React;
const { useEffect, useRef, useReducer } = React;

export function useResize(ref: React.RefObject<HTMLDivElement>): MinMaxPair {
const [size, setSize] = useState<MinMaxPair>([0, 0]);
interface State {
ref: React.RefObject<HTMLDivElement>;
selections: {
g: Selection;
svg: Selection;
};
size: MinMaxPair;
}

useEffect(() => {
const element = ref.current;
const resizeObserver = new ResizeObserver(entries => {
if (!entries || !entries.length) {
return;
}
const { width, height } = entries[0].contentRect;
setSize([width, height]);
});
resizeObserver.observe(element);
return () => resizeObserver.unobserve(element);
}, [ref]);
return size;
interface Action {
type: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any;
}

export function useResponsiveSVG<T>(
function reducer(state: State, action: Action): State {
const { type, payload } = action;
switch (type) {
case 'SET_SIZE':
return {
...state,
size: payload,
};
case 'SET_SELECTIONS':
return {
...state,
selections: payload,
};
default:
return state;
}
}

export function useResponsiveSVGSelection<T>(
minSize: MinMaxPair,
initialSize?: MinMaxPair,
): [React.RefObject<HTMLDivElement>, Selection, MinMaxPair] {
const ref = useRef();
const [selection, setSelection] = useState();
const [size, setSize] = useState(initialSize);
const resized = useResize(ref);

const hasResized = initialSize === undefined && (resized[0] || resized[1]);
): State {
const ref = useRef<HTMLDivElement>();
const svg = useRef<Selection>();
const g = useRef<Selection>();
const [state, dispatch] = useReducer(reducer, {
ref,
selections: {
g: null,
svg: null,
},
size: initialSize,
});

// set initial svg and size
useEffect(() => {
function updateSize(width: number, height: number): void {
svg.current.attr('height', height).attr('width', width);
g.current.attr('transform', `translate(${width / 2}, ${height / 2})`);
dispatch({
type: 'SET_SIZE',
payload: [width, height],
});
}

// set svg selections
const element = ref.current;
const {
offsetWidth: parentWidth,
offsetHeight: parentHeight,
} = element.parentNode;
svg.current = d3
.select(element)
.append('svg')
.style('display', 'block'); // inline svg leave white space
g.current = svg.current.append('g');
dispatch({
type: 'SET_SELECTIONS',
payload: {
g: g.current,
svg: svg.current,
},
});

// update initial size
let width = 0;
let height = 0;
// Use initialSize if it is provided
if (initialSize !== undefined) {
// Use initialSize if it is provided
[width, height] = initialSize;
} else {
// Use parentNode size if resized has not updated
if (resized[0] === 0 && resized[1] === 0) {
width = parentWidth;
height = parentHeight;
// Use resized when there are resize changes
} else {
[width, height] = resized;
}
width = element.parentElement.offsetWidth;
height = element.parentElement.offsetHeight;
}
// Ensure that minSize is always applied/handled before updating size
width = Math.max(width, minSize[0]);
height = Math.max(height, minSize[1]);
setSize([width, height]);
updateSize(width, height);

if (size !== undefined) {
setSelection(
d3
.select(element)
.append('svg')
.attr('height', height)
.attr('width', width)
.append('g')
.attr('transform', `translate(${width / 2}, ${height / 2})`),
);
}
// update resize using a resize observer
const resizeObserver = new ResizeObserver(entries => {
if (!entries || !entries.length) {
return;
}
if (initialSize === undefined) {
let { width, height } = entries[0].contentRect;
updateSize(width, height);
}
});
resizeObserver.observe(element);

// cleanup
return () => {
resizeObserver.unobserve(element);
d3.select(element)
.selectAll('*')
.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialSize, hasResized]);
}, [initialSize, minSize]);

return [ref, selection, size];
return state;
}
98 changes: 64 additions & 34 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { descending } from 'd3';
import * as d3Cloud from 'd3-cloud';
import * as React from 'react';

import { useResponsiveSVG } from './hooks';
import { useResponsiveSVGSelection } from './hooks';
import render from './render';
import { Callbacks, MinMaxPair, Options, Scale, Spiral, Word } from './types';
import { getDefaultColors, getFontScale, getText, rotate } from './utils';
Expand All @@ -17,7 +18,7 @@ export const defaultCallbacks: Callbacks = {
export const defaultOptions: Options = {
colors: getDefaultColors(),
enableTooltip: true,
fontFamily: 'impact',
fontFamily: 'times new roman',
fontSizes: [5, 40],
fontStyle: 'normal',
fontWeight: 'normal',
Expand All @@ -30,17 +31,33 @@ export const defaultOptions: Options = {
};

export interface Props {
/** Callbacks to control various word properties and behaviors (getWordColor, getWordTooltip, onWordClick, onWordMouseOut, onWordMouseOver). */
/**
* Callbacks to control various word properties and behaviors (getWordColor,
* getWordTooltip, onWordClick, onWordMouseOut, onWordMouseOver).
*/
callbacks?: Callbacks;
/** Set minimum [width, height] values for the SVG container. */
/**
* Set minimum [width, height] values for the SVG container.
*/
minSize?: MinMaxPair;
/** Maximum number of words to display. */
/**
* Maximum number of words to display.
*/
maxWords?: number;
/** Configure wordcloud with various options (colors, enableTooltip, fontFamily, fontSizes, fontStyle, fontWeight, padding, rotationAngles, rotations, scale, spiral, transitionDuration). */
/**
* Configure wordcloud with various options (colors, enableTooltip,
* fontFamily, fontSizes, fontStyle, fontWeight, padding, rotationAngles,
* rotations, scale, spiral, transitionDuration).
*/
options?: Options;
/** Set explicit [width, height] values for the SVG container. This will disable responsive resizing. */
/**
* Set explicit [width, height] values for the SVG container. This will
* disable responsive resizing.
*/
size?: MinMaxPair;
/** An array of word. A word must contain the 'text' and 'value' keys. */
/**
* An array of word. A word must contain the 'text' and 'value' keys.
*/
words: Word[];
}

Expand All @@ -52,7 +69,11 @@ function Wordcloud({
size: initialSize,
words,
}: Props): React.ReactElement {
const [ref, selection, size] = useResponsiveSVG(minSize, initialSize);
const { ref, selections, size } = useResponsiveSVGSelection(
minSize,
initialSize,
);
const selection = selections.g;

// render viz
useEffect(() => {
Expand All @@ -66,25 +87,28 @@ function Wordcloud({
fontStyle,
fontSizes,
fontWeight,
padding,
rotations,
rotationAngles,
spiral,
scale,
} = mergedOptions;

const sortedWords = words
.concat()
.sort()
.slice(0, maxWords);
const ctx = document.createElement('canvas').getContext('2d');
ctx.font = `${fontSizes[1]}px ${fontFamily}`;

if (rotations !== undefined) {
layout.rotate(() => rotate(rotations, rotationAngles));
}

const sortedWords = words
.concat()
.sort((x, y) => descending(x.value, y.value))
.slice(0, maxWords);

layout
.size(size)
.padding(1)
.words(words)
.padding(padding)
.words(sortedWords)
.spiral(spiral)
.text(getText)
.font(fontFamily)
Expand All @@ -97,31 +121,37 @@ function Wordcloud({
const fontScale = getFontScale(words, fontSizes, scale);
return fontScale(word.value);
})
.on('end', output => {
if (words.length !== output.length) {
// https://github.com/jasondavies/d3-cloud/issues/36
// recursively draw and decrease maxFontSize by minFontSize.
// Ensure that minFontSize is at least of value '1'
const minFontSize = fontSizes[0] || 1;
const maxFontSize = fontSizes[1] - fontSizes[0];
draw([minFontSize, maxFontSize]);
return;
} else {
render(selection, sortedWords, mergedOptions, mergedCallbacks);
}
.on('end', () => {
// For each word, we derive the x/y width projections based on the
// rotation angle. Calculate the scale factor of the respective
// width projections against the svg container width and height.
// Apply a universal font-size scaling (maximum value = 1) in render
let widthX = 0;
let widthY = 0;
sortedWords.forEach(word => {
const wordWidth = ctx.measureText(word.text).width * 1.1;
const angle = (word.rotate / 180) * Math.PI;
widthX = Math.max(wordWidth * Math.cos(angle), widthX);
widthY = Math.max(wordWidth * Math.sin(angle), widthY);
});
const scaleFactorX = size[0] / widthX;
const scaleFactorY = size[1] / widthY;
const scaleFactor = Math.min(1, scaleFactorX, scaleFactorY);
render(
selection,
sortedWords,
mergedOptions,
mergedCallbacks,
scaleFactor,
);
})
.start();
};
draw(fontSizes);
}
}, [callbacks, maxWords, options, selection, size, words]);

// outer div is the parent container while inner div houses the wordcloud svg
return (
<div>
<div ref={ref} />
</div>
);
return <div ref={ref} />;
}

Wordcloud.defaultProps = {
Expand Down
6 changes: 4 additions & 2 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default function render(
words: Word[],
options: Options,
callbacks: Callbacks,
scaleFactor: number,
): void {
const {
getWordColor,
Expand All @@ -21,6 +22,7 @@ export default function render(
} = callbacks;
const { colors, enableTooltip, fontStyle, fontWeight } = options;
const { fontFamily, transitionDuration } = options;
const scaledFontSize = getFontSize(scaleFactor);

function getFill(word: Word): string {
return getWordColor ? getWordColor(word) : choose(colors);
Expand Down Expand Up @@ -61,7 +63,7 @@ export default function render(
.attr('transform', 'translate(0, 0) rotate(0)')
.transition()
.duration(transitionDuration)
.attr('font-size', getFontSize)
.attr('font-size', scaledFontSize)
.attr('transform', getTransform)
.text(getText);

Expand All @@ -71,7 +73,7 @@ export default function render(
.duration(transitionDuration)
.attr('fill', getFill)
.attr('font-family', fontFamily)
.attr('font-size', getFontSize)
.attr('font-size', scaledFontSize)
.attr('transform', getTransform)
.text(getText);

Expand Down
7 changes: 2 additions & 5 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as d3 from 'd3';
import { Word as CloudWord } from 'd3-cloud';

export type MinMaxPair = [number, number];

Expand Down Expand Up @@ -38,11 +39,7 @@ export interface Options {
transitionDuration: number;
}

export interface Word {
export interface Word extends CloudWord {
text: string;
value: number;
rotate?: number;
size?: number;
x?: number;
y?: number;
}
Loading

0 comments on commit afd610f

Please sign in to comment.