Skip to content

Commit

Permalink
feat: add explainable word cloud (#18)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: RoxaneBurri <[email protected]>
Co-authored-by: spaenleh <[email protected]>
  • Loading branch information
3 people authored May 26, 2023
1 parent 8bf7703 commit fd8f2f2
Show file tree
Hide file tree
Showing 10 changed files with 721 additions and 548 deletions.
6 changes: 0 additions & 6 deletions src/App.css

This file was deleted.

49 changes: 49 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useState } from "react";
import Wordcloud from "./Wordcloud";
import { defaultWords1 } from "./data";

const App = () => {
const [showBounds, setShowBounds] = useState(false);
const [showWordBounds, setShowWordBounds] = useState(false);

return (
<div style={{ flexDirection: "column", alignItems: "center" }}>
<fieldset>
<legend>Settings</legend>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div>
<input
id="showBounds"
name="showBounds"
type="checkbox"
value={showBounds.toString()}
onChange={() => setShowBounds((p) => !p)}
/>
<label htmlFor="showBounds">Show bounds</label>
</div>

<div>
<input
id="showWordBounds"
name="showWordBounds"
type="checkbox"
value={showWordBounds.toString()}
onChange={() => setShowWordBounds((p) => !p)}
/>
<label htmlFor="showWordBounds">Show Word bounds</label>
</div>

<div>
<button onClick={() => window.location.reload()}>Update</button>
</div>
</div>
</fieldset>
<Wordcloud
data={defaultWords1}
showBounds={showBounds}
showWordBounds={showWordBounds}
/>
</div>
);
};
export default App;
192 changes: 142 additions & 50 deletions src/Wordcloud.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import {
boundParent,
futurPosition,
getAreaRectangle,
getBoundingRect,
getBoundingWordCloud,
getMoveDirection,
getNewPositions,
placeFirstWord,
Rectangle,
slideWords,
Word,
} from "./utils";
import * as React from "react";
Expand All @@ -11,7 +17,6 @@ import {
CONTAINER_HEIGHT,
CONTAINER_WIDTH,
DEFAULT_RECT,
NUMBER_OF_INTERVALS,
} from "./constants";

const CUT_OFF = 0.5;
Expand All @@ -20,15 +25,19 @@ export const MAX_FONT_SIZE = 20;
export const MIN_FONT_SIZE = 6;

type Props = {
data: Word[];
data: { category: string; words: Word[] }[];
width?: number;
height?: number;
showBounds?: boolean;
showWordBounds?: boolean;
};

