initial commit

This commit is contained in:
Tristan Lee
2022-12-13 07:48:50 -06:00
commit 88b43dfd31
23 changed files with 28730 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
.DS_STORE
node_modules
scripts/flow/*/.flowconfig
.flowconfig
*~
*.pyc
.grunt
_SpecRunner.html
__benchmarks__
build/
remote-repo/
coverage/
.module-cache
fixtures/dom/public/react-dom.js
fixtures/dom/public/react.js
test/the-files-to-test.generated.js
*.log*
chrome-user-data
*.sublime-project
*.sublime-workspace
.idea
*.iml
.vscode
*.swp
*.swo
packages/react-devtools-core/dist
packages/react-devtools-extensions/chrome/build
packages/react-devtools-extensions/chrome/*.crx
packages/react-devtools-extensions/chrome/*.pem
packages/react-devtools-extensions/firefox/build
packages/react-devtools-extensions/firefox/*.xpi
packages/react-devtools-extensions/firefox/*.pem
packages/react-devtools-extensions/shared/build
packages/react-devtools-extensions/.tempUserDataDir
packages/react-devtools-inline/dist
packages/react-devtools-shell/dist
packages/react-devtools-timeline/dist

34
README.md Normal file
View File

@@ -0,0 +1,34 @@
# Sigma.js full-featured demo
This project aims to provide a full-features "real life" application using sigma.js. It was bootstrapped with [Create React App](https://github.com/facebook/create-react-app) and uses [react-sigma-v2](https://github.com/sim51/react-sigma-v2) to interface sigma.js with React.
## Dataset
The dataset has been kindly crafted by the [Sciences-Po médialab](https://medialab.sciencespo.fr/) and [OuestWare](https://www.ouestware.com/en/) teams using [Seealsology](https://densitydesign.github.io/strumentalia-seealsology/). It represents a network of Wikipedia pages, connected by ["See also"](https://en.wikipedia.org/wiki/See_also) links. It then was tagged by hand.
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:5000](http://localhost:5000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.

27377
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "sigma-demo",
"version": "0.1.0",
"private": true,
"homepage": "./",
"dependencies": {
"@types/lodash": "^4.14.178",
"@types/node": "^17.0.2",
"@types/react": "^17.0.37",
"@types/react-dom": "^17.0.11",
"babel-loader": "8.2.3",
"graphology": "^0.23.2",
"graphology-layout-forceatlas2": "^0.8.1",
"graphology-types": "^0.24.5",
"lodash": "^4.17.21",
"react": "^17.0.2",
"react-animate-height": "^2.0.23",
"react-dom": "^17.0.2",
"react-icons": "^4.3.1",
"react-scripts": "5.0.0",
"react-sigma-v2": "^1.3.0",
"sigma": "latest",
"typescript": "^4.5.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
],
"rules": {
"react-hooks/exhaustive-deps": "off"
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

File diff suppressed because one or more lines are too long

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

37
public/index.html Normal file
View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#e22653" />
<meta name="description" content="Entities mentioned in English-language posts from GESARA channels" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Entities mentioned in English-language posts from GESARA channels</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

114
src/canvas-utils.ts Normal file
View File

@@ -0,0 +1,114 @@
import { NodeDisplayData, PartialButFor, PlainObject } from "sigma/types";
import { Settings } from "sigma/settings";
const TEXT_COLOR = "#000000";
/**
* This function draw in the input canvas 2D context a rectangle.
* It only deals with tracing the path, and does not fill or stroke.
*/
export function drawRoundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
/**
* Custom hover renderer
*/
export function drawHover(context: CanvasRenderingContext2D, data: PlainObject, settings: PlainObject) {
const size = settings.labelSize;
const font = settings.labelFont;
const weight = settings.labelWeight;
const subLabelSize = size - 2;
const label = data.label;
const subLabel = data.tag !== "unknown" ? data.tag : "";
const clusterLabel = data.clusterLabel;
// Then we draw the label background
context.beginPath();
context.fillStyle = "#fff";
context.shadowOffsetX = 0;
context.shadowOffsetY = 2;
context.shadowBlur = 8;
context.shadowColor = "#000";
context.font = `${weight} ${size}px ${font}`;
const labelWidth = context.measureText(label).width;
context.font = `${weight} ${subLabelSize}px ${font}`;
const subLabelWidth = subLabel ? context.measureText(subLabel).width : 0;
context.font = `${weight} ${subLabelSize}px ${font}`;
const clusterLabelWidth = clusterLabel ? context.measureText(clusterLabel).width : 0;
const textWidth = Math.max(labelWidth, subLabelWidth, clusterLabelWidth);
const x = Math.round(data.x);
const y = Math.round(data.y);
const w = Math.round(textWidth + size / 2 + data.size + 3);
const hLabel = Math.round(size / 2 + 4);
const hSubLabel = subLabel ? Math.round(subLabelSize / 2 + 9) : 0;
const hClusterLabel = Math.round(subLabelSize / 2 + 9);
drawRoundRect(context, x, y - hSubLabel - 12, w, hClusterLabel + hLabel + hSubLabel + 12, 5);
context.closePath();
context.fill();
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
context.shadowBlur = 0;
// And finally we draw the labels
context.fillStyle = TEXT_COLOR;
context.font = `${weight} ${size}px ${font}`;
context.fillText(label, data.x + data.size + 3, data.y + size / 3);
if (subLabel) {
context.fillStyle = TEXT_COLOR;
context.font = `${weight} ${subLabelSize}px ${font}`;
context.fillText(subLabel, data.x + data.size + 3, data.y - (2 * size) / 3 - 2);
}
context.fillStyle = data.color;
context.font = `${weight} ${subLabelSize}px ${font}`;
context.fillText(clusterLabel, data.x + data.size + 3, data.y + size / 3 + 3 + subLabelSize);
}
/**
* Custom label renderer
*/
export default function drawLabel(
context: CanvasRenderingContext2D,
data: PartialButFor<NodeDisplayData, "x" | "y" | "size" | "label" | "color">,
settings: Settings,
): void {
if (!data.label) return;
const size = settings.labelSize,
font = settings.labelFont,
weight = settings.labelWeight;
context.font = `${weight} ${size}px ${font}`;
const width = context.measureText(data.label).width + 8;
context.fillStyle = "#ffffffcc";
context.fillRect(data.x + data.size, data.y + size / 3 - 15, width, 20);
context.fillStyle = "#000";
context.fillText(data.label, data.x + data.size + 3, data.y + size / 3);
}

12
src/index.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom";
import "./styles.css";
import Root from "./views/Root";
ReactDOM.render(
<React.StrictMode>
<Root />
</React.StrictMode>,
document.getElementById("root"),
);

1
src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

335
src/styles.css Normal file
View File

@@ -0,0 +1,335 @@
@import url("https://fonts.googleapis.com/css2?family=Lora&family=Public+Sans:ital@0;1&display=swap");
/**
* VARIABLES:
* **********
*/
:root {
--ruby: #e22653;
--grey: #999;
--dark-grey: #666;
--light-grey: #ccc;
--cream: #f9f7ed;
--transparent-white: #ffffffcc;
--transition: all ease-out 300ms;
--shadow: 0 1px 5px var(--dark-grey);
--hover-opacity: 0.7;
--stage-padding: 8px;
--panels-width: 350px;
--border-radius: 3px;
}
/**
* BASE STYLES:
* ************
*/
body {
font-family: "Public Sans", sans-serif;
background: white;
font-size: 0.9em;
overflow: hidden;
}
h1,
h2 {
font-family: Lora, serif;
}
h2 {
font-size: 1.3em;
margin: 0;
}
h2 > * {
vertical-align: text-top;
}
a {
color: black !important;
}
a:hover {
opacity: var(--hover-opacity);
}
/**
* LAYOUT:
* *******
*/
body {
margin: 0;
padding: 0;
}
#root {
width: 100vw;
height: 100vh;
position: relative;
}
#app-root,
.sigma-container {
position: absolute;
inset: 0;
}
.controls {
position: absolute;
bottom: var(--stage-padding);
left: var(--stage-padding);
}
.graph-title {
z-index: 1;
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: calc(100vw - var(--panels-width) - 3 * var(--stage-padding));
padding: var(--stage-padding);
}
.graph-title h1 {
font-size: 1.8em;
}
.graph-title h1,
.graph-title h2 {
margin: 0;
background: var(--transparent-white);
}
.panels {
position: absolute;
bottom: 0;
right: 0;
width: 350px;
max-height: calc(100vh - 2 * var(--stage-padding));
overflow-y: auto;
padding: var(--stage-padding);
scrollbar-width: thin;
}
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--grey);
border: transparent;
}
/**
* USEFUL CLASSES:
* ***************
*/
div.ico > button {
display: block;
position: relative;
font-size: 1.8em;
width: 2em;
height: 2em;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
color: black;
background: white;
border: none;
outline: none;
margin-top: 0.2em;
cursor: pointer;
}
div.ico > button:hover {
color: var(--dark-grey);
}
div.ico > button > * {
position: absolute;
inset: 0;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
button.btn {
background: white;
color: black;
border: 1px solid black;
outline: none;
border-radius: var(--border-radius);
padding: 0.3em 0.5em;
font-size: 1em;
font-family: Lato, sans-serif;
cursor: pointer;
}
button.btn:hover {
opacity: var(--hover-opacity);
}
button.btn > * {
vertical-align: baseline;
}
.buttons {
display: flex;
justify-content: space-between;
}
ul {
list-style: none;
padding: 0;
}
ul > li {
margin-top: 0.2em;
}
.text-muted {
color: var(--dark-grey);
}
.text-small {
font-size: 0.7em;
vertical-align: baseline;
}
.mouse-pointer {
cursor: pointer;
}
/**
* CAPTIONS PANELS:
* ****************
*/
.panel {
background: white;
padding: 1em;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
}
.panel:not(:last-child) {
margin-bottom: 0.5em;
}
.panel h2 button {
float: right;
background: white;
border: 1px solid black;
border-radius: var(--border-radius);
font-size: 1.2em;
height: 1em;
width: 1em;
text-align: center;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.panel h2 button:hover {
opacity: var(--hover-opacity);
}
.caption-row input[type="checkbox"] {
display: none;
}
.caption-row input[type="checkbox"]:not(:checked) + label {
color: var(--dark-grey);
}
.caption-row input[type="checkbox"]:not(:checked) + label .circle {
background-color: white !important;
}
.caption-row label {
display: flex;
flex-direction: row;
cursor: pointer;
}
.caption-row label:hover {
opacity: var(--hover-opacity);
}
.caption-row label .circle {
flex-shrink: 0;
display: inline-block;
width: 1.2em;
height: 1.2em;
border-radius: 1.2em;
vertical-align: middle;
box-sizing: border-box;
background-color: var(--dark-grey);
background-position: center;
background-size: cover;
background-repeat: no-repeat;
margin-right: 0.2em;
transition: var(--transition);
border: 3px solid var(--dark-grey);
}
.caption-row label .node-label {
flex-grow: 1;
}
.caption-row label .bar {
position: relative;
background: var(--light-grey);
height: 3px;
margin-bottom: 0.2em;
}
.caption-row label .bar .inside-bar {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--dark-grey);
transition: var(--transition);
}
/**
* SEARCH FIELD:
* *************
*/
.search-wrapper {
position: relative;
}
.search-wrapper > input[type="search"] {
width: calc(100%);
height: 3em;
box-shadow: var(--shadow);
border: none;
outline: none;
border-radius: var(--border-radius);
margin-bottom: 0.5em;
padding: 1em 1em 1em 3em;
font-family: Lato, sans-serif;
font-size: 1em;
}
.search-wrapper > .icon {
position: absolute;
width: 1em;
height: 1em;
top: 1em;
left: 1em;
}
/**
* RESPONSIVENESS:
* ***************
*/
@media (max-width: 767.98px) {
#app-root:not(.show-contents) .contents,
#app-root.show-contents .controls {
display: none;
}
#app-root.show-contents .contents {
position: absolute;
inset: 0;
overflow-y: auto;
scrollbar-width: thin;
background: var(--transparent-white);
}
#app-root.show-contents .graph-title,
#app-root.show-contents .panels {
height: auto;
max-height: unset;
max-width: unset;
position: static;
overflow-y: visible;
width: auto;
}
#app-root.show-contents .graph-title {
background: white;
padding-right: calc(3em + 2 * var(--stage-padding));
min-height: 3em;
}
#app-root.show-contents .contents .hide-contents {
position: absolute;
top: var(--stage-padding);
right: var(--stage-padding);
}
}
@media (min-width: 768px) {
button.show-contents,
button.hide-contents {
display: none !important;
}
}

27
src/types.ts Normal file
View File

@@ -0,0 +1,27 @@
import { Extent } from "sigma/types";
export interface NodeData {
key: string;
label: string;
cluster: string;
x: number;
y: number;
size: number;
}
export interface Cluster {
key: string;
color: string;
clusterLabel: string;
}
export interface Dataset {
nodes: NodeData[];
edges: [string, string][];
clusters: Cluster[];
bbox: {'x': Extent, 'y': Extent}
}
export interface FiltersState {
clusters: Record<string, boolean>;
}

27
src/use-debounce.ts Normal file
View File

@@ -0,0 +1,27 @@
import { useEffect, useState } from "react";
function useDebounce<T>(value: T, delay: number): T {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(
() => {
// Update debounced value after delay
const handler = setTimeout(() => {
if (value !== debouncedValue) setDebouncedValue(value);
}, delay);
// Cancel the timeout if value changes (also on delay change or unmount)
// This is how we prevent debounced value from updating if value is changed ...
// .. within the delay period. Timeout gets cleared and restarted.
return () => {
clearTimeout(handler);
};
},
[value, delay], // Only re-call effect if value or delay changes
);
return debouncedValue;
}
export default useDebounce;

112
src/views/ClustersPanel.tsx Normal file
View File

@@ -0,0 +1,112 @@
import React, { FC, useEffect, useMemo, useState } from "react";
import { useSigma } from "react-sigma-v2";
import { sortBy, values, keyBy, mapValues } from "lodash";
import { MdGroupWork } from "react-icons/md";
import { AiOutlineCheckCircle, AiOutlineCloseCircle } from "react-icons/ai";
import { Cluster, FiltersState } from "../types";
import Panel from "./Panel";
const ClustersPanel: FC<{
clusters: Cluster[];
filters: FiltersState;
toggleCluster: (cluster: string) => void;
setClusters: (clusters: Record<string, boolean>) => void;
}> = ({ clusters, filters, toggleCluster, setClusters }) => {
const sigma = useSigma();
const graph = sigma.getGraph();
const nodesPerCluster = useMemo(() => {
const index: Record<string, number> = {};
graph.forEachNode((_, { cluster }) => (index[cluster] = (index[cluster] || 0) + 1));
return index;
}, []);
const maxNodesPerCluster = useMemo(() => Math.max(...values(nodesPerCluster)), [nodesPerCluster]);
const visibleClustersCount = useMemo(() => Object.keys(filters.clusters).length, [filters]);
const [visibleNodesPerCluster, setVisibleNodesPerCluster] = useState<Record<string, number>>(nodesPerCluster);
useEffect(() => {
// To ensure the graphology instance has up to data "hidden" values for
// nodes, we wait for next frame before reindexing. This won't matter in the
// UX, because of the visible nodes bar width transition.
requestAnimationFrame(() => {
const index: Record<string, number> = {};
graph.forEachNode((_, { cluster, hidden }) => !hidden && (index[cluster] = (index[cluster] || 0) + 1));
setVisibleNodesPerCluster(index);
});
}, [filters]);
const sortedClusters = useMemo(
() => sortBy(clusters, (cluster) => -nodesPerCluster[cluster.key]),
[clusters, nodesPerCluster],
);
return (
<Panel
title={
<>
<MdGroupWork className="text-muted" /> Clusters
{visibleClustersCount < clusters.length ? (
<span className="text-muted text-small">
{" "}
({visibleClustersCount} / {clusters.length})
</span>
) : (
""
)}
</>
}
>
<p>
<i className="text-muted">Click a cluster to show/hide related pages from the network.</i>
</p>
<p className="buttons">
<button className="btn" onClick={() => setClusters(mapValues(keyBy(clusters, "key"), () => true))}>
<AiOutlineCheckCircle /> Check all
</button>{" "}
<button className="btn" onClick={() => setClusters({})}>
<AiOutlineCloseCircle /> Uncheck all
</button>
</p>
<ul>
{sortedClusters.map((cluster) => {
const nodesCount = nodesPerCluster[cluster.key];
const visibleNodesCount = visibleNodesPerCluster[cluster.key] || 0;
return (
<li
className="caption-row"
key={cluster.key}
title={`${nodesCount} page${nodesCount > 1 ? "s" : ""}${
visibleNodesCount !== nodesCount ? ` (only ${visibleNodesCount} visible)` : ""
}`}
>
<input
type="checkbox"
checked={filters.clusters[cluster.key] || false}
onChange={() => toggleCluster(cluster.key)}
id={`cluster-${cluster.key}`}
/>
<label htmlFor={`cluster-${cluster.key}`}>
<span className="circle" style={{ background: cluster.color, borderColor: cluster.color }} />{" "}
<div className="node-label">
<span>{cluster.clusterLabel}</span>
<div className="bar" style={{ width: (100 * nodesCount) / maxNodesPerCluster + "%" }}>
<div
className="inside-bar"
style={{
width: (100 * visibleNodesCount) / nodesCount + "%",
}}
/>
</div>
</div>
</label>
</li>
);
})}
</ul>
</Panel>
);
};
export default ClustersPanel;

