Implemented network geolocations tab in React

- Add Projection selector to geolocations visualization
This commit is contained in:
Aurora Lahtela 2022-09-11 09:30:33 +03:00
parent 7a51633690
commit cb7749d778
17 changed files with 193 additions and 53 deletions

View File

@ -252,6 +252,11 @@ public enum HtmlLang implements Lang {
LABEL_ALPHABETICAL("html.label.alphabetical", "Alphabetical"),
LABEL_SORT_BY("html.label.sortBy", "Sort By"),
LABEL_STACKED("html.label.stacked", "Stacked"),
LABEL_PROJECTION("html.label.geoProjection.dropdown", "Select projection"),
LABEL_PROJECTION_MILLER("html.label.geoProjection.miller", "Miller"),
LABEL_PROJECTION_MERCATOR("html.label.geoProjection.mercator", "Mercator"),
LABEL_PROJECTION_EQUAL_EARTH("html.label.geoProjection.equalEarth", "Equal Earth"),
LABEL_PROJECTION_ORTOGRAPHIC("html.label.geoProjection.ortographic", "Ortographic"),
LOGIN_LOGIN("html.login.login", "Login"),
LOGIN_LOGOUT("html.login.logout", "Logout"),

View File

@ -1866,6 +1866,7 @@ a.text-dark:hover, a.text-dark:focus {
#wrapper {
display: flex;
min-height: 100vh;
}
#wrapper #content-wrapper {

View File

@ -39,6 +39,7 @@ const NetworkOverview = React.lazy(() => import("./views/network/NetworkOverview
const NetworkServers = React.lazy(() => import("./views/network/NetworkServers"));
const NetworkSessions = React.lazy(() => import("./views/network/NetworkSessions"));
const NetworkJoinAddresses = React.lazy(() => import("./views/network/NetworkJoinAddresses"));
const NetworkGeolocations = React.lazy(() => import("./views/network/NetworkGeolocations"));
const PlayersPage = React.lazy(() => import("./views/layout/PlayersPage"));
const AllPlayers = React.lazy(() => import("./views/players/AllPlayers"));
@ -124,6 +125,7 @@ function App() {
<Route path="sessions" element={<Lazy><NetworkSessions/></Lazy>}/>
<Route path="join-addresses" element={<Lazy><NetworkJoinAddresses/></Lazy>}/>
<Route path="players" element={<Lazy><AllPlayers/></Lazy>}/>
<Route path="geolocations" element={<Lazy><NetworkGeolocations/></Lazy>}/>
<Route path="plugins-overview" element={<Lazy><ServerPluginData/></Lazy>}/>
<Route path="plugins/:plugin" element={<Lazy><ServerWidePluginData/></Lazy>}/>
<Route path="*" element={<ErrorView error={{

View File

@ -1,22 +1,49 @@
import {useTranslation} from "react-i18next";
import {Card, Col, Row} from "react-bootstrap-v5";
import {Card, Col, Dropdown, Row} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import React from "react";
import {faExclamationTriangle, faGlobe} from "@fortawesome/free-solid-svg-icons";
import React, {useState} from "react";
import {faExclamationTriangle, faGlobe, faLayerGroup} from "@fortawesome/free-solid-svg-icons";
import GeolocationBarGraph from "../../graphs/GeolocationBarGraph";
import GeolocationWorldMap from "../../graphs/GeolocationWorldMap";
import GeolocationWorldMap, {ProjectionOptions} from "../../graphs/GeolocationWorldMap";
import {CardLoader} from "../../navigation/Loader";
import DropdownToggle from "react-bootstrap-v5/lib/esm/DropdownToggle";
import DropdownMenu from "react-bootstrap-v5/lib/esm/DropdownMenu";
import DropdownItem from "react-bootstrap-v5/lib/esm/DropdownItem";
const ProjectionDropDown = ({projection, setProjection}) => {
const {t} = useTranslation();
const projectionOptions = Object.values(ProjectionOptions);
return (
<Dropdown className="float-end" style={{position: "absolute", right: "0.5rem"}}
title={t('html.label.geoProjection.dropdown')}>
<DropdownToggle variant=''>
<Fa icon={faLayerGroup}/> {t(projection)}
</DropdownToggle>
<DropdownMenu>
<h6 className="dropdown-header">{t('html.label.geoProjection.dropdown')}</h6>
{projectionOptions.map((option, i) => (
<DropdownItem key={i} onClick={() => setProjection(option)}>
{t(option)}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
)
}
const GeolocationsCard = ({data}) => {
const {t} = useTranslation();
const [projection, setProjection] = useState(ProjectionOptions.MILLER);
if (!data) return <CardLoader/>
if (!data?.geolocations_enabled) {
return (
<div className="alert alert-warning mb-0" id="geolocation-warning">
<Fa icon={faExclamationTriangle}/>{' '}
{t('html.description.noGeolocations')}
<Fa icon={faExclamationTriangle}/>{' '}{t('html.description.noGeolocations')}
</div>
)
}
@ -27,6 +54,7 @@ const GeolocationsCard = ({data}) => {
<h6 className="col-black">
<Fa icon={faGlobe} className="col-green"/> {t('html.label.geolocations')}
</h6>
<ProjectionDropDown projection={projection} setProjection={setProjection}/>
</Card.Header>
<Card.Body className="chart-area" style={{height: "100%"}}>
<Row>
@ -34,7 +62,8 @@ const GeolocationsCard = ({data}) => {
<GeolocationBarGraph series={data.geolocation_bar_series} color={data.colors.bars}/>
</Col>
<Col md={9}>
<GeolocationWorldMap series={data.geolocation_series} colors={data.colors}/>
<GeolocationWorldMap series={data.geolocation_series} colors={data.colors}
projection={projection}/>
</Col>
</Row>
</Card.Body>

View File

@ -8,7 +8,7 @@ import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faChartColumn} from "@fortawesome/free-solid-svg-icons";
import JoinAddressGraph from "../../../graphs/JoinAddressGraph";
import Toggle from "../../../Toggle";
import Toggle from "../../../input/Toggle";
const JoinAddressGraphCard = ({identifier}) => {
const {t} = useTranslation();

View File

@ -1,13 +1,34 @@
import React, {useEffect} from 'react';
import {useTranslation} from "react-i18next";
import {useTheme} from "../../hooks/themeHook";
import {withReducedSaturation} from "../../util/colors";
import Highcharts from 'highcharts/highmaps.js';
import map from '@highcharts/map-collection/custom/world.geo.json';
import topology from '@highcharts/map-collection/custom/world.topo.json';
import Accessibility from "highcharts/modules/accessibility";
import NoDataDisplay from "highcharts/modules/no-data-to-display";
const GeolocationWorldMap = ({series, colors}) => {
export const ProjectionOptions = {
MILLER: "html.label.geoProjection.miller",
MERCATOR: "html.label.geoProjection.mercator",
EQUAL_EARTH: "html.label.geoProjection.equalEarth"
// ORTOGRAPHIC: "html.label.geoProjection.ortographic"
}
const getProjection = option => {
switch (option) {
case ProjectionOptions.MERCATOR:
return {name: 'WebMercator'};
case ProjectionOptions.EQUAL_EARTH:
return {name: 'EqualEarth'};
// Ortographic projection stops working after a while for some reason
// case ProjectionOptions.ORTOGRAPHIC:
// return {name: 'Orthographic'};
case ProjectionOptions.MILLER:
default:
return {name: 'Miller'};
}
}
const GeolocationWorldMap = ({series, colors, projection}) => {
const {t} = useTranslation();
const {nightModeEnabled, graphTheming} = useTheme();
@ -15,7 +36,6 @@ const GeolocationWorldMap = ({series, colors}) => {
const mapSeries = {
name: t('html.label.players'),
type: 'map',
mapData: map,
data: series,
joinBy: ['iso-a3', 'code']
};
@ -26,24 +46,31 @@ const GeolocationWorldMap = ({series, colors}) => {
Highcharts.setOptions({lang: {noData: t('html.label.noDataToDisplay')}});
Highcharts.mapChart('countryWorldMap', {
chart: {
map: topology,
animation: true
},
title: {text: ''},
mapNavigation: {
enabled: true,
enableDoubleClickZoomTo: true
enableDoubleClickZoomTo: true,
enableMouseWheelZoom: true,
enableTouchZoom: true
},
mapView: {
projection: getProjection(projection)
},
colorAxis: {
min: 1,
type: 'logarithmic',
minColor: nightModeEnabled ? withReducedSaturation(colors.low) : colors.low,
maxColor: nightModeEnabled ? withReducedSaturation(colors.high) : colors.high
minColor: colors.low,
maxColor: colors.high
},
series: [mapSeries]
})
}, [colors, series, graphTheming, nightModeEnabled, t]);
}, [colors, series, graphTheming, nightModeEnabled, t, projection]);
return (<div id="countryWorldMap"/>);
};

View File

@ -0,0 +1,29 @@
import React from 'react';
import DropdownToggle from "react-bootstrap-v5/lib/esm/DropdownToggle";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import DropdownMenu from "react-bootstrap-v5/lib/esm/DropdownMenu";
import DropdownItem from "react-bootstrap-v5/lib/esm/DropdownItem";
import {useTranslation} from "react-i18next";
const BasicDropdown = ({selected, optionList, onChange, optionLabelMapper, icon, title}) => {
const {t} = useTranslation();
return (
<BasicDropdown className="float-end" style={{position: "absolute", right: "0.5rem"}} title={t(title)}>
<DropdownToggle variant=''>
<Fa icon={icon}/> {t(optionLabelMapper ? optionLabelMapper(selected) : selected)}
</DropdownToggle>
<DropdownMenu>
<h6 className="dropdown-header">{t(title)}</h6>
{optionList.map((option, i) => (
<DropdownItem key={i} onClick={() => onChange(option)}>
{t(optionLabelMapper ? optionLabelMapper(option) : option)}
</DropdownItem>
))}
</DropdownMenu>
</BasicDropdown>
)
};
export default BasicDropdown

View File

@ -59,7 +59,7 @@ const Header = ({page, tab}) => {
<span className="topbar-divider"/>
<div className="refresh-element">
<button onClick={requestUpdate}>
<Fa icon={faSyncAlt} spin={updating}/>
<Fa icon={faSyncAlt} spin={Boolean(updating)}/>
</button>
{' '}
<span className="refresh-time">{lastUpdate.formatted}</span>

View File

@ -1,6 +1,7 @@
import React from "react";
import {useTheme} from "../../hooks/themeHook";
import {useTranslation} from "react-i18next";
import Scrollable from "../Scrollable";
const PingRow = ({country}) => {
return (
@ -18,24 +19,26 @@ const PingTable = ({countries}) => {
const {nightModeEnabled} = useTheme();
return (
<table className={"table mb-0" + (nightModeEnabled ? " table-dark" : '')}>
<thead className="bg-amber">
<tr>
<th>{t('html.label.country')}</th>
<th>{t('html.label.averagePing')}</th>
<th>{t('html.label.bestPing')}</th>
<th>{t('html.label.worstPing')}</th>
</tr>
</thead>
<tbody>
{countries.length ? countries.map((country, i) => <PingRow key={i} country={country}/>) : <tr>
<td>{t('generic.noData')}</td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>}
</tbody>
</table>
<Scrollable>
<table className={"table mb-0" + (nightModeEnabled ? " table-dark" : '')}>
<thead className="bg-amber" style={{position: "sticky", top: 0}}>
<tr>
<th>{t('html.label.country')}</th>
<th>{t('html.label.averagePing')}</th>
<th>{t('html.label.bestPing')}</th>
<th>{t('html.label.worstPing')}</th>
</tr>
</thead>
<tbody>
{countries.length ? countries.map((country, i) => <PingRow key={i} country={country}/>) : <tr>
<td>{t('generic.noData')}</td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>}
</tbody>
</table>
</Scrollable>
)
};

View File

@ -19,3 +19,8 @@ export const fetchNetworkSessionsOverview = async (timestamp) => {
const url = `/v1/network/sessionsOverview?timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchNetworkPingTable = async (timestamp) => {
const url = `/v1/network/pingTable?timestamp=${timestamp}`;
return doGetRequest(url);
}

View File

@ -58,7 +58,7 @@ export const fetchPlayers = async (timestamp, identifier) => {
}
export const fetchPingTable = async (timestamp, identifier) => {
const url = identifier ? `/v1/pingTable?server=${identifier}&timestamp=${timestamp}` : `/v1/pingTable?timestamp=${timestamp}`;
const url = `/v1/pingTable?server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
@ -102,7 +102,8 @@ export const fetchWorldPie = async (timestamp, identifier) => {
}
export const fetchGeolocations = async (timestamp, identifier) => {
const url = `/v1/graph?type=geolocation&server=${identifier}&timestamp=${timestamp}`;
const url = identifier ? `/v1/graph?type=geolocation&server=${identifier}&timestamp=${timestamp}` :
`/v1/graph?type=geolocation&timestamp=${timestamp}`;
return doGetRequest(url);
}

View File

@ -1868,6 +1868,7 @@ a.text-dark:hover, a.text-dark:focus {
#wrapper {
display: flex;
min-height: 100vh;
}
#wrapper #content-wrapper {

View File

@ -1345,3 +1345,8 @@ button, input[type="submit"], input[type="reset"] {
position: relative;
top: 0.1rem;
}
.dataTables_filter input {
/* Fixes datatables search bar going outside cards */
width: calc(100% - 3.7rem) !important;
}

View File

@ -0,0 +1,24 @@
import React from 'react';
import {Col, Row} from "react-bootstrap-v5";
import {ErrorViewCard} from "../ErrorView";
import GeolocationsCard from "../../components/cards/common/GeolocationsCard";
import PingTableCard from "../../components/cards/common/PingTableCard";
import LoadIn from "../../components/animation/LoadIn";
const Geolocations = ({className, geolocationData, pingData, geolocationError, pingError}) => {
return (
<LoadIn>
<section className={className}>
<Row>
<Col md={12}>
{geolocationError ? <ErrorViewCard error={geolocationError}/> :
<GeolocationsCard data={geolocationData}/>}
{pingError ? <ErrorViewCard error={pingError}/> : <PingTableCard data={pingData}/>}
</Col>
</Row>
</section>
</LoadIn>
)
};
export default Geolocations

View File

@ -0,0 +1,19 @@
import React from 'react';
import {useDataRequest} from "../../hooks/dataFetchHook";
import Geolocations from "../common/Geolocations";
import {fetchNetworkPingTable} from "../../service/networkService";
import {fetchGeolocations} from "../../service/serverService";
const NetworkGeolocations = () => {
const {data, loadingError} = useDataRequest(fetchGeolocations, []);
const {data: pingData, loadingError: pingLoadingError} = useDataRequest(fetchNetworkPingTable, []);
return (
<Geolocations className={"server_geolocations"}
geolocationData={data} geolocationError={loadingError}
pingData={pingData} pingError={pingLoadingError}
/>
)
};
export default NetworkGeolocations

View File

@ -2,11 +2,7 @@ import React from 'react';
import {useParams} from "react-router-dom";
import {useDataRequest} from "../../hooks/dataFetchHook";
import {fetchGeolocations, fetchPingTable} from "../../service/serverService";
import {Col, Row} from "react-bootstrap-v5";
import {ErrorViewCard} from "../ErrorView";
import GeolocationsCard from "../../components/cards/common/GeolocationsCard";
import LoadIn from "../../components/animation/LoadIn";
import PingTableCard from "../../components/cards/common/PingTableCard";
import Geolocations from "../common/Geolocations";
const ServerGeolocations = () => {
const {identifier} = useParams();
@ -15,17 +11,10 @@ const ServerGeolocations = () => {
const {pingData, pingLoadingError} = useDataRequest(fetchPingTable, [identifier]);
return (
<LoadIn>
<section className="server_geolocations">
<Row>
<Col md={12}>
{loadingError ? <ErrorViewCard error={loadingError}/> : <GeolocationsCard data={data}/>}
{pingLoadingError ? <ErrorViewCard error={pingLoadingError}/> :
<PingTableCard data={pingData}/>}
</Col>
</Row>
</section>
</LoadIn>
<Geolocations className={"server_geolocations"}
geolocationData={data} geolocationError={loadingError}
pingData={pingData} pingError={pingLoadingError}
/>
)
};