const Wordcloud = ({
data,
width = CONTAINER_WIDTH,
height = CONTAINER_HEIGHT,
showBounds = false,
showWordBounds = false,
}: Props) => {
const [words, setWords] = React.useState(data);

Expand All @@ -37,39 +46,79 @@ const Wordcloud = ({

const updateWords = () => {
setWords((prevWords) => {
const wordsToPlace = prevWords
.map((w) => ({ ...w, rect: getBoundingRect(w.id) || DEFAULT_RECT }))
.sort((a, b) => (a.coef > b.coef ? -1 : 1));
const rectsToPlace = wordsToPlace.map((w) => w.rect);
const firstRect = { ...rectsToPlace[0] };
const centeredRect = {
width: firstRect.width,
height: firstRect.height,
x: centerX,
y: centerY,
};
prevWords.forEach((cat) => ({
...cat,
words: cat.words.sort((a, b) => (a.coef > b.coef ? -1 : 1)),
}));
const wordCloudOfWordCloud = prevWords.map(({ words }) => {
const wordsToPlace = words.map((w) => ({
...w,
rect: getBoundingRect(w.id) || DEFAULT_RECT,
}));

wordsToPlace.sort((a, b) => (a.coef > b.coef ? -1 : 1));
const rectsToPlace = wordsToPlace.map((w) => w.rect);
const firstRect = { ...rectsToPlace[0] };

const centeredRect = placeFirstWord(firstRect, centerX, centerY);

// Initialize the weights with the value 1, of the size of the number of intervals
const weight = new Array(NUMBER_OF_INTERVALS).fill(1);
const newPositions = getNewPositions(rectsToPlace, centeredRect, 7)

return wordsToPlace.map((word, idx) => (
{
...word,
rect: newPositions[idx],
}));
});

const bigWordCloudsToPlace = wordCloudOfWordCloud.map((w) => ({
rect: getBoundingWordCloud(w) || DEFAULT_RECT,
}));
bigWordCloudsToPlace.sort((a, b) =>
getAreaRectangle(a.rect) > getAreaRectangle(b.rect) ? -1 : 1
);

const newPositions = rectsToPlace.slice(1).reduce(
(placedElements, rect) => {
const futureWord = futurPosition(rect, placedElements, 3, weight);
return [...placedElements, futureWord];
},
[centeredRect]
const bigWordCloudsRectToPlace = bigWordCloudsToPlace.map((w) => w.rect);
const firstWordCloud = { ...bigWordCloudsRectToPlace[0] };
const centeredWordCloud = placeFirstWord(
firstWordCloud,
centerX,
centerY
);

return wordsToPlace.map((word, idx) => ({
...word,
rect: newPositions[idx],
console.log("CENTERED", centeredWordCloud)

const newPositionWordCloud = getNewPositions(bigWordCloudsRectToPlace, centeredWordCloud,1)

// slide word inside the word cloud
const slideCoeff = wordCloudOfWordCloud.map((wordCloud, idx) =>
slideWords(
wordCloud.map((w) => w.rect),
getMoveDirection([centeredWordCloud], newPositionWordCloud[idx])
)
);
return prevWords.map((wordCloud, idx) => ({
...wordCloud,
words: wordCloud.words.map((w, idxw) => ({
...w,
rect: slideCoeff[idx][idxw],
})),
}));
});
};

// casting is fine here https://codereview.stackexchange.com/questions/135363/filtering-undefined-elements-out-of-an-array
const rects = words.map((w) => w.rect).filter(Boolean) as Rectangle[];

const rects = words
.map(
(wordCloud) =>
wordCloud.words.map((w) => w.rect).filter(Boolean) as Rectangle[]
)
.reduce((acc, wordCloud) => [...acc, ...wordCloud], []);
const bounds = words.map((wordCloud) =>
boundParent(
wordCloud.words.map((w) => w.rect).filter(Boolean) as Rectangle[]
)
);
const bound = rects.length
? boundParent(rects)
: {
Expand All @@ -81,39 +130,82 @@ const Wordcloud = ({

React.useEffect(() => {
updateWords();
}, []);
}, [data]);

return (
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
style={{ outline: "1px solid green" }}
style={{ outline: "1px solid transparent" }}
viewBox={`${bound.x} ${bound.y} ${bound.width} ${bound.height}`}
>
{words.map((word) => {
const fontSize =
(word.coef - CUT_OFF) *
(1 / (1 - CUT_OFF)) ** 2 *
(MAX_FONT_SIZE - MIN_FONT_SIZE) +
MIN_FONT_SIZE;

return (
<text
key={word.id}
// useful to have the anchor at the center of the word
textAnchor="middle"
fontSize={fontSize}
id={word.id}
x={(word.rect?.x || centerX).toString()}
// I don't know why I have to add the third of the fontSize to center te word vertically but it works
y={((word.rect?.y || centerY) + fontSize / 3).toString()}
>
{word.text}
</text>
);
})}
{words.map((wordCloud) => (
<g id={wordCloud.category} opacity={1}>
{wordCloud.words.map((word) => {
const fontSize =
(word.coef - CUT_OFF) *
(1 / (1 - CUT_OFF)) ** 2 *
(MAX_FONT_SIZE - MIN_FONT_SIZE) +
MIN_FONT_SIZE;

return (
<text
key={word.id}
// useful to have the anchor at the center of the word
textAnchor="middle"
fontSize={fontSize}
id={word.id}
x={(word.rect?.x || centerX).toString()}
// I don't know why I have to add the third of the fontSize to center te word vertically but it works
y={((word.rect?.y || centerY) + fontSize / 3).toString()}
>
{word.text}
</text>
);
})}
</g>
))}
{showBounds &&
bounds.map((b) => (
<rect
x={b.x}
y={b.y}
width={b.width}
height={b.height}
fill="none"
stroke="blue"
strokeWidth={1}
/>
))}
{showWordBounds &&
words.map((wordCloud) =>
wordCloud.words.map(({ text, rect: initRect }) => {
const rect = {
width: initRect?.width,
height: initRect?.height,
x: (initRect?.x || 0) - (initRect?.width || 0) / 2,
y: (initRect?.y || 0) - (initRect?.height || 0) / 2,
};
return (
<g opacity={0.5}>
<rect
x={rect?.x}
y={rect?.y}
width={rect?.width}
height={rect?.height}
fill="none"
stroke="green"
strokeWidth={1}
/>
<text x={rect?.x} y={rect?.y}>
{text}
</text>
</g>
);
})
)}
</svg>
);
};
Expand Down
9 changes: 7 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const CONTAINER_WIDTH = 500;
export const CONTAINER_HEIGHT = 300;
export const CONTAINER_WIDTH = 700;
export const CONTAINER_HEIGHT = 500;

export const CENTER_Y = CONTAINER_HEIGHT / 2;
export const CENTER_X = CONTAINER_WIDTH / 2;
Expand All @@ -10,5 +10,10 @@ export const DEFAULT_RECT = {
width: 10,
height: 10,
};
export const MARGIN_WIDTH = 10;
export const MARGIN_HEIGHT = -5;

export const WORD_CLOUD_MARGIN_WIDTH = 0;
export const WORD_CLOUD_MARGIN_HEIGHT = 0;

export const NUMBER_OF_INTERVALS = 6;
59 changes: 43 additions & 16 deletions src/data.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,49 @@
import { Word } from "./utils";

export const defaultWords1: Word[] = [
{ id: "word-1", text: " Big word ", coef: 0.99 },
{ id: "word-2", text: "hello", coef: 0.8 },
{ id: "word-4", text: "caramba", coef: 0.97 },
{ id: "word-3", text: "all", coef: 0.74 },
{ id: "word-5", text: "Piniata", coef: 0.6 },
{ id: "word-6", text: "Taxi", coef: 0.93 },
{ id: "word-7", text: "papa", coef: 0.94 },
{ id: "word-8", text: "chicita", coef: 0.66 },
{ id: "word-9", text: "hellicopter", coef: 0.92 },
{ id: "word-10", text: "chiold", coef: 0.75 },
{ id: "word-11", text: "text", coef: 0.81 },
{ id: "word-12", text: "document", coef: 0.77 },
{ id: "word-13", text: "text", coef: 0.89 },
{ id: "word-14", text: "finger", coef: 0.91 },
{ id: "word-15", text: "girl", coef: 0.88 },
export const defaultWords1: { category: string; words: Word[] }[] = [
{
category: "1",
words: [
{ id: "word-1", text: " Big word ", coef: 0.99 },
{ id: "word-2", text: "hello", coef: 0.8 },
{ id: "word-4", text: "caramba", coef: 0.97 },
],
},
{
category: "2",
words: [
{ id: "word-1", text: " Big word ", coef: 0.99 },
{ id: "word-2", text: "hello", coef: 0.8 },
{ id: "word-4", text: "caramba", coef: 0.97 },
{ id: "word-3", text: "all", coef: 0.94 },
{ id: "word-5", text: "Piniata", coef: 0.6 },
{ id: "word-6", text: "Taxi", coef: 0.93 },
{ id: "word-7", text: "papa", coef: 0.94 },
{ id: "word-8", text: "chicita", coef: 0.66 },
{ id: "word-9", text: "hellicopter", coef: 0.92 },
{ id: "word-10", text: "chiold", coef: 0.75 },
{ id: "word-11", text: "text", coef: 0.81 },
{ id: "word-12", text: "document", coef: 0.77 },
{ id: "word-13", text: "text", coef: 0.89 },
{ id: "word-14", text: "finger", coef: 0.91 },
{ id: "word-15", text: "girl", coef: 0.88 },
],
},
{
category: "3",
words: [
{ id: "word-1", text: " Big word ", coef: 0.6 },
{ id: "word-2", text: "hello world", coef: 0.8 },
{ id: "word-4", text: "caramba", coef: 0.97 },
{ id: "word-3", text: "all", coef: 0.74 },
{ id: "word-13", text: "grand-pa", coef: 0.89 },
{ id: "word-14", text: "finger", coef: 0.91 },
{ id: "word-15", text: "coffin", coef: 0.8 },
],
},
];

export const explainWordCloud = [{ key: "class1", value: defaultWords1 }];
export const defaultWords2: Word[] = [
{ id: "word-1", text: "group", coef: 0.99 },
{ id: "word-2", text: "people", coef: 0.99 },
Expand Down
Loading

0 comments on commit fd8f2f2

Please sign in to comment.