Color code graph
This commit is contained in:
parent
2de412bfbb
commit
3b515c3a36
|
@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- It is now possible to color code the graph by instance type (e.g. Mastodon, Pleroma, etc.)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
|
@ -35,7 +35,7 @@ defmodule Backend.Api do
|
||||||
i.user_count >= ^user_threshold
|
i.user_count >= ^user_threshold
|
||||||
)
|
)
|
||||||
|> maybe_filter_nodes_to_neighborhood(domain)
|
|> maybe_filter_nodes_to_neighborhood(domain)
|
||||||
|> select([c], [:domain, :user_count, :x, :y])
|
|> select([c], [:domain, :user_count, :x, :y, :type])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,8 @@ defmodule Backend.Crawler.ApiCrawler do
|
||||||
# {domain_mentioned, count}
|
# {domain_mentioned, count}
|
||||||
@type instance_interactions :: %{String.t() => integer}
|
@type instance_interactions :: %{String.t() => integer}
|
||||||
|
|
||||||
|
@type instance_type :: :mastodon | :pleroma | :gab
|
||||||
|
|
||||||
defstruct [
|
defstruct [
|
||||||
:version,
|
:version,
|
||||||
:description,
|
:description,
|
||||||
|
@ -21,7 +23,8 @@ defmodule Backend.Crawler.ApiCrawler do
|
||||||
:status_count,
|
:status_count,
|
||||||
:peers,
|
:peers,
|
||||||
:interactions,
|
:interactions,
|
||||||
:statuses_seen
|
:statuses_seen,
|
||||||
|
:instance_type
|
||||||
]
|
]
|
||||||
|
|
||||||
@type t() :: %__MODULE__{
|
@type t() :: %__MODULE__{
|
||||||
|
@ -31,7 +34,8 @@ defmodule Backend.Crawler.ApiCrawler do
|
||||||
status_count: integer,
|
status_count: integer,
|
||||||
peers: [String.t()],
|
peers: [String.t()],
|
||||||
interactions: instance_interactions,
|
interactions: instance_interactions,
|
||||||
statuses_seen: integer
|
statuses_seen: integer,
|
||||||
|
instance_type: instance_type
|
||||||
}
|
}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
@ -103,6 +103,12 @@ defmodule Backend.Crawler do
|
||||||
}) do
|
}) do
|
||||||
now = get_now()
|
now = get_now()
|
||||||
|
|
||||||
|
instance_type =
|
||||||
|
case result.instance_type do
|
||||||
|
nil -> nil
|
||||||
|
not_nil_type -> Atom.to_string(not_nil_type)
|
||||||
|
end
|
||||||
|
|
||||||
## Update the instance we crawled ##
|
## Update the instance we crawled ##
|
||||||
Repo.insert!(
|
Repo.insert!(
|
||||||
%Instance{
|
%Instance{
|
||||||
|
@ -110,7 +116,8 @@ defmodule Backend.Crawler do
|
||||||
description: result.description,
|
description: result.description,
|
||||||
version: result.version,
|
version: result.version,
|
||||||
user_count: result.user_count,
|
user_count: result.user_count,
|
||||||
status_count: result.status_count
|
status_count: result.status_count,
|
||||||
|
type: instance_type
|
||||||
},
|
},
|
||||||
on_conflict: [
|
on_conflict: [
|
||||||
set: [
|
set: [
|
||||||
|
@ -118,6 +125,7 @@ defmodule Backend.Crawler do
|
||||||
version: result.version,
|
version: result.version,
|
||||||
user_count: result.user_count,
|
user_count: result.user_count,
|
||||||
status_count: result.status_count,
|
status_count: result.status_count,
|
||||||
|
type: instance_type,
|
||||||
updated_at: now
|
updated_at: now
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
@ -140,7 +148,7 @@ defmodule Backend.Crawler do
|
||||||
result.interactions
|
result.interactions
|
||||||
|> Map.keys()
|
|> Map.keys()
|
||||||
|> list_union(result.peers)
|
|> list_union(result.peers)
|
||||||
|> Enum.filter(fn domain -> not is_blacklisted?(domain) end)
|
|> Enum.filter(fn domain -> domain != nil and not is_blacklisted?(domain) end)
|
||||||
|
|
||||||
peers =
|
peers =
|
||||||
peers_domains
|
peers_domains
|
||||||
|
|
|
@ -38,12 +38,17 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
||||||
crawl_large_instance(domain, instance)
|
crawl_large_instance(domain, instance)
|
||||||
else
|
else
|
||||||
Map.merge(
|
Map.merge(
|
||||||
Map.merge(
|
Map.take(instance["stats"], ["user_count"])
|
||||||
Map.take(instance, ["version", "description"]),
|
|
||||||
Map.take(instance["stats"], ["user_count", "status_count"])
|
|
||||||
)
|
|
||||||
|> Map.new(fn {k, v} -> {String.to_atom(k), v} end),
|
|> Map.new(fn {k, v} -> {String.to_atom(k), v} end),
|
||||||
%{peers: [], interactions: %{}, statuses_seen: 0}
|
%{
|
||||||
|
peers: [],
|
||||||
|
interactions: %{},
|
||||||
|
statuses_seen: 0,
|
||||||
|
instance_type: nil,
|
||||||
|
description: nil,
|
||||||
|
version: nil,
|
||||||
|
status_count: nil
|
||||||
|
}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -68,13 +73,25 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
||||||
} mentions in #{statuses_seen} statuses."
|
} mentions in #{statuses_seen} statuses."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
instance_type =
|
||||||
|
cond do
|
||||||
|
Map.get(instance, "version") |> String.downcase() =~ "pleroma" -> :pleroma
|
||||||
|
is_gab?(instance) -> :gab
|
||||||
|
true -> :mastodon
|
||||||
|
end
|
||||||
|
|
||||||
Map.merge(
|
Map.merge(
|
||||||
Map.merge(
|
Map.merge(
|
||||||
Map.take(instance, ["version", "description"]),
|
Map.take(instance, ["version", "description"]),
|
||||||
Map.take(instance["stats"], ["user_count", "status_count"])
|
Map.take(instance["stats"], ["user_count", "status_count"])
|
||||||
)
|
)
|
||||||
|> Map.new(fn {k, v} -> {String.to_atom(k), v} end),
|
|> Map.new(fn {k, v} -> {String.to_atom(k), v} end),
|
||||||
%{peers: peers, interactions: interactions, statuses_seen: statuses_seen}
|
%{
|
||||||
|
peers: peers,
|
||||||
|
interactions: interactions,
|
||||||
|
statuses_seen: statuses_seen,
|
||||||
|
instance_type: instance_type
|
||||||
|
}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -208,4 +225,18 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
||||||
Map.merge(acc, map)
|
Map.merge(acc, map)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp is_gab?(instance) do
|
||||||
|
title_is_gab = Map.get(instance, "title") |> String.downcase() == "gab social"
|
||||||
|
|
||||||
|
contact_account = Map.get(instance, "contact_account")
|
||||||
|
|
||||||
|
if contact_account != nil do
|
||||||
|
gab_keys = ["is_pro", "is_verified", "is_donor", "is_investor"]
|
||||||
|
has_gab_keys = gab_keys |> Enum.any?(&Map.has_key?(contact_account, &1))
|
||||||
|
title_is_gab or has_gab_keys
|
||||||
|
else
|
||||||
|
title_is_gab
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -47,6 +47,7 @@ defmodule Backend.Crawler.StaleInstanceManager do
|
||||||
Process.send_after(self(), :queue_stale_domains, 60_000)
|
Process.send_after(self(), :queue_stale_domains, 60_000)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# TODO: crawl instances with a blocking robots.txt less often (daily?)
|
||||||
defp queue_stale_domains() do
|
defp queue_stale_domains() do
|
||||||
interval = -1 * get_config(:crawl_interval_mins)
|
interval = -1 * get_config(:crawl_interval_mins)
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ defmodule Backend.Instance do
|
||||||
field :status_count, :integer
|
field :status_count, :integer
|
||||||
field :version, :string
|
field :version, :string
|
||||||
field :insularity, :float
|
field :insularity, :float
|
||||||
|
field :type, :string
|
||||||
|
|
||||||
many_to_many :peers, Backend.Instance,
|
many_to_many :peers, Backend.Instance,
|
||||||
join_through: Backend.InstancePeer,
|
join_through: Backend.InstancePeer,
|
||||||
|
@ -33,7 +34,8 @@ defmodule Backend.Instance do
|
||||||
:status_count,
|
:status_count,
|
||||||
:version,
|
:version,
|
||||||
:insularity,
|
:insularity,
|
||||||
:updated_at
|
:updated_at,
|
||||||
|
:type
|
||||||
])
|
])
|
||||||
|> validate_required([:domain])
|
|> validate_required([:domain])
|
||||||
|> put_assoc(:peers, attrs.peers)
|
|> put_assoc(:peers, attrs.peers)
|
||||||
|
|
|
@ -4,12 +4,12 @@ defmodule BackendWeb.GraphView do
|
||||||
|
|
||||||
def render("index.json", %{nodes: nodes, edges: edges}) do
|
def render("index.json", %{nodes: nodes, edges: edges}) do
|
||||||
%{
|
%{
|
||||||
nodes: render_many(nodes, GraphView, "node.json"),
|
nodes: render_many(nodes, GraphView, "node.json", as: :node),
|
||||||
edges: render_many(edges, GraphView, "edge.json")
|
edges: render_many(edges, GraphView, "edge.json", as: :edge)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def render("node.json", %{graph: node}) do
|
def render("node.json", %{node: node}) do
|
||||||
size =
|
size =
|
||||||
case node.user_count > 1 do
|
case node.user_count > 1 do
|
||||||
true -> :math.log(node.user_count)
|
true -> :math.log(node.user_count)
|
||||||
|
@ -21,7 +21,8 @@ defmodule BackendWeb.GraphView do
|
||||||
data: %{
|
data: %{
|
||||||
id: node.domain,
|
id: node.domain,
|
||||||
label: node.domain,
|
label: node.domain,
|
||||||
size: size
|
size: size,
|
||||||
|
type: node.type
|
||||||
},
|
},
|
||||||
position: %{
|
position: %{
|
||||||
x: node.x,
|
x: node.x,
|
||||||
|
@ -30,7 +31,7 @@ defmodule BackendWeb.GraphView do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def render("edge.json", %{graph: edge}) do
|
def render("edge.json", %{edge: edge}) do
|
||||||
%{
|
%{
|
||||||
data: %{
|
data: %{
|
||||||
id: edge.id,
|
id: edge.id,
|
||||||
|
|
|
@ -23,7 +23,8 @@ defmodule BackendWeb.SearchView do
|
||||||
%{
|
%{
|
||||||
name: instance.domain,
|
name: instance.domain,
|
||||||
description: description,
|
description: description,
|
||||||
userCount: instance.user_count
|
userCount: instance.user_count,
|
||||||
|
type: instance.type
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
defmodule Backend.Repo.Migrations.AddInstanceType do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:instances) do
|
||||||
|
add :type, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,13 +2,18 @@ import { Card, Elevation, ICardProps } from "@blueprintjs/core";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
const FloatingCardRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
`;
|
||||||
const FloatingCardElement = styled(Card)`
|
const FloatingCardElement = styled(Card)`
|
||||||
position: absolute;
|
margin: 0 0 10px 10px;
|
||||||
bottom: 10px;
|
|
||||||
left: 10px;
|
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const FloatingCard: React.FC<ICardProps> = props => <FloatingCardElement elevation={Elevation.TWO} {...props} />;
|
const FloatingCard: React.FC<ICardProps> = props => (
|
||||||
|
<FloatingCardRow>
|
||||||
|
<FloatingCardElement elevation={Elevation.ONE} {...props} />
|
||||||
|
</FloatingCardRow>
|
||||||
|
);
|
||||||
|
|
||||||
export default FloatingCard;
|
export default FloatingCard;
|
||||||
|
|
74
frontend/src/components/atoms/GraphKey.tsx
Normal file
74
frontend/src/components/atoms/GraphKey.tsx
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import { Button, Classes, H5, H6, Icon, MenuItem } from "@blueprintjs/core";
|
||||||
|
import { IconNames } from "@blueprintjs/icons";
|
||||||
|
import { ItemRenderer, Select } from "@blueprintjs/select";
|
||||||
|
import React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { FloatingCard } from ".";
|
||||||
|
import { QUALITATIVE_COLOR_SCHEME } from "../../constants";
|
||||||
|
import { IColorSchemeType } from "../../types";
|
||||||
|
import { capitalize } from "../../util";
|
||||||
|
|
||||||
|
const ColorSchemeSelect = Select.ofType<IColorSchemeType>();
|
||||||
|
|
||||||
|
const StyledLi = styled.li`
|
||||||
|
margin-top: 2px;
|
||||||
|
`;
|
||||||
|
const StyledIcon = styled(Icon)`
|
||||||
|
margin-right: 5px;
|
||||||
|
`;
|
||||||
|
const StyledKeyContainer = styled.div`
|
||||||
|
margin-top: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface IGraphKeyProps {
|
||||||
|
current?: IColorSchemeType;
|
||||||
|
colorSchemes: IColorSchemeType[];
|
||||||
|
onItemSelect: (colorScheme?: IColorSchemeType) => void;
|
||||||
|
}
|
||||||
|
const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, onItemSelect }) => {
|
||||||
|
const unsetColorScheme = () => {
|
||||||
|
onItemSelect(undefined);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<FloatingCard>
|
||||||
|
<H5>Color coding</H5>
|
||||||
|
<ColorSchemeSelect
|
||||||
|
activeItem={current}
|
||||||
|
filterable={false}
|
||||||
|
items={colorSchemes}
|
||||||
|
itemRenderer={renderItem}
|
||||||
|
onItemSelect={onItemSelect}
|
||||||
|
popoverProps={{ minimal: true }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
text={(current && current.name) || "Select..."}
|
||||||
|
icon={IconNames.TINT}
|
||||||
|
rightIcon={IconNames.CARET_DOWN}
|
||||||
|
/>
|
||||||
|
<Button icon={IconNames.SMALL_CROSS} minimal={true} onClick={unsetColorScheme} disabled={!current} />
|
||||||
|
</ColorSchemeSelect>
|
||||||
|
{current && (
|
||||||
|
<StyledKeyContainer>
|
||||||
|
<H6>Key</H6>
|
||||||
|
<ul className={Classes.LIST_UNSTYLED}>
|
||||||
|
{current.values.map((v, idx) => (
|
||||||
|
<StyledLi>
|
||||||
|
<StyledIcon icon={IconNames.FULL_CIRCLE} color={QUALITATIVE_COLOR_SCHEME[idx]} />
|
||||||
|
{capitalize(v)}
|
||||||
|
</StyledLi>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</StyledKeyContainer>
|
||||||
|
)}
|
||||||
|
</FloatingCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem: ItemRenderer<IColorSchemeType> = (colorScheme, { handleClick, modifiers }) => {
|
||||||
|
if (!modifiers.matchesPredicate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GraphKey;
|
13
frontend/src/components/atoms/GraphResetButton.tsx
Normal file
13
frontend/src/components/atoms/GraphResetButton.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { Button } from "@blueprintjs/core";
|
||||||
|
import * as React from "react";
|
||||||
|
import FloatingCard from "./FloatingCard";
|
||||||
|
|
||||||
|
interface IGraphResetButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
const GraphResetButton: React.FC<IGraphResetButtonProps> = ({ onClick }) => (
|
||||||
|
<FloatingCard>
|
||||||
|
<Button icon="compass" title="Reset graph view" onClick={onClick} />
|
||||||
|
</FloatingCard>
|
||||||
|
);
|
||||||
|
export default GraphResetButton;
|
|
@ -1,2 +1,4 @@
|
||||||
export { default as Page } from "./Page";
|
export { default as Page } from "./Page";
|
||||||
export { default as FloatingCard } from "./FloatingCard";
|
export { default as FloatingCard } from "./FloatingCard";
|
||||||
|
export { default as GraphKey } from "./GraphKey";
|
||||||
|
export { default as GraphResetButton } from "./GraphResetButton";
|
||||||
|
|
|
@ -3,7 +3,8 @@ import * as React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import tippy, { Instance } from "tippy.js";
|
import tippy, { Instance } from "tippy.js";
|
||||||
import { DEFAULT_NODE_COLOR, SELECTED_NODE_COLOR } from "../../constants";
|
import { DEFAULT_NODE_COLOR, QUALITATIVE_COLOR_SCHEME, SELECTED_NODE_COLOR } from "../../constants";
|
||||||
|
import { IColorSchemeType } from "../../types";
|
||||||
|
|
||||||
const CytoscapeContainer = styled.div`
|
const CytoscapeContainer = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -12,20 +13,15 @@ const CytoscapeContainer = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface ICytoscapeProps {
|
interface ICytoscapeProps {
|
||||||
|
colorScheme?: IColorSchemeType;
|
||||||
currentNodeId: string | null;
|
currentNodeId: string | null;
|
||||||
elements: cytoscape.ElementsDefinition;
|
elements: cytoscape.ElementsDefinition;
|
||||||
navigateToInstancePath?: (domain: string) => void;
|
navigateToInstancePath?: (domain: string) => void;
|
||||||
navigateToRoot?: () => void;
|
navigateToRoot?: () => void;
|
||||||
}
|
}
|
||||||
class Cytoscape extends React.Component<ICytoscapeProps> {
|
class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
private cy?: cytoscape.Core;
|
private cy?: cytoscape.Core;
|
||||||
|
|
||||||
public shouldComponentUpdate(prevProps: ICytoscapeProps) {
|
|
||||||
// We only want to update this component if the current instance selection changes.
|
|
||||||
// We know that the `elements` prop will never change so we skip the expensive computations here.
|
|
||||||
return prevProps.currentNodeId !== this.props.currentNodeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentDidMount() {
|
public componentDidMount() {
|
||||||
const container = ReactDOM.findDOMNode(this);
|
const container = ReactDOM.findDOMNode(this);
|
||||||
this.cy = cytoscape({
|
this.cy = cytoscape({
|
||||||
|
@ -63,24 +59,8 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
||||||
});
|
});
|
||||||
|
|
||||||
const style = this.cy.style() as any;
|
const style = this.cy.style() as any;
|
||||||
|
|
||||||
style
|
style
|
||||||
.clear()
|
.clear()
|
||||||
.selector("node")
|
|
||||||
.style({
|
|
||||||
"background-color": DEFAULT_NODE_COLOR,
|
|
||||||
// The size from the backend is log_10(userCount), which from 10 <= userCount <= 1,000,000 gives us the range
|
|
||||||
// 1-6. We map this to the range of sizes we want.
|
|
||||||
// TODO: I should probably check that that the backend is actually using log_10 and not log_e, but it look
|
|
||||||
// quite good as it is, so...
|
|
||||||
height: "mapData(size, 1, 6, 20, 200)",
|
|
||||||
label: "data(id)",
|
|
||||||
width: "mapData(size, 1, 6, 20, 200)"
|
|
||||||
})
|
|
||||||
.selector("node:selected")
|
|
||||||
.style({
|
|
||||||
"background-color": SELECTED_NODE_COLOR
|
|
||||||
})
|
|
||||||
.selector("edge")
|
.selector("edge")
|
||||||
.style({
|
.style({
|
||||||
"curve-style": "haystack", // fast edges
|
"curve-style": "haystack", // fast edges
|
||||||
|
@ -100,8 +80,8 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
||||||
.selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them
|
.selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them
|
||||||
.style({
|
.style({
|
||||||
width: 2
|
width: 2
|
||||||
})
|
});
|
||||||
.update();
|
this.resetNodeColorScheme(style); // this function also called `update()`
|
||||||
|
|
||||||
this.cy.nodes().on("select", e => {
|
this.cy.nodes().on("select", e => {
|
||||||
const instanceId = e.target.data("id");
|
const instanceId = e.target.data("id");
|
||||||
|
@ -145,6 +125,9 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
||||||
|
|
||||||
public componentDidUpdate(prevProps: ICytoscapeProps) {
|
public componentDidUpdate(prevProps: ICytoscapeProps) {
|
||||||
this.setNodeSelection(prevProps.currentNodeId);
|
this.setNodeSelection(prevProps.currentNodeId);
|
||||||
|
if (prevProps.colorScheme !== this.props.colorScheme) {
|
||||||
|
this.updateColorScheme();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount() {
|
public componentWillUnmount() {
|
||||||
|
@ -195,6 +178,54 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
||||||
this.cy.center(selected);
|
this.cy.center(selected);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the color scheme to the default. A style object can optionally be passed.
|
||||||
|
* This is used during initilization to avoid having to do multiple renderings of the graph.
|
||||||
|
*/
|
||||||
|
private resetNodeColorScheme = (style?: any) => {
|
||||||
|
if (!style) {
|
||||||
|
style = this.cy!.style() as any;
|
||||||
|
}
|
||||||
|
style
|
||||||
|
.selector("node")
|
||||||
|
.style({
|
||||||
|
"background-color": DEFAULT_NODE_COLOR,
|
||||||
|
// The size from the backend is log_10(userCount), which from 10 <= userCount <= 1,000,000 gives us the range
|
||||||
|
// 1-6. We map this to the range of sizes we want.
|
||||||
|
// TODO: I should probably check that that the backend is actually using log_10 and not log_e, but it look
|
||||||
|
// quite good as it is, so...
|
||||||
|
height: "mapData(size, 1, 6, 20, 200)",
|
||||||
|
label: "data(id)",
|
||||||
|
width: "mapData(size, 1, 6, 20, 200)"
|
||||||
|
})
|
||||||
|
.selector("node:selected")
|
||||||
|
.style({
|
||||||
|
"background-color": SELECTED_NODE_COLOR
|
||||||
|
})
|
||||||
|
.update();
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateColorScheme = () => {
|
||||||
|
if (!this.cy) {
|
||||||
|
throw new Error("Expected cytoscape, but there wasn't one!");
|
||||||
|
}
|
||||||
|
const { colorScheme } = this.props;
|
||||||
|
let style = this.cy.style() as any;
|
||||||
|
if (!colorScheme) {
|
||||||
|
this.resetNodeColorScheme();
|
||||||
|
} else {
|
||||||
|
colorScheme.values.forEach((v, idx) => {
|
||||||
|
style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({
|
||||||
|
"background-color": QUALITATIVE_COLOR_SCHEME[idx]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
style
|
||||||
|
.selector("node:selected")
|
||||||
|
.style({ "background-color": SELECTED_NODE_COLOR })
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Cytoscape;
|
export default Cytoscape;
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
import { Button } from "@blueprintjs/core";
|
|
||||||
import * as React from "react";
|
|
||||||
import { FloatingCard } from "../atoms/";
|
|
||||||
|
|
||||||
interface IFloatingResetButtonProps {
|
|
||||||
onClick?: () => any;
|
|
||||||
}
|
|
||||||
const FloatingResetButton: React.FC<IFloatingResetButtonProps> = ({ onClick }) => (
|
|
||||||
<FloatingCard>
|
|
||||||
<Button icon="compass" title="Reset graph view" onClick={onClick} />
|
|
||||||
</FloatingCard>
|
|
||||||
);
|
|
||||||
export default FloatingResetButton;
|
|
33
frontend/src/components/molecules/GraphTools.tsx
Normal file
33
frontend/src/components/molecules/GraphTools.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { IColorSchemeType } from "../../types";
|
||||||
|
import { GraphKey, GraphResetButton } from "../atoms";
|
||||||
|
|
||||||
|
const GraphToolsContainer = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface IGraphToolsProps {
|
||||||
|
currentColorScheme?: IColorSchemeType;
|
||||||
|
colorSchemes: IColorSchemeType[];
|
||||||
|
onColorSchemeSelect: (colorScheme?: IColorSchemeType) => void;
|
||||||
|
onResetButtonClick: () => void;
|
||||||
|
}
|
||||||
|
const GraphTools: React.FC<IGraphToolsProps> = ({
|
||||||
|
currentColorScheme,
|
||||||
|
colorSchemes,
|
||||||
|
onColorSchemeSelect,
|
||||||
|
onResetButtonClick
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<GraphToolsContainer>
|
||||||
|
<GraphResetButton onClick={onResetButtonClick} />
|
||||||
|
<GraphKey current={currentColorScheme} colorSchemes={colorSchemes} onItemSelect={onColorSchemeSelect} />
|
||||||
|
</GraphToolsContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default GraphTools;
|
|
@ -1,10 +1,14 @@
|
||||||
import { Card, Classes, Elevation, H4 } from "@blueprintjs/core";
|
import { Card, Classes, Elevation, H4, Icon } from "@blueprintjs/core";
|
||||||
|
import { IconNames } from "@blueprintjs/icons";
|
||||||
import inflection from "inflection";
|
import inflection from "inflection";
|
||||||
import * as numeral from "numeral";
|
import * as numeral from "numeral";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import sanitize from "sanitize-html";
|
import sanitize from "sanitize-html";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { QUALITATIVE_COLOR_SCHEME } from "../../constants";
|
||||||
import { ISearchResultInstance } from "../../redux/types";
|
import { ISearchResultInstance } from "../../redux/types";
|
||||||
|
import { typeColorScheme } from "../../types";
|
||||||
|
import { capitalize } from "../../util";
|
||||||
|
|
||||||
const StyledCard = styled(Card)`
|
const StyledCard = styled(Card)`
|
||||||
width: 80%;
|
width: 80%;
|
||||||
|
@ -12,9 +16,19 @@ const StyledCard = styled(Card)`
|
||||||
background-color: #394b59 !important;
|
background-color: #394b59 !important;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
`;
|
`;
|
||||||
const StyledH4 = styled(H4)`
|
const StyledHeadingContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
`;
|
`;
|
||||||
|
const StyledH4 = styled(H4)`
|
||||||
|
margin: 0 5px 0 0;
|
||||||
|
flex: 1;
|
||||||
|
`;
|
||||||
|
const StyledType = styled.div`
|
||||||
|
margin: 0;
|
||||||
|
align-self: flex-end;
|
||||||
|
`;
|
||||||
const StyledUserCount = styled.div`
|
const StyledUserCount = styled.div`
|
||||||
margin: 0;
|
margin: 0;
|
||||||
`;
|
`;
|
||||||
|
@ -33,9 +47,24 @@ const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick }) => {
|
||||||
shortenedDescription = shortenedDescription.substring(0, 100) + "...";
|
shortenedDescription = shortenedDescription.substring(0, 100) + "...";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let typeIcon;
|
||||||
|
if (result.type) {
|
||||||
|
const idx = typeColorScheme.values.indexOf(result.type);
|
||||||
|
typeIcon = (
|
||||||
|
<StyledType className={Classes.TEXT_MUTED}>
|
||||||
|
<Icon icon={IconNames.SYMBOL_CIRCLE} color={QUALITATIVE_COLOR_SCHEME[idx]} />
|
||||||
|
{" " + capitalize(result.type)}
|
||||||
|
</StyledType>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledCard elevation={Elevation.ONE} interactive={true} key={result.name} onClick={onClick}>
|
<StyledCard elevation={Elevation.ONE} interactive={true} key={result.name} onClick={onClick}>
|
||||||
<StyledH4>{result.name}</StyledH4>
|
<StyledHeadingContainer>
|
||||||
|
<StyledH4>{result.name}</StyledH4>
|
||||||
|
{typeIcon}
|
||||||
|
</StyledHeadingContainer>
|
||||||
{result.userCount && (
|
{result.userCount && (
|
||||||
<StyledUserCount className={Classes.TEXT_MUTED}>
|
<StyledUserCount className={Classes.TEXT_MUTED}>
|
||||||
{numeral.default(result.userCount).format("0,0")} {inflection.inflect("people", result.userCount, "person")}
|
{numeral.default(result.userCount).format("0,0")} {inflection.inflect("people", result.userCount, "person")}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export { default as Cytoscape } from "./Cytoscape";
|
export { default as Cytoscape } from "./Cytoscape";
|
||||||
export { default as ErrorState } from "./ErrorState";
|
export { default as ErrorState } from "./ErrorState";
|
||||||
export { default as FloatingResetButton } from "./FloatingResetButton";
|
|
||||||
export { default as SearchResult } from "./SearchResult";
|
export { default as SearchResult } from "./SearchResult";
|
||||||
|
export { default as GraphTools } from "./GraphTools";
|
||||||
|
|
|
@ -7,8 +7,9 @@ import { Dispatch } from "redux";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { fetchGraph } from "../../redux/actions";
|
import { fetchGraph } from "../../redux/actions";
|
||||||
import { IAppState, IGraph } from "../../redux/types";
|
import { IAppState, IGraph } from "../../redux/types";
|
||||||
|
import { colorSchemes, IColorSchemeType } from "../../types";
|
||||||
import { domainMatchSelector } from "../../util";
|
import { domainMatchSelector } from "../../util";
|
||||||
import { Cytoscape, ErrorState, FloatingResetButton } from "../molecules/";
|
import { Cytoscape, ErrorState, GraphTools } from "../molecules/";
|
||||||
|
|
||||||
const GraphDiv = styled.div`
|
const GraphDiv = styled.div`
|
||||||
flex: 2;
|
flex: 2;
|
||||||
|
@ -22,12 +23,16 @@ interface IGraphProps {
|
||||||
isLoadingGraph: boolean;
|
isLoadingGraph: boolean;
|
||||||
navigate: (path: string) => void;
|
navigate: (path: string) => void;
|
||||||
}
|
}
|
||||||
class GraphImpl extends React.Component<IGraphProps> {
|
interface IGraphState {
|
||||||
|
colorScheme?: IColorSchemeType;
|
||||||
|
}
|
||||||
|
class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
||||||
private cytoscapeComponent: React.RefObject<Cytoscape>;
|
private cytoscapeComponent: React.RefObject<Cytoscape>;
|
||||||
|
|
||||||
public constructor(props: IGraphProps) {
|
public constructor(props: IGraphProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.cytoscapeComponent = React.createRef();
|
this.cytoscapeComponent = React.createRef();
|
||||||
|
this.state = { colorScheme: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidMount() {
|
public componentDidMount() {
|
||||||
|
@ -44,13 +49,19 @@ class GraphImpl extends React.Component<IGraphProps> {
|
||||||
content = (
|
content = (
|
||||||
<>
|
<>
|
||||||
<Cytoscape
|
<Cytoscape
|
||||||
|
colorScheme={this.state.colorScheme}
|
||||||
currentNodeId={this.props.currentInstanceName}
|
currentNodeId={this.props.currentInstanceName}
|
||||||
elements={this.props.graph}
|
elements={this.props.graph}
|
||||||
navigateToInstancePath={this.navigateToInstancePath}
|
navigateToInstancePath={this.navigateToInstancePath}
|
||||||
navigateToRoot={this.navigateToRoot}
|
navigateToRoot={this.navigateToRoot}
|
||||||
ref={this.cytoscapeComponent}
|
ref={this.cytoscapeComponent}
|
||||||
/>
|
/>
|
||||||
<FloatingResetButton onClick={this.resetGraphPosition} />
|
<GraphTools
|
||||||
|
onResetButtonClick={this.resetGraphPosition}
|
||||||
|
currentColorScheme={this.state.colorScheme}
|
||||||
|
colorSchemes={colorSchemes}
|
||||||
|
onColorSchemeSelect={this.setColorScheme}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -70,6 +81,10 @@ class GraphImpl extends React.Component<IGraphProps> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private setColorScheme = (colorScheme?: IColorSchemeType) => {
|
||||||
|
this.setState({ colorScheme });
|
||||||
|
};
|
||||||
|
|
||||||
private navigateToInstancePath = (domain: string) => {
|
private navigateToInstancePath = (domain: string) => {
|
||||||
this.props.navigate(`/instance/${domain}`);
|
this.props.navigate(`/instance/${domain}`);
|
||||||
};
|
};
|
||||||
|
|
|
@ -144,6 +144,8 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
}
|
}
|
||||||
|
|
||||||
private processEdgesToFindNeighbors = () => {
|
private processEdgesToFindNeighbors = () => {
|
||||||
|
// TODO: use cytoscape to replace this method
|
||||||
|
// simply cy.$id(nodeId).outgoers() (and/or incomers())
|
||||||
const { graph, instanceName } = this.props;
|
const { graph, instanceName } = this.props;
|
||||||
const { localGraph } = this.state;
|
const { localGraph } = this.state;
|
||||||
if ((!graph && !localGraph) || !instanceName) {
|
if ((!graph && !localGraph) || !instanceName) {
|
||||||
|
|
|
@ -4,6 +4,20 @@ export const DESKTOP_WIDTH_THRESHOLD = 1000;
|
||||||
export const DEFAULT_NODE_COLOR = "#CED9E0";
|
export const DEFAULT_NODE_COLOR = "#CED9E0";
|
||||||
export const SELECTED_NODE_COLOR = "#48AFF0";
|
export const SELECTED_NODE_COLOR = "#48AFF0";
|
||||||
|
|
||||||
|
// From https://blueprintjs.com/docs/#core/colors.qualitative-color-schemes
|
||||||
|
export const QUALITATIVE_COLOR_SCHEME = [
|
||||||
|
"#2965CC",
|
||||||
|
"#29A634",
|
||||||
|
"#D99E0B",
|
||||||
|
"#D13913",
|
||||||
|
"#8F398F",
|
||||||
|
"#00B3A4",
|
||||||
|
"#DB2C6F",
|
||||||
|
"#9BBF30",
|
||||||
|
"#96622D",
|
||||||
|
"#7157D9"
|
||||||
|
];
|
||||||
|
|
||||||
export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
|
export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
|
||||||
export interface IInstanceDomainPath {
|
export interface IInstanceDomainPath {
|
||||||
domain: string;
|
domain: string;
|
||||||
|
|
|
@ -33,6 +33,7 @@ export interface ISearchResultInstance {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
userCount?: number;
|
userCount?: number;
|
||||||
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IInstanceDetails {
|
export interface IInstanceDetails {
|
||||||
|
|
18
frontend/src/types.ts
Normal file
18
frontend/src/types.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
export interface IColorSchemeType {
|
||||||
|
// The name of the coloring, e.g. "Instance type"
|
||||||
|
name: string;
|
||||||
|
// The name of the key in a cytoscape node's `data` field to color by.
|
||||||
|
// For example, use cytoscapeDataKey: "type" to color according to type.
|
||||||
|
cytoscapeDataKey: string;
|
||||||
|
// The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"].
|
||||||
|
values: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const typeColorScheme: IColorSchemeType = {
|
||||||
|
cytoscapeDataKey: "type",
|
||||||
|
name: "Instance type",
|
||||||
|
// We could also extract the values from the server response, but this would slow things down...
|
||||||
|
values: ["mastodon", "gab", "pleroma"]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const colorSchemes: IColorSchemeType[] = [typeColorScheme];
|
|
@ -18,3 +18,5 @@ export const getFromApi = (path: string): Promise<any> => {
|
||||||
export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH);
|
export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH);
|
||||||
|
|
||||||
export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD;
|
export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD;
|
||||||
|
|
||||||
|
export const capitalize = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
||||||
|
|
Loading…
Reference in a new issue