quantitative color coding
This commit is contained in:
parent
0f01620413
commit
7db145261b
|
@ -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
|
||||
|
||||
|
|
25
README.md
25
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@<DOMAIN>: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.
|
||||
|
|
|
@ -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, []}}
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
BIN
frontend/src/assets/nlnet.png
Normal file
BIN
frontend/src/assets/nlnet.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.8 KiB |
|
@ -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;
|
||||
|
|
|
@ -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<IColorSchemeType>();
|
||||
const ColorSchemeSelect = Select.ofType<IColorScheme>();
|
||||
|
||||
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<IColorBarProps>`
|
||||
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<IGraphKeyProps> = ({ current, colorSchemes, onItemSelect }) => {
|
||||
const GraphKey: React.FC<IGraphKeyProps> = ({ 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 (
|
||||
<FloatingCard>
|
||||
<H5>Color coding</H5>
|
||||
|
@ -42,27 +77,49 @@ const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, onItemSelec
|
|||
/>
|
||||
<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 => (
|
||||
<StyledLi key={v}>
|
||||
<InstanceType type={v} />
|
||||
</StyledLi>
|
||||
))}
|
||||
</ul>
|
||||
</StyledKeyContainer>
|
||||
<br />
|
||||
{!!current && !!key && (
|
||||
<>
|
||||
{current.description && <span className={Classes.TEXT_MUTED}>{current.description}</span>}
|
||||
<StyledKeyContainer>{key}</StyledKeyContainer>
|
||||
</>
|
||||
)}
|
||||
</FloatingCard>
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem: ItemRenderer<IColorSchemeType> = (colorScheme, { handleClick, modifiers }) => {
|
||||
const renderItem: ItemRenderer<IColorScheme> = (colorScheme, { handleClick, modifiers }) => {
|
||||
if (!modifiers.matchesPredicate) {
|
||||
return null;
|
||||
}
|
||||
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
|
||||
};
|
||||
|
||||
const renderQualitativeKey = (values: string[]) => (
|
||||
<ul className={Classes.LIST_UNSTYLED}>
|
||||
{values.map(v => (
|
||||
<StyledLi key={v}>
|
||||
<InstanceType type={v} />
|
||||
</StyledLi>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
const renderQuantitativeKey = (range: number[]) => {
|
||||
const [min, max] = range;
|
||||
return (
|
||||
<ColorKeyContainer>
|
||||
<ColorBarContainer>
|
||||
{QUANTITATIVE_COLOR_SCHEME.map((color, idx) => (
|
||||
<ColorBar color={color} />
|
||||
))}
|
||||
</ColorBarContainer>
|
||||
<TextContainer>
|
||||
<span className={Classes.TEXT_SMALL}>{numeral.default(min).format("0")}</span>
|
||||
<span className={Classes.TEXT_SMALL}>{numeral.default(max).format("0")}</span>
|
||||
</TextContainer>
|
||||
</ColorKeyContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default GraphKey;
|
||||
|
|
|
@ -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<ICytoscapeProps> {
|
|||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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<IGraphToolsProps> = ({
|
||||
currentColorScheme,
|
||||
colorSchemes,
|
||||
ranges,
|
||||
onColorSchemeSelect,
|
||||
onResetButtonClick
|
||||
}) => {
|
||||
return (
|
||||
<GraphToolsContainer>
|
||||
<GraphResetButton onClick={onResetButtonClick} />
|
||||
<GraphKey current={currentColorScheme} colorSchemes={colorSchemes} onItemSelect={onColorSchemeSelect} />
|
||||
<GraphKey
|
||||
current={currentColorScheme}
|
||||
colorSchemes={colorSchemes}
|
||||
onItemSelect={onColorSchemeSelect}
|
||||
ranges={ranges}
|
||||
/>
|
||||
</GraphToolsContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<IGraphProps, IGraphState> {
|
||||
private cytoscapeComponent: React.RefObject<Cytoscape>;
|
||||
|
@ -45,7 +45,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
|||
let content;
|
||||
if (this.props.isLoadingGraph) {
|
||||
content = <NonIdealState icon={<Spinner />} title="Loading..." />;
|
||||
} else if (this.props.graphLoadError || !this.props.graph) {
|
||||
} else if (this.props.graphLoadError || !this.props.graphResponse) {
|
||||
content = <ErrorState />;
|
||||
} else {
|
||||
content = (
|
||||
|
@ -53,7 +53,8 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
|||
<Cytoscape
|
||||
colorScheme={this.state.colorScheme}
|
||||
currentNodeId={this.props.currentInstanceName}
|
||||
elements={this.props.graph}
|
||||
elements={this.props.graphResponse.graph}
|
||||
ranges={this.props.graphResponse.metadata.ranges}
|
||||
hoveringOver={this.props.hoveringOverResult}
|
||||
navigateToInstancePath={this.navigateToInstancePath}
|
||||
navigateToRoot={this.navigateToRoot}
|
||||
|
@ -65,6 +66,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
|||
currentColorScheme={this.state.colorScheme}
|
||||
colorSchemes={colorSchemes}
|
||||
onColorSchemeSelect={this.setColorScheme}
|
||||
ranges={this.props.graphResponse.metadata.ranges}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
@ -85,7 +87,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
|||
}
|
||||
};
|
||||
|
||||
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)
|
||||
|
|
|
@ -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 = () => (
|
|||
</p>
|
||||
|
||||
<H4>How do I add my personal instance?</H4>
|
||||
<p className={Classes.RUNNING_TEXT}>
|
||||
Send a DM to{" "}
|
||||
<a href="https://cursed.technology/@fediversespace" target="_blank" rel="noopener noreferrer">
|
||||
@fediversespace
|
||||
</a>{" "}
|
||||
on Mastodon. Make sure to send it from the account that's listed as the instance admin.
|
||||
</p>
|
||||
<p className={Classes.RUNNING_TEXT}>Click on the Administration link in the top right to opt-in.</p>
|
||||
|
||||
<H4>How do you calculate the strength of relationships between instances?</H4>
|
||||
<p className={Classes.RUNNING_TEXT}>
|
||||
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
|
||||
<Code>mentions of an instance / total statuses</Code>. It uses a ratio rather than an absolute number of mentions
|
||||
to reflect that smaller instances can play a large role in a community.
|
||||
</p>
|
||||
|
||||
<H2>Credits</H2>
|
||||
<p className={Classes.RUNNING_TEXT}>This site is inspired by several other sites in the same vein:</p>
|
||||
|
||||
<a href="https://nlnet.nl/project/fediverse_space/" target="_blank" rel="noopener noreferrer">
|
||||
<img src={nlnetLogo} alt="NLnet logo" width={160} height={60} />
|
||||
</a>
|
||||
<br />
|
||||
<br />
|
||||
<p className={Classes.RUNNING_TEXT}>
|
||||
This project is proudly supported by{" "}
|
||||
<a href="https://nlnet.nl/project/fediverse_space/" target="_blank" rel="noopener noreferrer">
|
||||
NLnet
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p className={Classes.RUNNING_TEXT}>Inspiration for this site comes from several places:</p>
|
||||
<ul className={Classes.LIST}>
|
||||
<li>
|
||||
<a href="https://the-federation.info/" target="_blank" rel="noopener noreferrer">
|
||||
|
|
|
@ -259,7 +259,16 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
|||
if (!this.props.instanceDetails) {
|
||||
throw new Error("Did not receive instance details as expected!");
|
||||
}
|
||||
const { version, userCount, statusCount, domainCount, lastUpdated, insularity, type } = this.props.instanceDetails;
|
||||
const {
|
||||
version,
|
||||
userCount,
|
||||
statusCount,
|
||||
domainCount,
|
||||
lastUpdated,
|
||||
insularity,
|
||||
type,
|
||||
statusesPerDay
|
||||
} = this.props.instanceDetails;
|
||||
return (
|
||||
<StyledHTMLTable small={true} striped={true}>
|
||||
<tbody>
|
||||
|
@ -298,6 +307,25 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
|||
</td>
|
||||
<td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Statuses / day{" "}
|
||||
<Tooltip
|
||||
content={
|
||||
<span>
|
||||
The average number of statuses per day
|
||||
<br />
|
||||
over the last month.
|
||||
</span>
|
||||
}
|
||||
position={Position.TOP}
|
||||
className={Classes.DARK}
|
||||
>
|
||||
<Icon icon={IconNames.HELP} iconSize={Icon.SIZE_STANDARD} />
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td>{(statusesPerDay && numeral.default(statusesPerDay).format("0.0")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Known peers</td>
|
||||
<td>{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}</td>
|
||||
|
@ -430,7 +458,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
|||
const mapStateToProps = (state: IAppState) => {
|
||||
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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
BIN
nlnet-logo.png
Normal file
BIN
nlnet-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.3 KiB |
Loading…
Reference in a new issue