quantitative color coding

This commit is contained in:
Tao Bojlén 2019-07-27 17:58:40 +00:00
parent 0f01620413
commit 7db145261b
23 changed files with 349 additions and 69 deletions

View File

@ -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

View File

@ -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.

View File

@ -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, []}}
]

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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,

View File

@ -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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -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;

View File

@ -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;

View File

@ -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);
};
/**

View File

@ -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>
);
};

View File

@ -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)

View File

@ -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">

View File

@ -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,

View File

@ -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;

View File

@ -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:

View File

@ -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;
}

View File

@ -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];

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB