Merge branch 'develop' of gitlab.com:taobojlen/fediverse.space into develop

This commit is contained in:
Tao Bror Bojlén 2019-07-31 16:50:18 +03:00
commit b8264eb283
No known key found for this signature in database
GPG Key ID: C6EC7AAB905F9E6F
19 changed files with 313 additions and 51 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. - Added ElasticSearch full-text search over instance domains and descriptions.
- Search results are now highlighted on the graph. - Search results are now highlighted on the graph.
- When you hover a search result, it is 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 ### Changed

View File

@ -74,6 +74,8 @@ config :backend, Backend.Scheduler,
{"15 0 * * *", {Backend.Scheduler, :generate_edges, []}}, {"15 0 * * *", {Backend.Scheduler, :generate_edges, []}},
# 00.30 every night # 00.30 every night
{"30 0 * * *", {Backend.Scheduler, :generate_insularity_scores, []}}, {"30 0 * * *", {Backend.Scheduler, :generate_insularity_scores, []}},
# 00.45 every night
{"45 0 * * *", {Backend.Scheduler, :generate_status_rate, []}},
# Every 3 hours # Every 3 hours
{"0 */3 * * *", {Backend.Scheduler, :check_for_spam_instances, []}} {"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 i.user_count >= ^user_threshold and not i.opt_out
) )
|> maybe_filter_nodes_to_neighborhood(domain) |> 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() |> Repo.all()
end end

View File

@ -10,6 +10,7 @@ defmodule Backend.Instance do
field :version, :string field :version, :string
field :insularity, :float field :insularity, :float
field :type, :string field :type, :string
field :statuses_per_day, :float
field :base_domain, :string field :base_domain, :string
field :opt_in, :boolean field :opt_in, :boolean
field :opt_out, :boolean field :opt_out, :boolean
@ -39,6 +40,7 @@ defmodule Backend.Instance do
:insularity, :insularity,
:updated_at, :updated_at,
:type, :type,
:statuses_per_day,
:base_domain, :base_domain,
:opt_in, :opt_in,
:opt_out :opt_out

View File

@ -76,6 +76,61 @@ defmodule Backend.Scheduler do
) )
end 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 """ @doc """
This function aggregates statistics from the interactions in the database. This function aggregates statistics from the interactions in the database.
It calculates the strength of edges between nodes. Self-edges are not generated. 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 alias BackendWeb.GraphView
def render("index.json", %{nodes: nodes, edges: edges}) do 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), graph: %{
edges: render_many(edges, GraphView, "edge.json", as: :edge) 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 end
@ -22,7 +37,8 @@ defmodule BackendWeb.GraphView do
id: node.domain, id: node.domain,
label: node.domain, label: node.domain,
size: size, size: size,
type: node.type type: node.type,
statusesPerDay: node.statuses_per_day
}, },
position: %{ position: %{
x: node.x, x: node.x,

View File

@ -42,7 +42,8 @@ defmodule BackendWeb.InstanceView do
peers: render_many(filtered_peers, InstanceView, "instance.json"), peers: render_many(filtered_peers, InstanceView, "instance.json"),
lastUpdated: last_updated, lastUpdated: last_updated,
status: status, status: status,
type: instance.type type: instance.type,
statusesPerDay: instance.statuses_per_day
} }
end end
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

View File

@ -4,6 +4,7 @@ import styled from "styled-components";
const FloatingCardRow = styled.div` const FloatingCardRow = styled.div`
display: flex; display: flex;
max-width: 250px;
`; `;
const FloatingCardElement = styled(Card)` const FloatingCardElement = styled(Card)`
margin: 0 0 10px 10px; 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 { IconNames } from "@blueprintjs/icons";
import { ItemRenderer, Select } from "@blueprintjs/select"; import { ItemRenderer, Select } from "@blueprintjs/select";
import * as numeral from "numeral";
import React from "react"; import React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { FloatingCard, InstanceType } from "."; 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` const StyledLi = styled.li`
margin-top: 2px; margin-top: 2px;
@ -14,16 +16,49 @@ const StyledLi = styled.li`
const StyledKeyContainer = styled.div` const StyledKeyContainer = styled.div`
margin-top: 10px; 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 { interface IGraphKeyProps {
current?: IColorSchemeType; current?: IColorScheme;
colorSchemes: IColorSchemeType[]; colorSchemes: IColorScheme[];
onItemSelect: (colorScheme?: IColorSchemeType) => void; 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 = () => { const unsetColorScheme = () => {
onItemSelect(undefined); 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 ( return (
<FloatingCard> <FloatingCard>
<H5>Color coding</H5> <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} /> <Button icon={IconNames.SMALL_CROSS} minimal={true} onClick={unsetColorScheme} disabled={!current} />
</ColorSchemeSelect> </ColorSchemeSelect>
{current && ( <br />
<StyledKeyContainer> {!!current && !!key && (
<H6>Key</H6> <>
<ul className={Classes.LIST_UNSTYLED}> {current.description && <span className={Classes.TEXT_MUTED}>{current.description}</span>}
{current.values.map(v => ( <StyledKeyContainer>{key}</StyledKeyContainer>
<StyledLi key={v}> </>
<InstanceType type={v} />
</StyledLi>
))}
</ul>
</StyledKeyContainer>
)} )}
</FloatingCard> </FloatingCard>
); );
}; };
const renderItem: ItemRenderer<IColorSchemeType> = (colorScheme, { handleClick, modifiers }) => { const renderItem: ItemRenderer<IColorScheme> = (colorScheme, { handleClick, modifiers }) => {
if (!modifiers.matchesPredicate) { if (!modifiers.matchesPredicate) {
return null; return null;
} }
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />; 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; export default GraphKey;

View File

@ -8,10 +8,12 @@ import {
DEFAULT_NODE_COLOR, DEFAULT_NODE_COLOR,
HOVERED_NODE_COLOR, HOVERED_NODE_COLOR,
QUALITATIVE_COLOR_SCHEME, QUALITATIVE_COLOR_SCHEME,
QUANTITATIVE_COLOR_SCHEME,
SEARCH_RESULT_COLOR, SEARCH_RESULT_COLOR,
SELECTED_NODE_COLOR SELECTED_NODE_COLOR
} from "../../constants"; } from "../../constants";
import { IColorSchemeType } from "../../types"; import { IColorScheme } from "../../types";
import { getBuckets } from "../../util";
const CytoscapeContainer = styled.div` const CytoscapeContainer = styled.div`
width: 100%; width: 100%;
@ -20,10 +22,11 @@ const CytoscapeContainer = styled.div`
`; `;
interface ICytoscapeProps { interface ICytoscapeProps {
colorScheme?: IColorSchemeType; colorScheme?: IColorScheme;
currentNodeId: string | null; currentNodeId: string | null;
elements: cytoscape.ElementsDefinition; elements: cytoscape.ElementsDefinition;
hoveringOver?: string; hoveringOver?: string;
ranges?: { [key: string]: [number, number] };
searchResultIds?: string[]; searchResultIds?: string[];
navigateToInstancePath?: (domain: string) => void; navigateToInstancePath?: (domain: string) => void;
navigateToRoot?: () => void; navigateToRoot?: () => void;
@ -251,15 +254,33 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
let style = this.cy.style() as any; let style = this.cy.style() as any;
if (!colorScheme) { if (!colorScheme) {
this.resetNodeColorScheme(); this.resetNodeColorScheme();
} else { return;
} else if (colorScheme.type === "qualitative") {
colorScheme.values.forEach((v, idx) => { colorScheme.values.forEach((v, idx) => {
style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({ style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({
"background-color": QUALITATIVE_COLOR_SCHEME[idx] "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 React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { IColorSchemeType } from "../../types"; import { IColorScheme } from "../../types";
import { GraphKey, GraphResetButton } from "../atoms"; import { GraphKey, GraphResetButton } from "../atoms";
const GraphToolsContainer = styled.div` const GraphToolsContainer = styled.div`
@ -12,21 +12,28 @@ const GraphToolsContainer = styled.div`
`; `;
interface IGraphToolsProps { interface IGraphToolsProps {
currentColorScheme?: IColorSchemeType; currentColorScheme?: IColorScheme;
colorSchemes: IColorSchemeType[]; colorSchemes: IColorScheme[];
onColorSchemeSelect: (colorScheme?: IColorSchemeType) => void; ranges?: { [key: string]: [number, number] };
onColorSchemeSelect: (colorScheme?: IColorScheme) => void;
onResetButtonClick: () => void; onResetButtonClick: () => void;
} }
const GraphTools: React.FC<IGraphToolsProps> = ({ const GraphTools: React.FC<IGraphToolsProps> = ({
currentColorScheme, currentColorScheme,
colorSchemes, colorSchemes,
ranges,
onColorSchemeSelect, onColorSchemeSelect,
onResetButtonClick onResetButtonClick
}) => { }) => {
return ( return (
<GraphToolsContainer> <GraphToolsContainer>
<GraphResetButton onClick={onResetButtonClick} /> <GraphResetButton onClick={onResetButtonClick} />
<GraphKey current={currentColorScheme} colorSchemes={colorSchemes} onItemSelect={onColorSchemeSelect} /> <GraphKey
current={currentColorScheme}
colorSchemes={colorSchemes}
onItemSelect={onColorSchemeSelect}
ranges={ranges}
/>
</GraphToolsContainer> </GraphToolsContainer>
); );
}; };

View File

@ -6,8 +6,8 @@ import { push } from "connected-react-router";
import { Dispatch } from "redux"; 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, IGraphResponse } from "../../redux/types";
import { colorSchemes, IColorSchemeType } from "../../types"; import { colorSchemes, IColorScheme } from "../../types";
import { domainMatchSelector } from "../../util"; import { domainMatchSelector } from "../../util";
import { Cytoscape, ErrorState, GraphTools } from "../molecules/"; import { Cytoscape, ErrorState, GraphTools } from "../molecules/";
@ -18,7 +18,7 @@ const GraphDiv = styled.div`
interface IGraphProps { interface IGraphProps {
currentInstanceName: string | null; currentInstanceName: string | null;
fetchGraph: () => void; fetchGraph: () => void;
graph?: IGraph; graphResponse?: IGraphResponse;
graphLoadError: boolean; graphLoadError: boolean;
hoveringOverResult?: string; hoveringOverResult?: string;
isLoadingGraph: boolean; isLoadingGraph: boolean;
@ -26,7 +26,7 @@ interface IGraphProps {
navigate: (path: string) => void; navigate: (path: string) => void;
} }
interface IGraphState { interface IGraphState {
colorScheme?: IColorSchemeType; colorScheme?: IColorScheme;
} }
class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> { class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
private cytoscapeComponent: React.RefObject<Cytoscape>; private cytoscapeComponent: React.RefObject<Cytoscape>;
@ -45,7 +45,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
let content; let content;
if (this.props.isLoadingGraph) { if (this.props.isLoadingGraph) {
content = <NonIdealState icon={<Spinner />} title="Loading..." />; content = <NonIdealState icon={<Spinner />} title="Loading..." />;
} else if (this.props.graphLoadError || !this.props.graph) { } else if (this.props.graphLoadError || !this.props.graphResponse) {
content = <ErrorState />; content = <ErrorState />;
} else { } else {
content = ( content = (
@ -53,7 +53,8 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
<Cytoscape <Cytoscape
colorScheme={this.state.colorScheme} colorScheme={this.state.colorScheme}
currentNodeId={this.props.currentInstanceName} currentNodeId={this.props.currentInstanceName}
elements={this.props.graph} elements={this.props.graphResponse.graph}
ranges={this.props.graphResponse.metadata.ranges}
hoveringOver={this.props.hoveringOverResult} hoveringOver={this.props.hoveringOverResult}
navigateToInstancePath={this.navigateToInstancePath} navigateToInstancePath={this.navigateToInstancePath}
navigateToRoot={this.navigateToRoot} navigateToRoot={this.navigateToRoot}
@ -65,6 +66,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
currentColorScheme={this.state.colorScheme} currentColorScheme={this.state.colorScheme}
colorSchemes={colorSchemes} colorSchemes={colorSchemes}
onColorSchemeSelect={this.setColorScheme} 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 }); this.setState({ colorScheme });
}; };
@ -101,8 +103,8 @@ const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state); const match = domainMatchSelector(state);
return { return {
currentInstanceName: match && match.params.domain, currentInstanceName: match && match.params.domain,
graph: state.data.graph,
graphLoadError: state.data.error, graphLoadError: state.data.error,
graphResponse: state.data.graphResponse,
hoveringOverResult: state.search.hoveringOverResult, hoveringOverResult: state.search.hoveringOverResult,
isLoadingGraph: state.data.isLoadingGraph, isLoadingGraph: state.data.isLoadingGraph,
searchResultDomains: state.search.results.map(r => r.name) searchResultDomains: state.search.results.map(r => r.name)

View File

@ -259,7 +259,16 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
if (!this.props.instanceDetails) { if (!this.props.instanceDetails) {
throw new Error("Did not receive instance details as expected!"); 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 ( return (
<StyledHTMLTable small={true} striped={true}> <StyledHTMLTable small={true} striped={true}>
<tbody> <tbody>
@ -298,6 +307,25 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</td> </td>
<td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td> <td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td>
</tr> </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> <tr>
<td>Known peers</td> <td>Known peers</td>
<td>{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}</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 mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state); const match = domainMatchSelector(state);
return { return {
graph: state.data.graph, graph: state.data.graphResponse && state.data.graphResponse.graph,
instanceDetails: state.currentInstance.currentInstanceDetails, instanceDetails: state.currentInstance.currentInstanceDetails,
instanceLoadError: state.currentInstance.error, instanceLoadError: state.currentInstance.error,
instanceName: match && match.params.domain, instanceName: match && match.params.domain,

View File

@ -20,6 +20,20 @@ export const QUALITATIVE_COLOR_SCHEME = [
"#AD99FF" "#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 const INSTANCE_DOMAIN_PATH = "/instance/:domain";
export interface IInstanceDomainPath { export interface IInstanceDomainPath {
domain: string; domain: string;

View File

@ -8,18 +8,18 @@ const initialDataState = {
error: false, error: false,
isLoadingGraph: false isLoadingGraph: false
}; };
const data = (state: IDataState = initialDataState, action: IAction) => { const data = (state: IDataState = initialDataState, action: IAction): IDataState => {
switch (action.type) { switch (action.type) {
case ActionType.REQUEST_GRAPH: case ActionType.REQUEST_GRAPH:
return { return {
...state, ...state,
graph: undefined, graphResponse: undefined,
isLoadingGraph: true isLoadingGraph: true
}; };
case ActionType.RECEIVE_GRAPH: case ActionType.RECEIVE_GRAPH:
return { return {
...state, ...state,
graph: action.payload, graphResponse: action.payload,
isLoadingGraph: false isLoadingGraph: false
}; };
case ActionType.GRAPH_LOAD_ERROR: case ActionType.GRAPH_LOAD_ERROR:

View File

@ -48,6 +48,7 @@ export interface IInstanceDetails {
lastUpdated?: string; lastUpdated?: string;
status: string; status: string;
type?: string; type?: string;
statusesPerDay?: number;
} }
interface IGraphNode { interface IGraphNode {
@ -71,11 +72,20 @@ interface IGraphEdge {
}; };
} }
interface IGraphMetadata {
ranges: { [key: string]: [number, number] };
}
export interface IGraph { export interface IGraph {
nodes: IGraphNode[]; nodes: IGraphNode[];
edges: IGraphEdge[]; edges: IGraphEdge[];
} }
export interface IGraphResponse {
graph: IGraph;
metadata: IGraphMetadata;
}
export interface ISearchResponse { export interface ISearchResponse {
results: ISearchResultInstance[]; results: ISearchResultInstance[];
next: string | null; next: string | null;
@ -91,7 +101,7 @@ export interface ICurrentInstanceState {
} }
export interface IDataState { export interface IDataState {
graph?: IGraph; graphResponse?: IGraphResponse;
isLoadingGraph: boolean; isLoadingGraph: boolean;
error: boolean; error: boolean;
} }

View File

@ -1,18 +1,37 @@
export interface IColorSchemeType { interface IColorSchemeBase {
// The name of the coloring, e.g. "Instance type" // The name of the coloring, e.g. "Instance type"
name: string; name: string;
// The name of the key in a cytoscape node's `data` field to color by. // 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. // For example, use cytoscapeDataKey: "type" to color according to type.
cytoscapeDataKey: string; cytoscapeDataKey: string;
description?: string;
type: "qualitative" | "quantitative";
}
interface IQualitativeColorScheme extends IColorSchemeBase {
// The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"]. // The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"].
values: string[]; 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", cytoscapeDataKey: "type",
name: "Instance type", name: "Instance type",
type: "qualitative",
// We could also extract the values from the server response, but this would slow things down... // We could also extract the values from the server response, but this would slow things down...
values: ["mastodon", "gab", "pleroma"] 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 { createMatchSelector } from "connected-react-router";
import fetch from "cross-fetch"; import fetch from "cross-fetch";
import { range } from "lodash";
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants"; import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
import { IAppState } from "./redux/types"; import { IAppState } from "./redux/types";
@ -50,5 +51,20 @@ export const unsetAuthToken = () => {
export const getAuthToken = () => { export const getAuthToken = () => {
return sessionStorage.getItem("adminToken"); 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);
}
}; };