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