View File

@@ -0,0 +1,78 @@
import React, { FC } from "react";
import { BsInfoCircle } from "react-icons/bs";
import Panel from "./Panel";
const DescriptionPanel: FC = () => {
return (
<Panel
initiallyDeployed
title={
<>
<BsInfoCircle className="text-muted" /> Description
</>
}
>
<p>
This visualisation represents a <i>network</i> of{" "}
<a target="_blank" rel="noreferrer" href="https://spacy.io/usage/linguistic-features#named-entities">
named entities
</a> in English-language posts archived in a database of Telegram channels that have posted about GESARA. Each{" "}
<i>node</i> represents an entity, <i>edges</i> between nodes indicate that one or more posts contain both entities
.
</p>
<p>
Some social media channels were identified by researchers from{" "}
<a target="_blank" rel="noreferrer" href="https://www.bellingcat.com/">
Bellingcat
</a>{" "}and{" "}
<a target="_blank" rel="noreferrer" href="https://www.lighthousereports.nl/">
Lighthouse Reports
</a>
, then several rounds of snowball sampling found forwarded channels that have posted about GESARA.
The entities were identified using {" "}
<a target="_blank" rel="noreferrer" href="https://spacy.io/">
spaCy
</a>
.
</p>
<p>
This web application has been developed by{" "}
<a target="_blank" rel="noreferrer" href="https://www.bellingcat.com/">
Bellingcat
</a>
, using{" "}
<a target="_blank" rel="noreferrer" href="https://reactjs.org/">
react
</a>{" "}
and{" "}
<a target="_blank" rel="noreferrer" href="https://www.sigmajs.org">
sigma.js
</a>
. You can read the source code{" "}
<a target="_blank" rel="noreferrer" href="https://github.com/jacomyal/sigma.js/tree/main/demo">
on GitHub
</a>
.
</p>
<p>
Node sizes are related to the number of times the entity was mentioned in the database.
</p>
<p>
Nodes are colored based a{" "}
<a target="_blank" rel="noreferrer" href="https://arxiv.org/abs/0803.0476">
community detection algorithm
</a>.
</p>
<p>
For visualisation purposes, edges were pruned using the{" "}
<a target="_blank" rel="noreferrer" href="https://github.com/naviddianati/GraphPruning">
Marginal Likelihood Filter
</a>
.
</p>
</Panel>
);
};
export default DescriptionPanel;

