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.
|
- 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
|
||||||
|
|
||||||
|
|
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).
|
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
|
## 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.
|
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.
|
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).
|
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
|
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
|
4. Create the backing database
|
||||||
* `dokku postgres:create fediversedb`
|
|
||||||
* `dokku postgres:link fediversedb phoenix`
|
- `dokku postgres:create fediversedb`
|
||||||
* `dokku postgres:link fediversedb gephi`
|
- `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.
|
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).
|
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
|
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.
|
8. Set up a cron job for the graph layout (use the `dokku` user). E.g.
|
||||||
|
|
||||||
```
|
```
|
||||||
SHELL=/bin/bash
|
SHELL=/bin/bash
|
||||||
0 2 * * * /usr/bin/dokku run gephi java -Xmx1g -jar build/libs/graphBuilder.jar
|
0 2 * * * /usr/bin/dokku run gephi java -Xmx1g -jar build/libs/graphBuilder.jar
|
||||||
|
@ -82,6 +91,6 @@ SHELL=/bin/bash
|
||||||
|
|
||||||
## Acknowledgements
|
## 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.
|
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, []}},
|
{"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, []}}
|
||||||
]
|
]
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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`
|
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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Classes, Code, H1, H2, H4 } from "@blueprintjs/core";
|
import { Classes, Code, H1, H2, H4 } from "@blueprintjs/core";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import nlnetLogo from "../../assets/nlnet.png";
|
||||||
import { Page } from "../atoms/";
|
import { Page } from "../atoms/";
|
||||||
|
|
||||||
const AboutScreen: React.FC = () => (
|
const AboutScreen: React.FC = () => (
|
||||||
|
@ -33,24 +34,32 @@ const AboutScreen: React.FC = () => (
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H4>How do I add my personal instance?</H4>
|
<H4>How do I add my personal instance?</H4>
|
||||||
<p className={Classes.RUNNING_TEXT}>
|
<p className={Classes.RUNNING_TEXT}>Click on the Administration link in the top right to opt-in.</p>
|
||||||
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>
|
|
||||||
|
|
||||||
<H4>How do you calculate the strength of relationships between instances?</H4>
|
<H4>How do you calculate the strength of relationships between instances?</H4>
|
||||||
<p className={Classes.RUNNING_TEXT}>
|
<p className={Classes.RUNNING_TEXT}>
|
||||||
fediverse.space scrapes the last 5000 statuses from within the last month on the public timeline of each instance.
|
fediverse.space looks at statuses from within the last month on the public timeline of each instance. It
|
||||||
It looks at the ratio of
|
calculates at the ratio of
|
||||||
<Code>mentions of an instance / total statuses</Code>. It uses a ratio rather than an absolute number of mentions
|
<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.
|
to reflect that smaller instances can play a large role in a community.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>Credits</H2>
|
<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}>
|
<ul className={Classes.LIST}>
|
||||||
<li>
|
<li>
|
||||||
<a href="https://the-federation.info/" target="_blank" rel="noopener noreferrer">
|
<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) {
|
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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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];
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
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