initial commit

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

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

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

12
src/index.tsx Normal file
View File

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

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

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

335
src/styles.css Normal file
View File

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

27
src/types.ts Normal file
View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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