View File

@@ -0,0 +1,45 @@
import { useSigma } from "react-sigma-v2";
import { FC, useEffect } from "react";
import { keyBy, omit } from "lodash";
import { Dataset, FiltersState } from "../types";
const GraphDataController: FC<{ dataset: Dataset; filters: FiltersState }> = ({ dataset, filters, children }) => {
const sigma = useSigma();
const graph = sigma.getGraph();
/**
* Feed graphology with the new dataset:
*/
useEffect(() => {
if (!graph || !dataset) return;
sigma.setCustomBBox(dataset.bbox)
const clusters = keyBy(dataset.clusters, "key");
dataset.nodes.forEach((node) =>
graph.addNode(node.key, {
...node,
...omit(clusters[node.cluster], "key"),
}),
);
dataset.edges.forEach(([source, target]) => graph.addEdge(source, target, { size: 1 }));
return () => graph.clear();
}, [graph, dataset]);
/**
* Apply filters to graphology:
*/
useEffect(() => {
const { clusters } = filters;
graph.forEachNode((node, { cluster }) =>
graph.setNodeAttribute(node, "hidden", !clusters[cluster]),
);
}, [graph, filters]);
return <>{children}</>;
};
export default GraphDataController;

View File

@@ -0,0 +1,35 @@
import { useRegisterEvents, useSigma } from "react-sigma-v2";
import { FC, useEffect } from "react";
function getMouseLayer() {
return document.querySelector(".sigma-mouse");
}
const GraphEventsController: FC<{ setHoveredNode: (node: string | null) => void }> = ({ setHoveredNode, children }) => {
const registerEvents = useRegisterEvents();
/**
* Initialize here settings that require to know the graph and/or the sigma
* instance:
*/
useEffect(() => {
registerEvents({
enterNode({ node }) {
setHoveredNode(node);
// TODO: Find a better way to get the DOM mouse layer:
const mouseLayer = getMouseLayer();
if (mouseLayer) mouseLayer.classList.add("mouse-pointer");
},
leaveNode() {
setHoveredNode(null);
// TODO: Find a better way to get the DOM mouse layer:
const mouseLayer = getMouseLayer();
if (mouseLayer) mouseLayer.classList.remove("mouse-pointer");
},
});
}, []);
return <>{children}</>;
};
export default GraphEventsController;

View File

@@ -0,0 +1,60 @@
import { useSigma } from "react-sigma-v2";
import { FC, useEffect } from "react";
import { drawHover } from "../canvas-utils";
import useDebounce from "../use-debounce";
const NODE_FADE_COLOR = "#bbb";
const EDGE_FADE_COLOR = "#eee";
const GraphSettingsController: FC<{ hoveredNode: string | null }> = ({ children, hoveredNode }) => {
const sigma = useSigma();
const graph = sigma.getGraph();
// Here we debounce the value to avoid having too much highlights refresh when
// moving the mouse over the graph:
const debouncedHoveredNode = useDebounce(hoveredNode, 40);
/**
* Initialize here settings that require to know the graph and/or the sigma
* instance:
*/
useEffect(() => {
sigma.setSetting("hoverRenderer", (context, data, settings) =>
drawHover(context, { ...sigma.getNodeDisplayData(data.key), ...data }, settings),
);
}, [sigma, graph]);
/**
* Update node and edge reducers when a node is hovered, to highlight its
* neighborhood:
*/
useEffect(() => {
const hoveredColor: string = debouncedHoveredNode ? sigma.getNodeDisplayData(debouncedHoveredNode)!.color : "";
sigma.setSetting(
"nodeReducer",
debouncedHoveredNode
? (node, data) =>
node === debouncedHoveredNode ||
graph.hasEdge(node, debouncedHoveredNode) ||
graph.hasEdge(debouncedHoveredNode, node)
? { ...data, zIndex: 1 }
: { ...data, zIndex: 0, label: "", color: NODE_FADE_COLOR, image: null, highlighted: false }
: null,
);
sigma.setSetting(
"edgeReducer",
debouncedHoveredNode
? (edge, data) =>
graph.hasExtremity(edge, debouncedHoveredNode)
? { ...data, color: hoveredColor, size: 4 }
: { ...data, color: EDGE_FADE_COLOR, hidden: true }
: null,
);
}, [debouncedHoveredNode]);
return <>{children}</>;
};
export default GraphSettingsController;

47
src/views/GraphTitle.tsx Normal file
View File

@@ -0,0 +1,47 @@
import React, { FC, useEffect, useState } from "react";
import { useSigma } from "react-sigma-v2";
import { FiltersState } from "../types";
function prettyPercentage(val: number): string {
return (val * 100).toFixed(1) + "%";
}
const GraphTitle: FC<{ filters: FiltersState }> = ({ filters }) => {
const sigma = useSigma();
const graph = sigma.getGraph();
const [visibleItems, setVisibleItems] = useState<{ nodes: number; edges: number }>({ nodes: 0, edges: 0 });
useEffect(() => {
// To ensure the graphology instance has up to data "hidden" values for
// nodes, we wait for next frame before reindexing. This won't matter in the
// UX, because of the visible nodes bar width transition.
requestAnimationFrame(() => {
const index = { nodes: 0, edges: 0 };
graph.forEachNode((_, { hidden }) => !hidden && index.nodes++);
graph.forEachEdge((_, _2, _3, _4, source, target) => !source.hidden && !target.hidden && index.edges++);
setVisibleItems(index);
});
}, [filters]);
return (
<div className="graph-title">
<h1>Entities mentioned in English-language posts from GESARA channels</h1>
<h2>
<i>
{graph.order} node{graph.order > 1 ? "s" : ""}{" "}
{visibleItems.nodes !== graph.order
? ` (only ${prettyPercentage(visibleItems.nodes / graph.order)} visible)`
: ""}
, {graph.size} edge
{graph.size > 1 ? "s" : ""}{" "}
{visibleItems.edges !== graph.size
? ` (only ${prettyPercentage(visibleItems.edges / graph.size)} visible)`
: ""}
</i>
</h2>
</div>
);
};
export default GraphTitle;

37
src/views/Panel.tsx Normal file
View File

@@ -0,0 +1,37 @@
import React, { FC, useEffect, useRef, useState } from "react";
import { MdExpandLess, MdExpandMore } from "react-icons/md";
import AnimateHeight from "react-animate-height";
const DURATION = 300;
const Panel: FC<{ title: JSX.Element | string; initiallyDeployed?: boolean }> = ({
title,
initiallyDeployed,
children,
}) => {
const [isDeployed, setIsDeployed] = useState(initiallyDeployed || false);
const dom = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isDeployed)
setTimeout(() => {
if (dom.current) dom.current.parentElement!.scrollTo({ top: dom.current.offsetTop - 5, behavior: "smooth" });
}, DURATION);
}, [isDeployed]);
return (
<div className="panel" ref={dom}>
<h2>
{title}{" "}
<button type="button" onClick={() => setIsDeployed((v) => !v)}>
{isDeployed ? <MdExpandLess /> : <MdExpandMore />}
</button>
</h2>
<AnimateHeight duration={DURATION} height={isDeployed ? "auto" : 0}>
{children}
</AnimateHeight>
</div>
);
};
export default Panel;

133
src/views/Root.tsx Normal file
View File

@@ -0,0 +1,133 @@
import React, { FC, useEffect, useState } from "react";
import { SigmaContainer, ZoomControl, FullScreenControl } from "react-sigma-v2";
import { omit, mapValues, keyBy, constant } from "lodash";
import getNodeProgramImage from "sigma/rendering/webgl/programs/node.image";
import GraphSettingsController from "./GraphSettingsController";
import GraphEventsController from "./GraphEventsController";
import GraphDataController from "./GraphDataController";
import DescriptionPanel from "./DescriptionPanel";
import { Dataset, FiltersState } from "../types";
import ClustersPanel from "./ClustersPanel";
import SearchField from "./SearchField";
import drawLabel from "../canvas-utils";
import GraphTitle from "./GraphTitle";
import "react-sigma-v2/lib/react-sigma-v2.css";
import { GrClose } from "react-icons/gr";
import { BiRadioCircleMarked, BiBookContent } from "react-icons/bi";
import { BsArrowsFullscreen, BsFullscreenExit, BsZoomIn, BsZoomOut } from "react-icons/bs";
const Root: FC = () => {
const [showContents, setShowContents] = useState(false);
const [dataReady, setDataReady] = useState(false);
const [dataset, setDataset] = useState<Dataset | null>(null);
const [filtersState, setFiltersState] = useState<FiltersState>({
clusters: {},
});
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
// Load data on mount:
useEffect(() => {
fetch(`${process.env.PUBLIC_URL}/dataset_entities.json`)
.then((res) => res.json())
.then((dataset: Dataset) => {
setDataset(dataset);
setFiltersState({
clusters: mapValues(keyBy(dataset.clusters, "key"), constant(true)),
});
requestAnimationFrame(() => setDataReady(true));
});
}, []);
if (!dataset) return null;
return (
<div id="app-root" className={showContents ? "show-contents" : ""}>
<SigmaContainer
graphOptions={{ type: "undirected" }}
initialSettings={{
nodeProgramClasses: { image: getNodeProgramImage() },
labelRenderer: drawLabel,
defaultNodeType: "image",
labelDensity: 0.07,
labelGridCellSize: 60,
labelRenderedSizeThreshold: 10,
labelFont: "Lato, sans-serif",
zIndex: true,
}}
className="react-sigma"
>
<GraphSettingsController hoveredNode={hoveredNode} />
<GraphEventsController setHoveredNode={setHoveredNode} />
<GraphDataController dataset={dataset} filters={filtersState} />
{dataReady && (
<>
<div className="controls">
<div className="ico">
<button
type="button"
className="show-contents"
onClick={() => setShowContents(true)}
title="Show caption and description"
>
<BiBookContent />
</button>
</div>
<FullScreenControl
className="ico"
customEnterFullScreen={<BsArrowsFullscreen />}
customExitFullScreen={<BsFullscreenExit />}
/>
<ZoomControl
className="ico"
customZoomIn={<BsZoomIn />}
customZoomOut={<BsZoomOut />}
customZoomCenter={<BiRadioCircleMarked />}
/>
</div>
<div className="contents">
<div className="ico">
<button
type="button"
className="ico hide-contents"
onClick={() => setShowContents(false)}
title="Show caption and description"
>
<GrClose />
</button>
</div>
<GraphTitle filters={filtersState} />
<div className="panels">
<SearchField filters={filtersState} />
<DescriptionPanel />
<ClustersPanel
clusters={dataset.clusters}
filters={filtersState}
setClusters={(clusters) =>
setFiltersState((filters) => ({
...filters,
clusters,
}))
}
toggleCluster={(cluster) => {
setFiltersState((filters) => ({
...filters,
clusters: filters.clusters[cluster]
? omit(filters.clusters, cluster)
: { ...filters.clusters, [cluster]: true },
}));
}}
/>
</div>
</div>
</>
)}
</SigmaContainer>
</div>
);
};
export default Root;

