diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3325ea3..f7499c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added ElasticSearch full-text search over instance domains and descriptions.
- Search results are now highlighted on the graph.
- When you hover a search result, it is now highlighted on the graph.
+- Instance details now show activity rate (average number of statuses posted per day).
+- It's now possible to color code by activity rate.
### Changed
diff --git a/README.md b/README.md
index 3eddbef..8c152c9 100644
--- a/README.md
+++ b/README.md
@@ -59,22 +59,31 @@ If running in docker, this means you run
This project doesn't crawl personal instances: the goal is to understand communities, not individuals. The threshold for what makes an instance "personal" is defined in the [backend config](backend/config/config.exs) and the [graph builder SQL](gephi/src/main/java/space/fediverse/graph/GraphBuilder.java).
## Deployment
+
You don't have to follow these instructions, but it's one way to set up a continuous deployment pipeline. The following are for the backend; the frontend is just a static HTML/JS site that can be deployed anywhere.
+
1. Install [Dokku](http://dokku.viewdocs.io/dokku/) on your web server.
2. Install [dokku-postgres](https://github.com/dokku/dokku-postgres), [dokku-monorepo](https://github.com/notpushkin/dokku-monorepo), and [dokku-letsencrypt](https://github.com/dokku/dokku-letsencrypt).
3. Create the apps
- * `dokku apps:create phoenix`
- * `dokku apps:create gephi`
+
+- `dokku apps:create phoenix`
+- `dokku apps:create gephi`
+
4. Create the backing database
- * `dokku postgres:create fediversedb`
- * `dokku postgres:link fediversedb phoenix`
- * `dokku postgres:link fediversedb gephi`
+
+- `dokku postgres:create fediversedb`
+- `dokku postgres:link fediversedb phoenix`
+- `dokku postgres:link fediversedb gephi`
+
5. Update the backend configuration. In particular, change the `user_agent` in [config.exs](/backend/config/config.exs) to something descriptive.
6. Push the apps, e.g. `git push dokku@:phoenix` (note that the first push cannot be from the CD pipeline).
7. Set up SSL for the Phoenix app
- * `dokku letsencrypt phoenix`
- * `dokku letsencrypt:cron-job --add`
+
+- `dokku letsencrypt phoenix`
+- `dokku letsencrypt:cron-job --add`
+
8. Set up a cron job for the graph layout (use the `dokku` user). E.g.
+
```
SHELL=/bin/bash
0 2 * * * /usr/bin/dokku run gephi java -Xmx1g -jar build/libs/graphBuilder.jar
@@ -82,6 +91,6 @@ SHELL=/bin/bash
## Acknowledgements
-[![NLnet logo](https://i.imgur.com/huV3rvo.png)](https://nlnet.nl/project/fediverse_space/)
+[![NLnet logo](/nlnet-logo.png)](https://nlnet.nl/project/fediverse_space/)
Many thanks to [NLnet](https://nlnet.nl/project/fediverse_space/) for their support and guidance of this project.
diff --git a/backend/config/config.exs b/backend/config/config.exs
index ecef9a5..18eb2c0 100644
--- a/backend/config/config.exs
+++ b/backend/config/config.exs
@@ -74,6 +74,8 @@ config :backend, Backend.Scheduler,
{"15 0 * * *", {Backend.Scheduler, :generate_edges, []}},
# 00.30 every night
{"30 0 * * *", {Backend.Scheduler, :generate_insularity_scores, []}},
+ # 00.45 every night
+ {"45 0 * * *", {Backend.Scheduler, :generate_status_rate, []}},
# Every 3 hours
{"0 */3 * * *", {Backend.Scheduler, :check_for_spam_instances, []}}
]
diff --git a/backend/lib/backend/api.ex b/backend/lib/backend/api.ex
index 4dcc230..ec935ce 100644
--- a/backend/lib/backend/api.ex
+++ b/backend/lib/backend/api.ex
@@ -37,7 +37,7 @@ defmodule Backend.Api do
i.user_count >= ^user_threshold and not i.opt_out
)
|> maybe_filter_nodes_to_neighborhood(domain)
- |> select([c], [:domain, :user_count, :x, :y, :type])
+ |> select([c], [:domain, :user_count, :x, :y, :type, :statuses_per_day])
|> Repo.all()
end
diff --git a/backend/lib/backend/instance.ex b/backend/lib/backend/instance.ex
index e0d6891..d497cf4 100644
--- a/backend/lib/backend/instance.ex
+++ b/backend/lib/backend/instance.ex
@@ -10,6 +10,7 @@ defmodule Backend.Instance do
field :version, :string
field :insularity, :float
field :type, :string
+ field :statuses_per_day, :float
field :base_domain, :string
field :opt_in, :boolean
field :opt_out, :boolean
@@ -39,6 +40,7 @@ defmodule Backend.Instance do
:insularity,
:updated_at,
:type,
+ :statuses_per_day,
:base_domain,
:opt_in,
:opt_out
diff --git a/backend/lib/backend/scheduler.ex b/backend/lib/backend/scheduler.ex
index 5ca1eaa..d2899d9 100644
--- a/backend/lib/backend/scheduler.ex
+++ b/backend/lib/backend/scheduler.ex
@@ -76,6 +76,61 @@ defmodule Backend.Scheduler do
)
end
+ @doc """
+ This function calculates the average number of statuses per hour over the last month.
+ """
+ def generate_status_rate() do
+ now = get_now()
+ # We want the earliest sucessful crawl so that we can exclude it from the statistics.
+ # This is because the first crawl goes up to one month into the past -- this would mess up the counts!
+ # The statistics from here assume that all statuses were written at exactly the crawl's inserted_at timestamp.
+ earliest_successful_crawl_subquery =
+ Crawl
+ |> group_by([c], c.instance_domain)
+ |> select([c], %{
+ instance_domain: c.instance_domain,
+ earliest_crawl: min(c.inserted_at)
+ })
+
+ instances =
+ Crawl
+ |> join(:inner, [c], c2 in subquery(earliest_successful_crawl_subquery),
+ on: c.instance_domain == c2.instance_domain
+ )
+ |> where(
+ [c, c2],
+ c.inserted_at > c2.earliest_crawl and not is_nil(c.statuses_seen) and is_nil(c.error)
+ )
+ |> select([c], %{
+ instance_domain: c.instance_domain,
+ status_count: sum(c.statuses_seen),
+ second_earliest_crawl: min(c.inserted_at)
+ })
+ |> group_by([c], c.instance_domain)
+ |> Repo.all()
+ |> Enum.map(fn %{
+ instance_domain: domain,
+ status_count: status_count,
+ second_earliest_crawl: oldest_timestamp
+ } ->
+ time_diff_days = NaiveDateTime.diff(now, oldest_timestamp, :second) / (3600 * 24)
+
+ # (we're actually only ever updating, not inserting, so inserted_at will always be ignored...)
+ %{
+ domain: domain,
+ statuses_per_day: status_count / time_diff_days,
+ updated_at: now,
+ inserted_at: now
+ }
+ end)
+
+ Instance
+ |> Repo.insert_all(instances,
+ on_conflict: {:replace, [:statuses_per_day, :updated_at]},
+ conflict_target: :domain
+ )
+ end
+
@doc """
This function aggregates statistics from the interactions in the database.
It calculates the strength of edges between nodes. Self-edges are not generated.
diff --git a/backend/lib/backend_web/views/graph_view.ex b/backend/lib/backend_web/views/graph_view.ex
index 04afab0..b231177 100644
--- a/backend/lib/backend_web/views/graph_view.ex
+++ b/backend/lib/backend_web/views/graph_view.ex
@@ -3,9 +3,24 @@ defmodule BackendWeb.GraphView do
alias BackendWeb.GraphView
def render("index.json", %{nodes: nodes, edges: edges}) do
+ statuses_per_day =
+ nodes
+ |> Enum.map(fn %{statuses_per_day: statuses_per_day} -> statuses_per_day end)
+ |> Enum.filter(fn s -> s != nil end)
+
%{
- nodes: render_many(nodes, GraphView, "node.json", as: :node),
- edges: render_many(edges, GraphView, "edge.json", as: :edge)
+ graph: %{
+ nodes: render_many(nodes, GraphView, "node.json", as: :node),
+ edges: render_many(edges, GraphView, "edge.json", as: :edge)
+ },
+ metadata: %{
+ ranges: %{
+ statusesPerDay: [
+ Enum.min(statuses_per_day),
+ Enum.max(statuses_per_day)
+ ]
+ }
+ }
}
end
@@ -22,7 +37,8 @@ defmodule BackendWeb.GraphView do
id: node.domain,
label: node.domain,
size: size,
- type: node.type
+ type: node.type,
+ statusesPerDay: node.statuses_per_day
},
position: %{
x: node.x,
diff --git a/backend/lib/backend_web/views/instance_view.ex b/backend/lib/backend_web/views/instance_view.ex
index 8391062..3c04b8e 100644
--- a/backend/lib/backend_web/views/instance_view.ex
+++ b/backend/lib/backend_web/views/instance_view.ex
@@ -42,7 +42,8 @@ defmodule BackendWeb.InstanceView do
peers: render_many(filtered_peers, InstanceView, "instance.json"),
lastUpdated: last_updated,
status: status,
- type: instance.type
+ type: instance.type,
+ statusesPerDay: instance.statuses_per_day
}
end
end
diff --git a/backend/priv/repo/migrations/20190727140717_add_statuses_per_day.exs b/backend/priv/repo/migrations/20190727140717_add_statuses_per_day.exs
new file mode 100644
index 0000000..0598d22
--- /dev/null
+++ b/backend/priv/repo/migrations/20190727140717_add_statuses_per_day.exs
@@ -0,0 +1,9 @@
+defmodule Backend.Repo.Migrations.AddStatusesPerHour do
+ use Ecto.Migration
+
+ def change do
+ alter table(:instances) do
+ add :statuses_per_day, :float
+ end
+ end
+end
diff --git a/frontend/src/assets/nlnet.png b/frontend/src/assets/nlnet.png
new file mode 100644
index 0000000..27fad98
Binary files /dev/null and b/frontend/src/assets/nlnet.png differ
diff --git a/frontend/src/components/atoms/FloatingCard.tsx b/frontend/src/components/atoms/FloatingCard.tsx
index 87b63cd..32b6a0d 100644
--- a/frontend/src/components/atoms/FloatingCard.tsx
+++ b/frontend/src/components/atoms/FloatingCard.tsx
@@ -4,6 +4,7 @@ import styled from "styled-components";
const FloatingCardRow = styled.div`
display: flex;
+ max-width: 250px;
`;
const FloatingCardElement = styled(Card)`
margin: 0 0 10px 10px;
diff --git a/frontend/src/components/atoms/GraphKey.tsx b/frontend/src/components/atoms/GraphKey.tsx
index 83f6908..a978d2b 100644
--- a/frontend/src/components/atoms/GraphKey.tsx
+++ b/frontend/src/components/atoms/GraphKey.tsx
@@ -1,12 +1,14 @@
-import { Button, Classes, H5, H6, MenuItem } from "@blueprintjs/core";
+import { Button, Classes, H5, MenuItem } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { ItemRenderer, Select } from "@blueprintjs/select";
+import * as numeral from "numeral";
import React from "react";
import styled from "styled-components";
import { FloatingCard, InstanceType } from ".";
-import { IColorSchemeType } from "../../types";
+import { QUANTITATIVE_COLOR_SCHEME } from "../../constants";
+import { IColorScheme } from "../../types";
-const ColorSchemeSelect = Select.ofType();
+const ColorSchemeSelect = Select.ofType();
const StyledLi = styled.li`
margin-top: 2px;
@@ -14,16 +16,49 @@ const StyledLi = styled.li`
const StyledKeyContainer = styled.div`
margin-top: 10px;
`;
+const ColorKeyContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+ height: 100px;
+`;
+const ColorBarContainer = styled.div`
+ width: 10px;
+ display: flex;
+ flex-direction: column;
+ margin-right: 10px;
+`;
+interface IColorBarProps {
+ color: string;
+}
+const ColorBar = styled.div`
+ width: 10px;
+ background-color: ${props => props.color};
+ flex: 1;
+`;
+const TextContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+`;
interface IGraphKeyProps {
- current?: IColorSchemeType;
- colorSchemes: IColorSchemeType[];
- onItemSelect: (colorScheme?: IColorSchemeType) => void;
+ current?: IColorScheme;
+ colorSchemes: IColorScheme[];
+ ranges?: { [key: string]: [number, number] };
+ onItemSelect: (colorScheme?: IColorScheme) => void;
}
-const GraphKey: React.FC = ({ current, colorSchemes, onItemSelect }) => {
+const GraphKey: React.FC = ({ current, colorSchemes, ranges, onItemSelect }) => {
const unsetColorScheme = () => {
onItemSelect(undefined);
};
+ let key;
+ if (current) {
+ if (current.type === "qualitative") {
+ key = renderQualitativeKey(current.values);
+ } else if (current.type === "quantitative") {
+ key = renderQuantitativeKey(ranges![current.cytoscapeDataKey]);
+ }
+ }
return (
Color coding
@@ -42,27 +77,49 @@ const GraphKey: React.FC = ({ current, colorSchemes, onItemSelec
/>
- {current && (
-
- Key
-
- {current.values.map(v => (
-
-
-
- ))}
-
-
+
+ {!!current && !!key && (
+ <>
+ {current.description && {current.description} }
+ {key}
+ >
)}
);
};
-const renderItem: ItemRenderer = (colorScheme, { handleClick, modifiers }) => {
+const renderItem: ItemRenderer = (colorScheme, { handleClick, modifiers }) => {
if (!modifiers.matchesPredicate) {
return null;
}
return ;
};
+const renderQualitativeKey = (values: string[]) => (
+
+ {values.map(v => (
+
+
+
+ ))}
+
+);
+
+const renderQuantitativeKey = (range: number[]) => {
+ const [min, max] = range;
+ return (
+
+
+ {QUANTITATIVE_COLOR_SCHEME.map((color, idx) => (
+
+ ))}
+
+
+ {numeral.default(min).format("0")}
+ {numeral.default(max).format("0")}
+
+
+ );
+};
+
export default GraphKey;
diff --git a/frontend/src/components/molecules/Cytoscape.tsx b/frontend/src/components/molecules/Cytoscape.tsx
index cef9821..df06369 100644
--- a/frontend/src/components/molecules/Cytoscape.tsx
+++ b/frontend/src/components/molecules/Cytoscape.tsx
@@ -8,10 +8,12 @@ import {
DEFAULT_NODE_COLOR,
HOVERED_NODE_COLOR,
QUALITATIVE_COLOR_SCHEME,
+ QUANTITATIVE_COLOR_SCHEME,
SEARCH_RESULT_COLOR,
SELECTED_NODE_COLOR
} from "../../constants";
-import { IColorSchemeType } from "../../types";
+import { IColorScheme } from "../../types";
+import { getBuckets } from "../../util";
const CytoscapeContainer = styled.div`
width: 100%;
@@ -20,10 +22,11 @@ const CytoscapeContainer = styled.div`
`;
interface ICytoscapeProps {
- colorScheme?: IColorSchemeType;
+ colorScheme?: IColorScheme;
currentNodeId: string | null;
elements: cytoscape.ElementsDefinition;
hoveringOver?: string;
+ ranges?: { [key: string]: [number, number] };
searchResultIds?: string[];
navigateToInstancePath?: (domain: string) => void;
navigateToRoot?: () => void;
@@ -251,15 +254,33 @@ class Cytoscape extends React.PureComponent {
let style = this.cy.style() as any;
if (!colorScheme) {
this.resetNodeColorScheme();
- } else {
+ return;
+ } else if (colorScheme.type === "qualitative") {
colorScheme.values.forEach((v, idx) => {
style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({
"background-color": QUALITATIVE_COLOR_SCHEME[idx]
});
});
+ } else if (colorScheme.type === "quantitative") {
+ const dataKey = colorScheme.cytoscapeDataKey;
+ if (!this.props.ranges || !this.props.ranges[dataKey]) {
+ throw new Error("Expected a range but did not receive one!");
+ }
+ // Create buckets for the range and corresponding classes
+ const [minVal, maxVal] = this.props.ranges[dataKey];
+ const buckets = getBuckets(minVal, maxVal, QUANTITATIVE_COLOR_SCHEME.length, colorScheme.exponential);
- this.setNodeSearchColorScheme(style);
+ QUANTITATIVE_COLOR_SCHEME.forEach((color, idx) => {
+ const min = buckets[idx];
+ // Make sure the max value is also included in a bucket!
+ const max = idx === QUANTITATIVE_COLOR_SCHEME.length - 1 ? maxVal + 1 : buckets[idx + 1];
+ const selector = `node[${dataKey} >= ${min}][${dataKey} < ${max}]`;
+ style = style.selector(selector).style({
+ "background-color": color
+ });
+ });
}
+ this.setNodeSearchColorScheme(style);
};
/**
diff --git a/frontend/src/components/molecules/GraphTools.tsx b/frontend/src/components/molecules/GraphTools.tsx
index 61febd7..2ef75d0 100644
--- a/frontend/src/components/molecules/GraphTools.tsx
+++ b/frontend/src/components/molecules/GraphTools.tsx
@@ -1,6 +1,6 @@
import React from "react";
import styled from "styled-components";
-import { IColorSchemeType } from "../../types";
+import { IColorScheme } from "../../types";
import { GraphKey, GraphResetButton } from "../atoms";
const GraphToolsContainer = styled.div`
@@ -12,21 +12,28 @@ const GraphToolsContainer = styled.div`
`;
interface IGraphToolsProps {
- currentColorScheme?: IColorSchemeType;
- colorSchemes: IColorSchemeType[];
- onColorSchemeSelect: (colorScheme?: IColorSchemeType) => void;
+ currentColorScheme?: IColorScheme;
+ colorSchemes: IColorScheme[];
+ ranges?: { [key: string]: [number, number] };
+ onColorSchemeSelect: (colorScheme?: IColorScheme) => void;
onResetButtonClick: () => void;
}
const GraphTools: React.FC = ({
currentColorScheme,
colorSchemes,
+ ranges,
onColorSchemeSelect,
onResetButtonClick
}) => {
return (
-
+
);
};
diff --git a/frontend/src/components/organisms/Graph.tsx b/frontend/src/components/organisms/Graph.tsx
index 0ec5b04..fae4092 100644
--- a/frontend/src/components/organisms/Graph.tsx
+++ b/frontend/src/components/organisms/Graph.tsx
@@ -6,8 +6,8 @@ import { push } from "connected-react-router";
import { Dispatch } from "redux";
import styled from "styled-components";
import { fetchGraph } from "../../redux/actions";
-import { IAppState, IGraph } from "../../redux/types";
-import { colorSchemes, IColorSchemeType } from "../../types";
+import { IAppState, IGraphResponse } from "../../redux/types";
+import { colorSchemes, IColorScheme } from "../../types";
import { domainMatchSelector } from "../../util";
import { Cytoscape, ErrorState, GraphTools } from "../molecules/";
@@ -18,7 +18,7 @@ const GraphDiv = styled.div`
interface IGraphProps {
currentInstanceName: string | null;
fetchGraph: () => void;
- graph?: IGraph;
+ graphResponse?: IGraphResponse;
graphLoadError: boolean;
hoveringOverResult?: string;
isLoadingGraph: boolean;
@@ -26,7 +26,7 @@ interface IGraphProps {
navigate: (path: string) => void;
}
interface IGraphState {
- colorScheme?: IColorSchemeType;
+ colorScheme?: IColorScheme;
}
class GraphImpl extends React.PureComponent {
private cytoscapeComponent: React.RefObject;
@@ -45,7 +45,7 @@ class GraphImpl extends React.PureComponent {
let content;
if (this.props.isLoadingGraph) {
content = } title="Loading..." />;
- } else if (this.props.graphLoadError || !this.props.graph) {
+ } else if (this.props.graphLoadError || !this.props.graphResponse) {
content = ;
} else {
content = (
@@ -53,7 +53,8 @@ class GraphImpl extends React.PureComponent {
{
currentColorScheme={this.state.colorScheme}
colorSchemes={colorSchemes}
onColorSchemeSelect={this.setColorScheme}
+ ranges={this.props.graphResponse.metadata.ranges}
/>
>
);
@@ -85,7 +87,7 @@ class GraphImpl extends React.PureComponent {
}
};
- private setColorScheme = (colorScheme?: IColorSchemeType) => {
+ private setColorScheme = (colorScheme?: IColorScheme) => {
this.setState({ colorScheme });
};
@@ -101,8 +103,8 @@ const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state);
return {
currentInstanceName: match && match.params.domain,
- graph: state.data.graph,
graphLoadError: state.data.error,
+ graphResponse: state.data.graphResponse,
hoveringOverResult: state.search.hoveringOverResult,
isLoadingGraph: state.data.isLoadingGraph,
searchResultDomains: state.search.results.map(r => r.name)
diff --git a/frontend/src/components/screens/AboutScreen.tsx b/frontend/src/components/screens/AboutScreen.tsx
index 6afdbf3..ef2e6cb 100644
--- a/frontend/src/components/screens/AboutScreen.tsx
+++ b/frontend/src/components/screens/AboutScreen.tsx
@@ -1,5 +1,6 @@
import { Classes, Code, H1, H2, H4 } from "@blueprintjs/core";
import * as React from "react";
+import nlnetLogo from "../../assets/nlnet.png";
import { Page } from "../atoms/";
const AboutScreen: React.FC = () => (
@@ -33,24 +34,32 @@ const AboutScreen: React.FC = () => (
How do I add my personal instance?
-
- Send a DM to{" "}
-
- @fediversespace
- {" "}
- on Mastodon. Make sure to send it from the account that's listed as the instance admin.
-
+ Click on the Administration link in the top right to opt-in.
How do you calculate the strength of relationships between instances?
- fediverse.space scrapes the last 5000 statuses from within the last month on the public timeline of each instance.
- It looks at the ratio of
+ fediverse.space looks at statuses from within the last month on the public timeline of each instance. It
+ calculates at the ratio of
mentions of an instance / total statuses
. It uses a ratio rather than an absolute number of mentions
to reflect that smaller instances can play a large role in a community.
Credits
- This site is inspired by several other sites in the same vein:
+
+
+
+
+
+
+
+ This project is proudly supported by{" "}
+
+ NLnet
+
+ .
+
+
+ Inspiration for this site comes from several places:
diff --git a/frontend/src/components/screens/InstanceScreen.tsx b/frontend/src/components/screens/InstanceScreen.tsx
index 226958e..15a949b 100644
--- a/frontend/src/components/screens/InstanceScreen.tsx
+++ b/frontend/src/components/screens/InstanceScreen.tsx
@@ -259,7 +259,16 @@ class InstanceScreenImpl extends React.PureComponent
@@ -298,6 +307,25 @@ class InstanceScreenImpl extends React.PureComponent
{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}
+
+
+ Statuses / day{" "}
+
+ The average number of statuses per day
+
+ over the last month.
+
+ }
+ position={Position.TOP}
+ className={Classes.DARK}
+ >
+
+
+
+ {(statusesPerDay && numeral.default(statusesPerDay).format("0.0")) || "Unknown"}
+
Known peers
{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}
@@ -430,7 +458,7 @@ class InstanceScreenImpl extends React.PureComponent {
const match = domainMatchSelector(state);
return {
- graph: state.data.graph,
+ graph: state.data.graphResponse && state.data.graphResponse.graph,
instanceDetails: state.currentInstance.currentInstanceDetails,
instanceLoadError: state.currentInstance.error,
instanceName: match && match.params.domain,
diff --git a/frontend/src/constants.tsx b/frontend/src/constants.tsx
index 04e3812..004ff77 100644
--- a/frontend/src/constants.tsx
+++ b/frontend/src/constants.tsx
@@ -20,6 +20,20 @@ export const QUALITATIVE_COLOR_SCHEME = [
"#AD99FF"
];
+// From https://blueprintjs.com/docs/#core/colors.sequential-color-schemes
+export const QUANTITATIVE_COLOR_SCHEME = [
+ "#FFB7A5",
+ "#F5A793",
+ "#EB9882",
+ "#E18970",
+ "#D77A60",
+ "#CC6A4F",
+ "#C15B3F",
+ "#B64C2F",
+ "#AA3C1F",
+ "#9E2B0E"
+];
+
export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
export interface IInstanceDomainPath {
domain: string;
diff --git a/frontend/src/redux/reducers.ts b/frontend/src/redux/reducers.ts
index 04ea1ec..6803bdb 100644
--- a/frontend/src/redux/reducers.ts
+++ b/frontend/src/redux/reducers.ts
@@ -8,18 +8,18 @@ const initialDataState = {
error: false,
isLoadingGraph: false
};
-const data = (state: IDataState = initialDataState, action: IAction) => {
+const data = (state: IDataState = initialDataState, action: IAction): IDataState => {
switch (action.type) {
case ActionType.REQUEST_GRAPH:
return {
...state,
- graph: undefined,
+ graphResponse: undefined,
isLoadingGraph: true
};
case ActionType.RECEIVE_GRAPH:
return {
...state,
- graph: action.payload,
+ graphResponse: action.payload,
isLoadingGraph: false
};
case ActionType.GRAPH_LOAD_ERROR:
diff --git a/frontend/src/redux/types.ts b/frontend/src/redux/types.ts
index 0256b57..3de16a2 100644
--- a/frontend/src/redux/types.ts
+++ b/frontend/src/redux/types.ts
@@ -48,6 +48,7 @@ export interface IInstanceDetails {
lastUpdated?: string;
status: string;
type?: string;
+ statusesPerDay?: number;
}
interface IGraphNode {
@@ -71,11 +72,20 @@ interface IGraphEdge {
};
}
+interface IGraphMetadata {
+ ranges: { [key: string]: [number, number] };
+}
+
export interface IGraph {
nodes: IGraphNode[];
edges: IGraphEdge[];
}
+export interface IGraphResponse {
+ graph: IGraph;
+ metadata: IGraphMetadata;
+}
+
export interface ISearchResponse {
results: ISearchResultInstance[];
next: string | null;
@@ -91,7 +101,7 @@ export interface ICurrentInstanceState {
}
export interface IDataState {
- graph?: IGraph;
+ graphResponse?: IGraphResponse;
isLoadingGraph: boolean;
error: boolean;
}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 5cf6268..2396302 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -1,18 +1,37 @@
-export interface IColorSchemeType {
+interface IColorSchemeBase {
// 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;
+ description?: string;
+ type: "qualitative" | "quantitative";
+}
+interface IQualitativeColorScheme extends IColorSchemeBase {
// The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"].
values: string[];
+ type: "qualitative";
+}
+interface IQuantitativeColorScheme extends IColorSchemeBase {
+ type: "quantitative";
+ exponential: boolean;
}
-export const typeColorScheme: IColorSchemeType = {
+export type IColorScheme = IQualitativeColorScheme | IQuantitativeColorScheme;
+
+export const typeColorScheme: IQualitativeColorScheme = {
cytoscapeDataKey: "type",
name: "Instance type",
+ type: "qualitative",
// We could also extract the values from the server response, but this would slow things down...
values: ["mastodon", "gab", "pleroma"]
};
+export const activityColorScheme: IQuantitativeColorScheme = {
+ cytoscapeDataKey: "statusesPerDay",
+ description: "The average number of statuses posted per day.",
+ exponential: true,
+ name: "Activity",
+ type: "quantitative"
+};
-export const colorSchemes: IColorSchemeType[] = [typeColorScheme];
+export const colorSchemes: IColorScheme[] = [typeColorScheme, activityColorScheme];
diff --git a/frontend/src/util.ts b/frontend/src/util.ts
index 0557a24..653bd45 100644
--- a/frontend/src/util.ts
+++ b/frontend/src/util.ts
@@ -1,5 +1,6 @@
import { createMatchSelector } from "connected-react-router";
import fetch from "cross-fetch";
+import { range } from "lodash";
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
import { IAppState } from "./redux/types";
@@ -50,5 +51,20 @@ export const unsetAuthToken = () => {
export const getAuthToken = () => {
return sessionStorage.getItem("adminToken");
- // TODO: check if it's expired, and if so a) delete it and b) return null
+};
+
+export const getBuckets = (min: number, max: number, steps: number, exponential: boolean) => {
+ if (exponential) {
+ const logSpace = range(steps).map(i => Math.E ** i);
+ // Scale the log space to the linear range
+ const logRange = logSpace[logSpace.length - 1] - logSpace[0];
+ const linearRange = max - min;
+ const scalingFactor = linearRange / logRange;
+ const translation = min - logSpace[0];
+ return logSpace.map(i => (i + translation) * scalingFactor);
+ } else {
+ // Linear
+ const bucketSize = Math.ceil((max - min) / steps);
+ return range(min, max, bucketSize);
+ }
};
diff --git a/nlnet-logo.png b/nlnet-logo.png
new file mode 100644
index 0000000..e75ab2e
Binary files /dev/null and b/nlnet-logo.png differ