mirror of
https://github.com/bellingcat/gesara-entity-viz.git
synced 2026-06-07 19:18:32 +03:00
initial commit
This commit is contained in:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal 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
34
README.md
Normal 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
27377
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/dataset_entities.json
Normal file
1
public/dataset_entities.json
Normal file
File diff suppressed because one or more lines are too long
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
37
public/index.html
Normal file
37
public/index.html
Normal 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
114
src/canvas-utils.ts
Normal 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
12
src/index.tsx
Normal 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
1
src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-scripts" />
|
||||||
335
src/styles.css
Normal file
335
src/styles.css
Normal 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
27
src/types.ts
Normal 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
27
src/use-debounce.ts
Normal 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
112
src/views/ClustersPanel.tsx
Normal 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;
|
||||||
78
src/views/DescriptionPanel.tsx
Normal file
78
src/views/DescriptionPanel.tsx
Normal 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;
|
||||||
45
src/views/GraphDataController.tsx
Normal file
45
src/views/GraphDataController.tsx
Normal 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;
|
||||||
35
src/views/GraphEventsController.tsx
Normal file
35
src/views/GraphEventsController.tsx
Normal 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;
|
||||||
60
src/views/GraphSettingsController.tsx
Normal file
60
src/views/GraphSettingsController.tsx
Normal 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
47
src/views/GraphTitle.tsx
Normal 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
37
src/views/Panel.tsx
Normal 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
133
src/views/Root.tsx
Normal 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
102
src/views/SearchField.tsx
Normal 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
26
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user