102
src/views/SearchField.tsx Normal file
View File

@@ -0,0 +1,102 @@
import React, { KeyboardEvent, ChangeEvent, FC, useEffect, useState } from "react";
import { useSigma } from "react-sigma-v2";
import { Attributes } from "graphology-types";
import { BsSearch } from "react-icons/bs";
import { FiltersState } from "../types";
/**
* This component is basically a fork from React-sigma-v2's SearchControl
* component, to get some minor adjustments:
* 1. We need to hide hidden nodes from results
* 2. We need custom markup
*/
const SearchField: FC<{ filters: FiltersState }> = ({ filters }) => {
const sigma = useSigma();
const [search, setSearch] = useState<string>("");
const [values, setValues] = useState<Array<{ id: string; label: string }>>([]);
const [selected, setSelected] = useState<string | null>(null);
const refreshValues = () => {
const newValues: Array<{ id: string; label: string }> = [];
const lcSearch = search.toLowerCase();
if (!selected && search.length > 1) {
sigma.getGraph().forEachNode((key: string, attributes: Attributes): void => {
if (!attributes.hidden && attributes.label && attributes.label.toLowerCase().indexOf(lcSearch) === 0)
newValues.push({ id: key, label: attributes.label });
});
}
setValues(newValues);
};
// Refresh values when search is updated:
useEffect(() => refreshValues(), [search]);
// Refresh values when filters are updated (but wait a frame first):
useEffect(() => {
requestAnimationFrame(refreshValues);
}, [filters]);
useEffect(() => {
if (!selected) return;
sigma.getGraph().setNodeAttribute(selected, "highlighted", true);
const nodeDisplayData = sigma.getNodeDisplayData(selected);
if (nodeDisplayData)
sigma.getCamera().animate(
{ ...nodeDisplayData, ratio: 0.05 },
{
duration: 600,
},
);
return () => {
sigma.getGraph().setNodeAttribute(selected, "highlighted", false);
};
}, [selected]);
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const searchString = e.target.value;
const valueItem = values.find((value) => value.label === searchString);
if (valueItem) {
setSearch(valueItem.label);
setValues([]);
setSelected(valueItem.id);
} else {
setSelected(null);
setSearch(searchString);
}
};
const onKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && values.length) {
setSearch(values[0].label);
setSelected(values[0].id);
}
};
return (
<div className="search-wrapper">
<input
type="search"
placeholder="Search in nodes..."
list="nodes"
value={search}
onChange={onInputChange}
onKeyPress={onKeyPress}
/>
<BsSearch className="icon" />
<datalist id="nodes">
{values.map((value: { id: string; label: string }) => (
<option key={value.id} value={value.label}>
{value.label}
</option>
))}
</datalist>
</div>
);
};
export default SearchField;

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}