diff --git a/backend/lib/archive/archivist.ex b/backend/lib/archive/archivist.ex
new file mode 100644
index 0000000..2fa5ea5
--- /dev/null
+++ b/backend/lib/archive/archivist.ex
@@ -0,0 +1,53 @@
+defmodule Tilastokeskus.Archive.Archivist do
+ @moduledoc """
+ The archivist is responsible for updating clients about updates to the archive. When new data
+ is inserted, the archivist should be notified. It will then notify every client waiting for
+ new information.
+ """
+
+ alias Tilastokeskus.Archive.Schemas.PageView
+
+ @doc """
+ Get the name to use for the registry process.
+ """
+ @spec registry_name() :: atom
+ def registry_name(), do: __MODULE__
+
+ @doc """
+ Get name to use for registry key when listening to updates on the given host.
+ """
+ @spec listen_key(String.t()) :: {atom, String.t()}
+ def listen_key(host) when is_binary(host), do: {:archivist_listener, host}
+
+ @doc """
+ Return child tuple to add to supervisor to start archivist. If name is not given,
+ `registry_name/0` is used.
+ """
+ @spec child_tuple(atom) :: {module, list}
+ def child_tuple(name \\ nil) do
+ {Registry,
+ [
+ name: if(not is_nil(name), do: name, else: registry_name()),
+ keys: :duplicate,
+ partitions: System.schedulers_online()
+ ]}
+ end
+
+ @doc """
+ Register to listen for updates to a given host.
+ """
+ @spec register(String.t()) :: {:ok, pid} | {:error, any}
+ def register(host) when is_binary(host) do
+ Registry.register(registry_name(), listen_key(host), [])
+ end
+
+ @doc """
+ Send a page view update to any applicable listeners.
+ """
+ @spec update(PageView.t()) :: :ok
+ def update(%PageView{} = view) do
+ Registry.dispatch(registry_name(), listen_key(view.host), fn entries ->
+ for {pid, _} <- entries, do: send(pid, {:archivist_update, view})
+ end)
+ end
+end
diff --git a/backend/lib/archive/schemas/page_view.ex b/backend/lib/archive/schemas/page_view.ex
index f4428d4..e0d70e1 100644
--- a/backend/lib/archive/schemas/page_view.ex
+++ b/backend/lib/archive/schemas/page_view.ex
@@ -45,6 +45,8 @@ defmodule Tilastokeskus.Archive.Schemas.PageView do
# GeoIP calculated information
field(:loc_city, :string)
field(:loc_country, :string)
+ field(:loc_lat, :float)
+ field(:loc_lon, :float)
# Was the hit from a bot
field(:is_bot, :boolean)
@@ -77,6 +79,8 @@ defmodule Tilastokeskus.Archive.Schemas.PageView do
:tz_offset,
:loc_city,
:loc_country,
+ :loc_lat,
+ :loc_lon,
:is_bot
])
|> put_change(:type, event_type())
diff --git a/backend/lib/archive/scrubinator.ex b/backend/lib/archive/scrubinator.ex
index 5d427db..4c549c9 100644
--- a/backend/lib/archive/scrubinator.ex
+++ b/backend/lib/archive/scrubinator.ex
@@ -47,7 +47,9 @@ defmodule Tilastokeskus.Archive.Scrubinator do
scrubbed: true,
addr: nil,
ua: nil,
- loc_city: nil
+ loc_city: nil,
+ loc_lat: 0.0,
+ loc_lon: 0.0
]
]
)
diff --git a/backend/lib/archive/utils/page_view.ex b/backend/lib/archive/utils/page_view.ex
index 7357fe2..228cf8c 100644
--- a/backend/lib/archive/utils/page_view.ex
+++ b/backend/lib/archive/utils/page_view.ex
@@ -3,14 +3,37 @@ defmodule Tilastokeskus.Archive.Utils.PageView do
Pageview related utilities.
"""
+ import Ecto.Query, only: [from: 2]
+
alias Tilastokeskus.Archive.Repo
alias Tilastokeskus.Archive.Schemas.PageView
@doc """
Create new pageview from changeset.
"""
- @spec create(Ecto.Changeset.t()) :: {:ok, %PageView{}} | {:error, term}
+ @spec create(Ecto.Changeset.t()) :: {:ok, PageView.t()} | {:error, term}
def create(changeset) do
- Repo.insert(changeset)
+ case Repo.insert(changeset) do
+ {:ok, %PageView{} = view} ->
+ Tilastokeskus.Archive.Archivist.update(view)
+ {:ok, view}
+
+ e ->
+ e
+ end
+ end
+
+ @doc """
+ Get page views for the last `mins` minutes for the given host.
+ """
+ @spec get_last(String.t(), integer) :: [PageView.t()]
+ def get_last(host, mins) do
+ now = DateTime.utc_now()
+ then = DateTime.add(now, -(mins * 60))
+
+ from(pw in PageView,
+ where: pw.host == ^host and pw.at >= ^then
+ )
+ |> Repo.all()
end
end
diff --git a/backend/lib/reception/router.ex b/backend/lib/reception/router.ex
index 853c849..42717bb 100644
--- a/backend/lib/reception/router.ex
+++ b/backend/lib/reception/router.ex
@@ -3,6 +3,7 @@ defmodule Tilastokeskus.Reception.Router do
section([{Raxx.Logger, level: :debug}], [
{%{method: :POST, path: ["track"]}, Tilastokeskus.Reception.Routes.PageView},
+ {%{method: :GET, path: ["live", _host]}, Tilastokeskus.Reception.Routes.LiveView},
{_, Tilastokeskus.Reception.Routes.NotFound}
])
end
diff --git a/backend/lib/reception/routes/live_view.ex b/backend/lib/reception/routes/live_view.ex
new file mode 100644
index 0000000..c1bc58d
--- /dev/null
+++ b/backend/lib/reception/routes/live_view.ex
@@ -0,0 +1,96 @@
+defmodule Tilastokeskus.Reception.Routes.LiveView do
+ use Raxx.Server
+
+ alias Tilastokeskus.Archive.Schemas.PageView
+
+ @heartbeat_delay 30_000
+ @initial_data_mins 5
+
+ @typep req_state :: nil
+ @typep resp :: {[Raxx.Response.t()], req_state}
+
+ @impl Raxx.Server
+ @spec handle_head(Raxx.Request.t(), any) :: resp
+ def handle_head(%{method: :GET, path: ["live", host]}, _state) do
+ resp =
+ response(200)
+ |> set_header("content-type", "text/event-stream")
+ |> set_header("access-control-allow-origin", "*")
+ |> set_body(true)
+
+ # Register to listen for live updates
+ Tilastokeskus.Archive.Archivist.register(host)
+
+ # Start heartbeat ping
+ start_heartbeat_timer()
+
+ # Send initial data ASAP
+ Process.send_after(self(), {:init, host}, 0)
+
+ {[resp], nil}
+ end
+
+ @spec handle_info(any, req_state) :: resp
+ def handle_info(msg, state)
+
+ def handle_info({:archivist_update, %PageView{} = view}, state) do
+ {[view |> transform_view() |> data()], state}
+ end
+
+ def handle_info(:heartbeat, state) do
+ # Hearbeat, why do you miss when my baby kisses me?
+ start_heartbeat_timer()
+ {[data(":ping\n\n")], state}
+ end
+
+ def handle_info({:init, host}, state) do
+ # Get last minutes' data immediately
+ immediate_data =
+ Tilastokeskus.Archive.Utils.PageView.get_last(host, @initial_data_mins)
+ |> Enum.map(&transform_view/1)
+ |> Enum.join("")
+
+ immediate_data = if immediate_data == "", do: ":nil", else: immediate_data
+
+ {[data(immediate_data)], state}
+ end
+
+ def handle_info(_, state), do: {[], state}
+
+ # Heartbeat, why does a love kiss stay in my memory?
+ defp start_heartbeat_timer(), do: Process.send_after(self(), :heartbeat, @heartbeat_delay)
+
+ # Transform view to SSE event
+ defp transform_view(view) do
+ content =
+ Jason.encode!(%{
+ at: view.at,
+ addr: view.addr |> :inet_parse.ntoa() |> to_string(),
+ extra: view.extra,
+ scrubbed: view.scrubbed,
+ session: view.session_id,
+ path: view.path,
+ path_noq: view.path_noq,
+ host: view.host,
+ referrer: view.referrer,
+ referrer_domain: view.referrer_domain,
+ ua: view.ua,
+ ua_name: view.ua_name,
+ ua_version: view.ua_version,
+ os_name: view.os_name,
+ os_version: view.os_version,
+ device_type: view.device_type,
+ screen_w: view.screen_w,
+ screen_h: view.screen_h,
+ tz_offset: view.tz_offset,
+ loc_city: view.loc_city,
+ loc_country: view.loc_country,
+ loc_lat: view.loc_lat,
+ loc_lon: view.loc_lon,
+ is_bot: view.is_bot
+ })
+
+ content_data_rows = String.split(content, "\n") |> Enum.join("\ndata: ")
+ "event: view\ndata: #{content_data_rows}\n\n"
+ end
+end
diff --git a/backend/lib/reception/routes/page_view.ex b/backend/lib/reception/routes/page_view.ex
index 91cfa89..933e32d 100644
--- a/backend/lib/reception/routes/page_view.ex
+++ b/backend/lib/reception/routes/page_view.ex
@@ -23,7 +23,10 @@ defmodule Tilastokeskus.Reception.Routes.PageView do
screen_h = Map.get(body, "screen_height")
tz_offset = Map.get(body, "tz_offset")
- {city, country} = get_geoip(addr)
+ {city, {lat, lon}, country} = get_geoip(addr)
+
+ lat = if not is_nil(lat), do: lat, else: 0.0
+ lon = if not is_nil(lon), do: lon, else: 0.0
# Run in one transaction to avoid multiple DB checkouts
{:ok, response} =
@@ -54,6 +57,8 @@ defmodule Tilastokeskus.Reception.Routes.PageView do
tz_offset: tz_offset,
loc_city: city,
loc_country: country,
+ loc_lat: lat,
+ loc_lon: lon,
is_bot: is_bot
}
)
@@ -186,6 +191,7 @@ defmodule Tilastokeskus.Reception.Routes.PageView do
{
get_in(city, [:city, :names, :en]),
+ {get_in(city, [:location, :latitude]), get_in(city, [:location, :longitude])},
get_in(country, [:country, :names, :en])
}
end
diff --git a/backend/lib/tilastokeskus/application.ex b/backend/lib/tilastokeskus/application.ex
index f2e85de..4dec3df 100644
--- a/backend/lib/tilastokeskus/application.ex
+++ b/backend/lib/tilastokeskus/application.ex
@@ -14,6 +14,8 @@ defmodule Tilastokeskus.Application do
# List all child processes to be supervised
children = [
+ # PubSub system for live updates using registry
+ Tilastokeskus.Archive.Archivist.child_tuple(),
{Tilastokeskus.Archive.Repo, []},
{Tilastokeskus.Archive.Scrubinator, %{days: days}},
{Tilastokeskus.Reception.FrontDesk, [hosts: hosts, port: port, cleartext: true]}
diff --git a/backend/priv/repo/migrations/20191102205925_add_lat_lon.exs b/backend/priv/repo/migrations/20191102205925_add_lat_lon.exs
new file mode 100644
index 0000000..65ad5f5
--- /dev/null
+++ b/backend/priv/repo/migrations/20191102205925_add_lat_lon.exs
@@ -0,0 +1,10 @@
+defmodule Tilastokeskus.Archive.Repo.Migrations.AddLatLon do
+ use Ecto.Migration
+
+ def change do
+ alter table(:events) do
+ add(:loc_lat, :"double precision", null: false, default: 0.0)
+ add(:loc_lon, :"double precision", null: false, default: 0.0)
+ end
+ end
+end
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..dd87e2d
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+build
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..3c31122
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ Tilastokeskus
+
+
+
+
+
+ Tilastokeskus
+
+
+ ?
+ users currently live
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/archive-client.ts b/frontend/src/archive-client.ts
new file mode 100644
index 0000000..6622c59
--- /dev/null
+++ b/frontend/src/archive-client.ts
@@ -0,0 +1,37 @@
+const ARCHIVE_BASE_URL = "http://localhost:1971/live";
+
+const PAGE_VIEW_EVENT = "view";
+
+/**
+ * Get the URL path to use for updates about the given host.
+ */
+function hostUpdateUrl(host: string): string {
+ return `${ARCHIVE_BASE_URL}/${host}`;
+}
+
+/**
+ * Start listening for updates to the given host.
+ */
+export function connectListener(
+ host: string,
+ openCallback: (stream: EventSource, evt: Event) => void,
+ msgCallback: (stream: EventSource, evt: MessageEvent) => void,
+ errorCallback: (stream: EventSource, evt: ErrorEvent) => void,
+): EventSource {
+ const url = hostUpdateUrl(host);
+ const source = new EventSource(url);
+
+ source.addEventListener("open", (e: Event) => {
+ openCallback(source, e);
+ });
+
+ source.addEventListener("view", (e: Event): void => {
+ msgCallback(source, e as MessageEvent);
+ });
+
+ source.addEventListener("error", (e: Event) => {
+ errorCallback(source, e as ErrorEvent);
+ });
+
+ return source;
+}
diff --git a/frontend/src/component.ts b/frontend/src/component.ts
new file mode 100644
index 0000000..24dbdfd
--- /dev/null
+++ b/frontend/src/component.ts
@@ -0,0 +1,14 @@
+import { IPageView } from "./page-view.js";
+
+/** Component that controls an element, rendering output to it. */
+export abstract class Component {
+ protected el: HTMLElement;
+
+ constructor(el: HTMLElement) {
+ this.el = el;
+ }
+
+ public abstract handleView(view: IPageView): void;
+
+ public abstract render(): void;
+}
diff --git a/frontend/src/config.ts b/frontend/src/config.ts
new file mode 100644
index 0000000..a929e3a
--- /dev/null
+++ b/frontend/src/config.ts
@@ -0,0 +1,2 @@
+/** How many seconds a page view is considered "current". */
+export const CURRENT_THRESHOLD = 300;
diff --git a/frontend/src/current-sessions.ts b/frontend/src/current-sessions.ts
new file mode 100644
index 0000000..020738a
--- /dev/null
+++ b/frontend/src/current-sessions.ts
@@ -0,0 +1,41 @@
+import { IPageView } from "./page-view.js";
+import { Table } from "./table.js";
+
+const MAX_VIEWS = 20;
+
+export class CurrentSessions extends Table {
+ private viewData: IPageView[];
+
+ constructor(el: HTMLElement) {
+ super(el, ["Time", "Session", "Path", "Referrer"]);
+
+ this.viewData = [];
+ }
+
+ public handleView(view: IPageView) {
+ this.viewData.push(view);
+ this.render();
+ }
+
+ public render() {
+ this.viewData.sort((a, b) => {
+ return (b.at.getTime() - a.at.getTime());
+ });
+ this.viewData = this.viewData.slice(0, MAX_VIEWS - 1);
+
+ super.setData(this.viewData.map((v) => {
+ let ref: string | HTMLAnchorElement = v.referrer;
+
+ if (ref != null) {
+ ref = document.createElement("a");
+ ref.href = v.referrer;
+ ref.innerText = v.referrer;
+ ref.target = "_blank";
+ }
+
+ return [v.at.toISOString(), v.session, v.path_noq, ref];
+ }));
+
+ super.render();
+ }
+}
diff --git a/frontend/src/index.ts b/frontend/src/index.ts
new file mode 100644
index 0000000..ff4cbd9
--- /dev/null
+++ b/frontend/src/index.ts
@@ -0,0 +1,51 @@
+import { connectListener } from "./archive-client.js";
+import { CurrentSessions } from "./current-sessions.js";
+import { LiveCounter } from "./live-counter.js";
+import { IRemotePageView, parseView } from "./page-view.js";
+import { TopPaths } from "./top-paths.js";
+import { WorldMap } from "./world-map.js";
+
+let worldMap: WorldMap;
+let currentyLiveCounter: LiveCounter;
+let currentSessions: CurrentSessions;
+let topPaths: TopPaths;
+let eventSource: EventSource;
+
+function openCallback(stream: EventSource, e: Event) {
+ console.log("Opened event stream.");
+}
+
+function errorCallback(stream: EventSource, e: ErrorEvent) {
+ console.log(e);
+}
+
+function msgCallback(stream: EventSource, e: MessageEvent) {
+ const data = JSON.parse(e.data) as IRemotePageView;
+ console.log("Received data", data);
+
+ const parsedData = parseView(data);
+
+ currentyLiveCounter.handleView(parsedData);
+ worldMap.handleView(parsedData);
+ currentSessions.handleView(parsedData);
+ topPaths.handleView(parsedData);
+}
+
+function main() {
+ currentyLiveCounter = new LiveCounter(document.getElementById("currently-live-counter")!);
+ worldMap = new WorldMap(document.getElementById("world-map")!);
+ currentSessions = new CurrentSessions(document.getElementById("current-sessions-data")!);
+ topPaths = new TopPaths(document.getElementById("top-paths-data")!);
+
+ eventSource = connectListener("localhost:1971", openCallback, msgCallback, errorCallback);
+}
+
+if (document.readyState === "interactive") {
+ main();
+} else {
+ document.addEventListener("readystatechange", () => {
+ if (document.readyState === "interactive") {
+ main();
+ }
+ });
+}
diff --git a/frontend/src/live-counter.ts b/frontend/src/live-counter.ts
new file mode 100644
index 0000000..db8501a
--- /dev/null
+++ b/frontend/src/live-counter.ts
@@ -0,0 +1,49 @@
+import { Component } from "./component.js";
+import { CURRENT_THRESHOLD } from "./config.js";
+import { IPageView } from "./page-view.js";
+
+const UPDATE_TIMER = 10;
+
+/**
+ * LiveCounter shows the amount of users on the site in the last 5 minutes.
+ */
+export class LiveCounter extends Component {
+ private sessions: Map;
+
+ constructor(el: HTMLElement) {
+ super(el);
+
+ this.sessions = new Map();
+
+ setInterval(() => this.removeOld());
+ }
+
+ public handleView(view: IPageView) {
+ let isLatest = true;
+
+ if (this.sessions.has(view.session) && this.sessions.get(view.session)! >= view.at) {
+ isLatest = false;
+ }
+
+ if (isLatest) {
+ this.sessions.set(view.session, view.at);
+ }
+
+ this.render();
+ }
+
+ public render() {
+ this.el.innerText = this.sessions.size.toString();
+ }
+
+ private removeOld() {
+ const now = Date.now();
+ for (const [session, at] of this.sessions.entries()) {
+ if (now - at.getTime() > CURRENT_THRESHOLD * 1000) {
+ this.sessions.delete(session);
+ }
+ }
+
+ this.render();
+ }
+}
diff --git a/frontend/src/page-view.ts b/frontend/src/page-view.ts
new file mode 100644
index 0000000..33713f9
--- /dev/null
+++ b/frontend/src/page-view.ts
@@ -0,0 +1,65 @@
+export interface IBasePageView {
+ addr: string;
+ extra: object;
+ scrubbed: boolean;
+ session: string;
+ path: string;
+ path_noq: string;
+ host: string;
+ referrer: string;
+ referrer_domain: string;
+ ua: string;
+ ua_name: string;
+ ua_version: string;
+ os_name: string;
+ os_version: string;
+ device_type: string;
+ screen_w: number;
+ screen_h: number;
+ tz_offset: number;
+ loc_city: string;
+ loc_country: string;
+ loc_lat: number;
+ loc_lon: number;
+ is_bot: boolean;
+}
+
+export interface IRemotePageView extends IBasePageView {
+ at: string;
+}
+
+export interface IPageView extends IBasePageView {
+ at: Date;
+}
+
+/**
+ * Parse a page view coming from the backend.
+ */
+export function parseView(view: IRemotePageView): IPageView {
+ return {
+ at: new Date(view.at),
+ addr: view.addr,
+ extra: view.extra,
+ scrubbed: view.scrubbed,
+ session: view.session,
+ path: view.path,
+ path_noq: view.path_noq,
+ host: view.host,
+ referrer: view.referrer,
+ referrer_domain: view.referrer_domain,
+ ua: view.ua,
+ ua_name: view.ua_name,
+ ua_version: view.ua_version,
+ os_name: view.os_name,
+ os_version: view.os_version,
+ device_type: view.device_type,
+ screen_w: view.screen_w,
+ screen_h: view.screen_h,
+ tz_offset: view.tz_offset,
+ loc_city: view.loc_city,
+ loc_country: view.loc_country,
+ loc_lat: view.loc_lat,
+ loc_lon: view.loc_lon,
+ is_bot: view.is_bot,
+ };
+}
diff --git a/frontend/src/table.ts b/frontend/src/table.ts
new file mode 100644
index 0000000..20f8bbe
--- /dev/null
+++ b/frontend/src/table.ts
@@ -0,0 +1,61 @@
+import { Component } from "./component.js";
+
+type CellData = string | number | HTMLElement;
+type RowData = CellData[];
+
+export abstract class Table extends Component {
+ private data: RowData[];
+ private table: HTMLTableElement;
+ private tbody: HTMLTableSectionElement;
+
+ constructor(el: HTMLElement, headings: string[], showFooter: boolean = true) {
+ super(el);
+
+ this.data = [];
+ this.table = document.createElement("table");
+
+ const thead = document.createElement("thead");
+ const tfoot = document.createElement("tfoot");
+ for (const heading of headings) {
+ const th = document.createElement("th");
+ th.innerText = heading;
+ thead.append(th);
+ if (showFooter) { tfoot.append(th.cloneNode(true)); }
+ }
+
+ this.tbody = document.createElement("tbody");
+
+ this.table.append(thead);
+ if (showFooter) { this.table.append(tfoot); }
+ this.table.append(this.tbody);
+ this.el.append(this.table);
+ }
+
+ public setData(data: RowData[]) {
+ this.data = data;
+ }
+
+ public render() {
+ while (this.tbody.firstChild) {
+ this.tbody.removeChild(this.tbody.firstChild);
+ }
+
+ for (const row of this.data) {
+ const tr = document.createElement("tr");
+
+ for (const cell of row) {
+ const td = document.createElement("td");
+
+ if (cell instanceof HTMLElement) {
+ td.append(cell);
+ } else {
+ td.innerText = String(cell);
+ }
+
+ tr.append(td);
+ }
+
+ this.tbody.append(tr);
+ }
+ }
+}
diff --git a/frontend/src/top-paths.ts b/frontend/src/top-paths.ts
new file mode 100644
index 0000000..73daac0
--- /dev/null
+++ b/frontend/src/top-paths.ts
@@ -0,0 +1,51 @@
+import { CURRENT_THRESHOLD } from "./config.js";
+import { IPageView } from "./page-view.js";
+import { Table } from "./table.js";
+
+const OLD_CLEAR_INTERVAL = 1;
+const MAX_LEN = 8;
+
+export class TopPaths extends Table {
+ private views: IPageView[];
+
+ constructor(el: HTMLElement) {
+ super(el, ["#", "Path"], false);
+
+ this.views = [];
+
+ setInterval(() => this.clearOld(), OLD_CLEAR_INTERVAL * 1000);
+ }
+
+ public handleView(view: IPageView) {
+ this.views.push(view);
+ this.render();
+ }
+
+ public render() {
+ const hitMap = this.views.reduce((acc, view) => {
+ let hits = 1;
+ if (acc.has(view.path_noq)) {
+ hits += acc.get(view.path_noq)!;
+ }
+
+ acc.set(view.path_noq, hits);
+ return acc;
+ }, new Map() as Map);
+
+ const hitList = Array.from(hitMap.entries());
+ hitList.sort((a, b) => {
+ return b[1] - a[1];
+ });
+
+ let transposedList = hitList.map(([path, hits]) => ([hits, path]));
+ transposedList = transposedList.slice(0, MAX_LEN - 1);
+ super.setData(transposedList);
+ super.render();
+ }
+
+ private clearOld() {
+ const now = Date.now();
+ this.views = this.views.filter((v) => (now - v.at.getTime()) <= CURRENT_THRESHOLD * 1000);
+ this.render();
+ }
+}
diff --git a/frontend/src/world-map.png b/frontend/src/world-map.png
new file mode 100644
index 0000000..64180e4
Binary files /dev/null and b/frontend/src/world-map.png differ
diff --git a/frontend/src/world-map.ts b/frontend/src/world-map.ts
new file mode 100644
index 0000000..7bfb6df
--- /dev/null
+++ b/frontend/src/world-map.ts
@@ -0,0 +1,62 @@
+import { Component } from "./component.js";
+import { IPageView } from "./page-view.js";
+
+/** How many seconds a spot lives on the map. */
+const SPOT_LIFETIME = 60;
+
+/** How often in seconds to update spot opacity. */
+const SPOT_UPDATE_INTERVAL = 1;
+
+export class WorldMap extends Component {
+ private spots: Map;
+
+ constructor(el: HTMLElement) {
+ super(el);
+
+ this.spots = new Map();
+
+ const img = document.createElement("img");
+ img.alt = "World map";
+ img.src = "src/world-map.png";
+
+ el.append(img);
+
+ setInterval(() => this.render(), 1);
+ }
+
+ public handleView(view: IPageView) {
+ if (view.loc_lat === 0.0 || view.loc_lon === 0.0) {
+ return;
+ } else {
+ let spot: HTMLDivElement;
+
+ if (this.spots.has(view.session)) {
+ spot = this.spots.get(view.session)![0];
+ } else {
+ spot = document.createElement("div");
+ }
+
+ spot.className = "spot";
+ spot.title = (view.loc_city ? `${view.loc_city}, ` : "") + `${view.loc_country} (${view.session})`;
+ spot.style.bottom = `${(view.loc_lat + 90) / 180 * 100}%`;
+ spot.style.left = `${(view.loc_lon + 180) / 360 * 100}%`;
+
+ this.el.append(spot);
+ this.spots.set(view.session, [spot, view.at]);
+ }
+ }
+
+ public render() {
+ const now = Date.now();
+ for (const [session, [el, at]] of this.spots.entries()) {
+ const life = 1 - ((now - at.getTime()) / (SPOT_LIFETIME * 1000));
+
+ if (life <= 0) {
+ this.el.removeChild(el);
+ this.spots.delete(session);
+ } else {
+ el.style.opacity = String(life);
+ }
+ }
+ }
+}
diff --git a/frontend/style.css b/frontend/style.css
new file mode 100644
index 0000000..c939fa2
--- /dev/null
+++ b/frontend/style.css
@@ -0,0 +1,101 @@
+*, *::before {
+ box-sizing: border-box;
+}
+
+html {
+ --background-color: #291F1E;
+ --lighter-bg: #343434;
+ --heading-color: #799DB4;
+ --text-color: #E9F0E2;
+ --alert-color: #F64740;
+
+ background-color: var(--background-color);
+ color: var(--text-color);
+ font-family: "Helvetica", sans-serif;
+ font-size: 100%;
+
+ width: 100%;
+ padding: 0;
+}
+
+body {
+ width: 100%;
+ padding: 5px;
+ margin: 0;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ font-weight: 100;
+ color: var(--heading-color);
+}
+
+main {
+ width: 100%;
+ padding: 10px;
+
+ display: grid;
+ grid-template: 'title wmap'
+ 'clive wmap'
+ '. wmap'
+ 'csess csess'
+ / auto auto;
+ gap: 10px;
+
+ border: 1px solid var(--lighter-bg);
+ border-radius: 3px;
+}
+
+h1 {
+ font-size: 3rem;
+ grid-area: title;
+ margin: 5px 0;
+}
+
+a {
+ color: var(--heading-color);
+ filter: brightness(150%);
+}
+
+table td {
+ border: 1px solid var(--heading-color);
+ background-color: var(--lighter-bg);
+ padding: 5px;
+}
+
+#currently-live {
+ grid-area: clive;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+#world-map {
+ grid-area: wmap;
+ position: relative;
+}
+
+#world-map img {
+ width: 100%;
+}
+
+#world-map .spot {
+ width: 10px;
+ height: 10px;
+ border-radius: 10px;
+ background-color: var(--alert-color);
+ opacity: 1;
+
+ position: absolute;
+}
+
+#current-sessions {
+ grid-area: csess;
+}
+
+#currently-live-counter {
+ font-size: 5rem;
+ font-weight: 100;
+ color: var(--heading-color);
+ filter: brightness(125%);
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..a2f1d5c
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compileOnSave": true,
+ "compilerOptions": {
+ "module": "esnext",
+ "outDir": "build",
+ "strict": true,
+ "moduleResolution": "node",
+ "sourceMap": true,
+ "target": "es2019",
+ },
+ "include": [
+ "src/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
diff --git a/frontend/tslint.json b/frontend/tslint.json
new file mode 100644
index 0000000..86bbb3a
--- /dev/null
+++ b/frontend/tslint.json
@@ -0,0 +1,11 @@
+{
+ "defaultSeverity": "error",
+ "extends": [
+ "tslint:recommended"
+ ],
+ "jsRules": {},
+ "rules": {
+ "no-console": false
+ },
+ "rulesDirectory": []
+}