This commit is contained in:
Tristan Lee
2022-12-13 08:16:52 -06:00
parent 15e53f56fc
commit 53bc14f712
30 changed files with 92 additions and 29059 deletions

38
.gitignore vendored
View File

@@ -1,38 +0,0 @@
.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

View File

@@ -1,34 +0,0 @@
# 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.

13
asset-manifest.json Normal file
View File

@@ -0,0 +1,13 @@
{
"files": {
"main.css": "/gesara-entity-viz/static/css/main.ffef2fb8.css",
"main.js": "/gesara-entity-viz/static/js/main.ff804e72.js",
"index.html": "/gesara-entity-viz/index.html",
"main.ffef2fb8.css.map": "/gesara-entity-viz/static/css/main.ffef2fb8.css.map",
"main.ff804e72.js.map": "/gesara-entity-viz/static/js/main.ff804e72.js.map"
},
"entrypoints": [
"static/css/main.ffef2fb8.css",
"static/js/main.ff804e72.js"
]
}

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

1
index.html Normal file
View File

@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/gesara-entity-viz/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"/><title>Entities mentioned in English-language posts from GESARA channels</title><script defer="defer" src="/gesara-entity-viz/static/js/main.ff804e72.js"></script><link href="/gesara-entity-viz/static/css/main.ffef2fb8.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

27702
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +0,0 @@
{
"name": "sigma-demo",
"version": "0.1.0",
"private": true,
"homepage": "https://bellingcat.github.io/gesara-entity-viz/",
"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": {
"predeploy": "npm run build",
"deploy": "gh-pages -d build",
"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"
]
},
"devDependencies": {
"gh-pages": "^4.0.0"
}
}

View File

@@ -1,37 +0,0 @@
<!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>

View File

@@ -1,114 +0,0 @@
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);
}

View File

@@ -1,12 +0,0 @@
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"),
);

View File

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

View File

@@ -1,335 +0,0 @@
@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;
}
}

View File

@@ -1,27 +0,0 @@
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>;
}

View File

@@ -1,27 +0,0 @@
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;

View File

@@ -1,112 +0,0 @@
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

@@ -1,78 +0,0 @@
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

@@ -1,45 +0,0 @@
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

@@ -1,35 +0,0 @@
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

@@ -1,60 +0,0 @@
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;

View File

@@ -1,47 +0,0 @@
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;

View File

@@ -1,37 +0,0 @@
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;

View File

@@ -1,133 +0,0 @@
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;

View File

@@ -1,102 +0,0 @@
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;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,71 @@
/*
object-assign
(c) Sindre Sorhus
@license MIT
*/
/*!
Copyright (c) 2018 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/
/*! *****************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
/**
* @license
* Lodash <https://lodash.com/>
* Copyright OpenJS Foundation and other contributors <https://openjsf.org/>
* Released under MIT license <https://lodash.com/license>
* Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>
* Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
*/
/** @license React v0.20.2
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.2
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.2
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.2
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

File diff suppressed because one or more lines are too long

View File

@@ -1,26 +0,0 @@
{
"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"
]
}