Visualize join address / day in React server page

Join addresses were missing

- Implemented new group visualizer that allows viewing group data as column/bar/pie/table
- Implemented latest join address pie on server page (was missing)
- Implemented join addresses per day graph on new Join addresses tab
- Made playerbase overview use the group visualizer

Affects issues:
- Close #2362
This commit is contained in:
Aurora Lahtela 2022-09-05 16:56:59 +03:00
parent 2c233a0d21
commit cb21c45ea9
22 changed files with 592 additions and 34 deletions

View File

@ -0,0 +1,72 @@
/*
* This file is part of Player Analytics (Plan).
*
* Plan is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License v3 as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Plan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
*/
package com.djrapitops.plan.delivery.domain;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.Objects;
/**
* Represents a single join address - number pair.
*
* @author AuroraLS3
*/
public class JoinAddressCount implements Comparable<JoinAddressCount> {
private final int count;
private String joinAddress;
public JoinAddressCount(Map.Entry<String, Integer> entry) {
this(entry.getKey(), entry.getValue());
}
public JoinAddressCount(String joinAddress, int count) {
this.joinAddress = joinAddress;
this.count = count;
}
public String getJoinAddress() {
return joinAddress;
}
public void setJoinAddress(String joinAddress) {
this.joinAddress = joinAddress;
}
public int getCount() {
return count;
}
@Override
public int compareTo(@NotNull JoinAddressCount other) {
return String.CASE_INSENSITIVE_ORDER.compare(this.joinAddress, other.joinAddress);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
JoinAddressCount that = (JoinAddressCount) o;
return getCount() == that.getCount() && Objects.equals(getJoinAddress(), that.getJoinAddress());
}
@Override
public int hashCode() {
return Objects.hash(getJoinAddress(), getCount());
}
}

View File

@ -0,0 +1,42 @@
/*
* This file is part of Player Analytics (Plan).
*
* Plan is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License v3 as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Plan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
*/
package com.djrapitops.plan.delivery.domain;
import java.util.List;
/**
* @author AuroraLS3
*/
public class JoinAddressCounts implements DateHolder {
private final long date;
private final List<JoinAddressCount> joinAddresses;
public JoinAddressCounts(long date, List<JoinAddressCount> joinAddresses) {
this.date = date;
this.joinAddresses = joinAddresses;
}
@Override
public long getDate() {
return date;
}
public List<JoinAddressCount> getJoinAddresses() {
return joinAddresses;
}
}

View File

@ -17,6 +17,9 @@
package com.djrapitops.plan.delivery.rendering.json.graphs;
import com.djrapitops.plan.delivery.domain.DateMap;
import com.djrapitops.plan.delivery.domain.DateObj;
import com.djrapitops.plan.delivery.domain.JoinAddressCount;
import com.djrapitops.plan.delivery.domain.JoinAddressCounts;
import com.djrapitops.plan.delivery.domain.mutators.MutatorFunctions;
import com.djrapitops.plan.delivery.domain.mutators.PingMutator;
import com.djrapitops.plan.delivery.domain.mutators.TPSMutator;
@ -29,8 +32,6 @@ import com.djrapitops.plan.delivery.rendering.json.graphs.pie.Pie;
import com.djrapitops.plan.delivery.rendering.json.graphs.pie.WorldPie;
import com.djrapitops.plan.delivery.rendering.json.graphs.special.WorldMap;
import com.djrapitops.plan.delivery.rendering.json.graphs.stack.StackGraph;
import com.djrapitops.plan.delivery.web.resolver.exception.BadRequestException;
import com.djrapitops.plan.delivery.web.resolver.request.URIQuery;
import com.djrapitops.plan.gathering.domain.FinishedSession;
import com.djrapitops.plan.gathering.domain.Ping;
import com.djrapitops.plan.gathering.domain.WorldTimes;
@ -51,6 +52,7 @@ import com.djrapitops.plan.storage.database.queries.analysis.NetworkActivityInde
import com.djrapitops.plan.storage.database.queries.analysis.PlayerCountQueries;
import com.djrapitops.plan.storage.database.queries.objects.*;
import com.djrapitops.plan.storage.database.sql.tables.JoinAddressTable;
import com.djrapitops.plan.utilities.comparators.DateHolderOldestComparator;
import com.djrapitops.plan.utilities.java.Lists;
import com.djrapitops.plan.utilities.java.Maps;
import net.playeranalytics.plugin.scheduling.TimeAmount;
@ -62,6 +64,7 @@ import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* Creates Graph related Data JSON.
@ -123,9 +126,7 @@ public class GraphJSONCreator {
"}}";
}
public Map<String, Object> optimizedPerformanceGraphJSON(ServerUUID serverUUID, URIQuery query) {
// long after = getAfter(query); // TODO Implement if performance issues become apparent.
public Map<String, Object> optimizedPerformanceGraphJSON(ServerUUID serverUUID) {
long now = System.currentTimeMillis();
long twoMonthsAgo = now - TimeUnit.DAYS.toMillis(60);
long monthAgo = now - TimeUnit.DAYS.toMillis(30);
@ -187,16 +188,6 @@ public class GraphJSONCreator {
.build();
}
private long getAfter(URIQuery query) {
try {
return query.get("after")
.map(Long::parseLong)
.orElse(0L) - 500L; // Some headroom for out-of-sync clock.
} catch (NumberFormatException badType) {
throw new BadRequestException("'after': " + badType.toString());
}
}
public String playersOnlineGraph(ServerUUID serverUUID) {
Database db = dbSystem.getDatabase();
long now = System.currentTimeMillis();
@ -401,7 +392,7 @@ public class GraphJSONCreator {
long now = System.currentTimeMillis();
List<Ping> pings = db.query(PingQueries.fetchPingDataOfServer(now - TimeUnit.DAYS.toMillis(180L), now, serverUUID));
PingGraph pingGraph = graphs.line().pingGraph(new PingMutator(pings).mutateToByMinutePings().all());// TODO Optimize in query
PingGraph pingGraph = graphs.line().pingGraph(new PingMutator(pings).mutateToByMinutePings().all());
return "{\"min_ping_series\":" + pingGraph.getMinGraph().toHighChartsSeries() +
",\"avg_ping_series\":" + pingGraph.getAvgGraph().toHighChartsSeries() +
@ -468,4 +459,29 @@ public class GraphJSONCreator {
joinAddresses.put(locale.getString(GenericLang.UNKNOWN).toLowerCase(), unknown);
}
}
public Map<String, Object> joinAddressesByDay(ServerUUID serverUUID, long after, long before) {
String[] pieColors = theme.getPieColors(ThemeVal.GRAPH_WORLD_PIE);
List<DateObj<Map<String, Integer>>> joinAddresses = dbSystem.getDatabase().query(JoinAddressQueries.joinAddressesPerDay(serverUUID, config.getTimeZone().getOffset(System.currentTimeMillis()), after, before));
for (DateObj<Map<String, Integer>> addressesByDate : joinAddresses) {
translateUnknown(addressesByDate.getValue());
}
List<JoinAddressCounts> joinAddressCounts = joinAddresses.stream()
.map(addressesOnDay -> new JoinAddressCounts(
addressesOnDay.getDate(),
addressesOnDay.getValue().entrySet()
.stream()
.map(JoinAddressCount::new)
.sorted()
.collect(Collectors.toList())))
.sorted(new DateHolderOldestComparator())
.collect(Collectors.toList());
return Maps.builder(String.class, Object.class)
.put("colors", pieColors)
.put("join_addresses_by_date", joinAddressCounts)
.build();
}
}

View File

@ -51,7 +51,8 @@ public enum DataID {
EXTENSION_NAV,
EXTENSION_TABS,
EXTENSION_JSON,
LIST_SERVERS;
LIST_SERVERS,
JOIN_ADDRESSES_BY_DAY;
public String of(ServerUUID serverUUID) {
return name() + '-' + serverUUID;

View File

@ -98,6 +98,7 @@ public class GraphsJSONResolver implements Resolver {
@ExampleObject("punchCard"),
@ExampleObject("serverPie"),
@ExampleObject("joinAddressPie"),
@ExampleObject("joinAddressByDay"),
}),
@Parameter(in = ParameterIn.QUERY, name = "server", description = "Server identifier to get data for", examples = {
@ExampleObject("Server 1"),
@ -178,6 +179,8 @@ public class GraphsJSONResolver implements Resolver {
return DataID.GRAPH_SERVER_PIE;
case "joinAddressPie":
return DataID.GRAPH_HOSTNAME_PIE;
case "joinAddressByDay":
return DataID.JOIN_ADDRESSES_BY_DAY;
default:
throw new BadRequestException("unknown 'type' parameter.");
}
@ -188,7 +191,7 @@ public class GraphsJSONResolver implements Resolver {
case GRAPH_PERFORMANCE:
return graphJSON.performanceGraphJSON(serverUUID);
case GRAPH_OPTIMIZED_PERFORMANCE:
return graphJSON.optimizedPerformanceGraphJSON(serverUUID, query);
return graphJSON.optimizedPerformanceGraphJSON(serverUUID);
case GRAPH_ONLINE:
return graphJSON.playersOnlineGraph(serverUUID);
case GRAPH_UNIQUE_NEW:
@ -209,6 +212,15 @@ public class GraphsJSONResolver implements Resolver {
return graphJSON.pingGraphsJSON(serverUUID);
case GRAPH_PUNCHCARD:
return graphJSON.punchCardJSONAsMap(serverUUID);
case JOIN_ADDRESSES_BY_DAY:
try {
return graphJSON.joinAddressesByDay(serverUUID,
query.get("after").map(Long::parseLong).orElse(0L),
query.get("before").map(Long::parseLong).orElse(System.currentTimeMillis())
);
} catch (NumberFormatException e) {
throw new BadRequestException("'after' or 'before' is not a epoch millisecond (number) " + e.getMessage());
}
default:
return Collections.singletonMap("error", "Undefined ID: " + id.name());
}

View File

@ -122,6 +122,7 @@ public enum HtmlLang implements Lang {
TITLE_PLAYERBASE_DEVELOPMENT("html.label.playerbaseDevelopment", "Playerbase development"),
TITLE_CURRENT_PLAYERBASE("html.label.currentPlayerbase", "Current Playerbase"),
TITLE_JOIN_ADDRESSES("html.label.joinAddresses", "Join Addresses"),
TITLE_LATEST_JOIN_ADDRESSES("html.label.latestJoinAddresses", "Latest Join Addresses"),
COMPARING_60_DAYS("html.text.comparing30daysAgo", "Comparing 30d ago to Now"),
TITLE_30_DAYS_AGO("html.label.thirtyDaysAgo", "30 days ago"),
TITLE_NOW("html.label.now", "Now"),

View File

@ -16,19 +16,23 @@
*/
package com.djrapitops.plan.storage.database.queries.objects;
import com.djrapitops.plan.delivery.domain.DateObj;
import com.djrapitops.plan.identification.ServerUUID;
import com.djrapitops.plan.storage.database.queries.Query;
import com.djrapitops.plan.storage.database.queries.QueryAllStatement;
import com.djrapitops.plan.storage.database.queries.QueryStatement;
import com.djrapitops.plan.storage.database.queries.RowExtractors;
import com.djrapitops.plan.storage.database.sql.building.Sql;
import com.djrapitops.plan.storage.database.sql.tables.JoinAddressTable;
import com.djrapitops.plan.storage.database.sql.tables.ServerTable;
import com.djrapitops.plan.storage.database.sql.tables.SessionsTable;
import org.apache.commons.text.TextStringBuilder;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.*;
import java.util.stream.Collectors;
import static com.djrapitops.plan.storage.database.sql.building.Sql.*;
@ -110,4 +114,48 @@ public class JoinAddressQueries {
return db -> db.querySet(sql, RowExtractors.getInt(SessionsTable.USER_ID), joinAddresses.toArray());
}
public static Query<List<DateObj<Map<String, Integer>>>> joinAddressesPerDay(ServerUUID serverUUID, long timezoneOffset, long after, long before) {
return db -> {
Sql sql = db.getSql();
String selectAddresses = SELECT +
sql.dateToEpochSecond(sql.dateToDayStamp(sql.epochSecondToDate('(' + SessionsTable.SESSION_START + "+?)/1000"))) +
"*1000 as date," +
JoinAddressTable.JOIN_ADDRESS +
", COUNT(1) as count" +
FROM + SessionsTable.TABLE_NAME + " s" +
LEFT_JOIN + JoinAddressTable.TABLE_NAME + " j on s." + SessionsTable.JOIN_ADDRESS_ID + "=j." + JoinAddressTable.ID +
WHERE + SessionsTable.SERVER_ID + "=" + ServerTable.SELECT_SERVER_ID +
AND + SessionsTable.SESSION_START + ">?" +
AND + SessionsTable.SESSION_START + "<=?" +
GROUP_BY + "date,j." + JoinAddressTable.ID;
return db.query(new QueryStatement<>(selectAddresses, 1000) {
@Override
public void prepare(PreparedStatement statement) throws SQLException {
statement.setLong(1, timezoneOffset);
statement.setString(2, serverUUID.toString());
statement.setLong(3, after);
statement.setLong(4, before);
}
@Override
public List<DateObj<Map<String, Integer>>> processResults(ResultSet set) throws SQLException {
Map<Long, Map<String, Integer>> addressesByDate = new HashMap<>();
while (set.next()) {
long date = set.getLong("date");
String joinAddress = set.getString(JoinAddressTable.JOIN_ADDRESS);
int count = set.getInt("count");
Map<String, Integer> joinAddresses = addressesByDate.computeIfAbsent(date, k -> new TreeMap<>());
joinAddresses.put(joinAddress, count);
}
return addressesByDate.entrySet()
.stream().map(entry -> new DateObj<>(entry.getKey(), entry.getValue()))
.collect(Collectors.toList());
}
});
};
}
}

View File

@ -175,6 +175,7 @@ class AccessControlTest {
"/v1/graph?type=hourlyUniqueAndNew&server=" + TestConstants.SERVER_UUID_STRING + ",200",
"/v1/graph?type=serverCalendar&server=" + TestConstants.SERVER_UUID_STRING + ",200",
"/v1/graph?type=punchCard&server=" + TestConstants.SERVER_UUID_STRING + ",200",
"/v1/graph?type=joinAddressByDay&server=" + TestConstants.SERVER_UUID_STRING + "&after=0&before=" + 123456L + ",200",
"/v1/players?server=" + TestConstants.SERVER_UUID_STRING + ",200",
"/v1/kills?server=" + TestConstants.SERVER_UUID_STRING + ",200",
"/v1/pingTable?server=" + TestConstants.SERVER_UUID_STRING + ",200",
@ -251,6 +252,7 @@ class AccessControlTest {
"/v1/kills?server=" + TestConstants.SERVER_UUID_STRING + ",403",
"/v1/pingTable?server=" + TestConstants.SERVER_UUID_STRING + ",403",
"/v1/sessions?server=" + TestConstants.SERVER_UUID_STRING + ",403",
"/v1/graph?type=joinAddressByDay&server=" + TestConstants.SERVER_UUID_STRING + "&after=0&before=" + 123456L + ",403",
"/network,403",
"/v1/network/overview,403",
"/v1/network/servers,403",
@ -319,6 +321,7 @@ class AccessControlTest {
"/v1/graph?type=hourlyUniqueAndNew&server=" + TestConstants.SERVER_UUID_STRING + ",403",
"/v1/graph?type=serverCalendar&server=" + TestConstants.SERVER_UUID_STRING + ",403",
"/v1/graph?type=punchCard&server=" + TestConstants.SERVER_UUID_STRING + ",403",
"/v1/graph?type=joinAddressByDay&server=" + TestConstants.SERVER_UUID_STRING + "&after=0&before=" + 123456L + ",403",
"/v1/players?server=" + TestConstants.SERVER_UUID_STRING + ",403",
"/v1/kills?server=" + TestConstants.SERVER_UUID_STRING + ",403",
"/v1/pingTable?server=" + TestConstants.SERVER_UUID_STRING + ",403",
@ -391,6 +394,7 @@ class AccessControlTest {
"/v1/graph?type=hourlyUniqueAndNew&server=" + TestConstants.SERVER_UUID_STRING + ",403",
"/v1/graph?type=serverCalendar&server=" + TestConstants.SERVER_UUID_STRING + ",403",
"/v1/graph?type=punchCard&server=" + TestConstants.SERVER_UUID_STRING + ",403",
"/v1/graph?type=joinAddressByDay&server=" + TestConstants.SERVER_UUID_STRING + "&after=0&before=" + 123456L + ",403",
"/v1/players?server=" + TestConstants.SERVER_UUID_STRING + ",403",
"/v1/kills?server=" + TestConstants.SERVER_UUID_STRING + ",403",
"/v1/pingTable?server=" + TestConstants.SERVER_UUID_STRING + ",403",

View File

@ -32,6 +32,7 @@ const LoginPage = React.lazy(() => import("./views/layout/LoginPage"));
const ServerPerformance = React.lazy(() => import("./views/server/ServerPerformance"));
const ServerPluginData = React.lazy(() => import("./views/server/ServerPluginData"));
const ServerWidePluginData = React.lazy(() => import("./views/server/ServerWidePluginData"));
const ServerJoinAddresses = React.lazy(() => import("./views/server/ServerJoinAddresses"));
const NetworkPage = React.lazy(() => import("./views/layout/NetworkPage"));
const NetworkOverview = React.lazy(() => import("./views/network/NetworkOverview"));
@ -100,6 +101,7 @@ function App() {
<Route path="sessions" element={<Lazy><ServerSessions/></Lazy>}/>
<Route path="pvppve" element={<Lazy><ServerPvpPve/></Lazy>}/>
<Route path="playerbase" element={<Lazy><PlayerbaseOverview/></Lazy>}/>
<Route path="join-addresses" element={<Lazy><ServerJoinAddresses/></Lazy>}/>
<Route path="retention" element={<></>}/>
<Route path="players" element={<Lazy><ServerPlayers/></Lazy>}/>
<Route path="geolocations" element={<Lazy><ServerGeolocations/></Lazy>}/>

View File

@ -7,8 +7,8 @@ import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faUsers} from "@fortawesome/free-solid-svg-icons";
import PlayerbasePie from "../../../graphs/PlayerbasePie";
import {CardLoader} from "../../../navigation/Loader";
import GroupVisualizer from "../../../graphs/GroupVisualizer";
const CurrentPlayerbaseCard = () => {
const {t} = useTranslation();
@ -26,7 +26,7 @@ const CurrentPlayerbaseCard = () => {
<Fa icon={faUsers} className="col-amber"/> {t('html.label.currentPlayerbase')}
</h6>
</Card.Header>
<PlayerbasePie series={data.activity_pie_series}/>
<GroupVisualizer groups={data.activity_pie_series} name={t('html.label.players')}/>
</Card>
)
}

View File

@ -0,0 +1,34 @@
import React from 'react';
import {useTranslation} from "react-i18next";
import {useParams} from "react-router-dom";
import {useDataRequest} from "../../../../hooks/dataFetchHook";
import {fetchJoinAddressByDay} from "../../../../service/serverService";
import {ErrorViewCard} from "../../../../views/ErrorView";
import {CardLoader} from "../../../navigation/Loader";
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";
const JoinAddressGraphCard = () => {
const {t} = useTranslation();
const {identifier} = useParams();
const {data, loadingError} = useDataRequest(fetchJoinAddressByDay, [identifier]);
if (loadingError) return <ErrorViewCard error={loadingError}/>
if (!data) return <CardLoader/>;
return (
<Card>
<Card.Header>
<h6 className="col-black" style={{width: '100%'}}>
<Fa icon={faChartColumn} className="col-amber"/> {t('html.label.joinAddresses')}
</h6>
</Card.Header>
<JoinAddressGraph id={'join-address-graph'} data={data?.join_addresses_by_date} colors={data?.colors}/>
</Card>
)
};
export default JoinAddressGraphCard

View File

@ -0,0 +1,34 @@
import React from 'react';
import {useTranslation} from "react-i18next";
import {useParams} from "react-router-dom";
import {useDataRequest} from "../../../../hooks/dataFetchHook";
import {fetchJoinAddressPie} from "../../../../service/serverService";
import {ErrorViewCard} from "../../../../views/ErrorView";
import {CardLoader} from "../../../navigation/Loader";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faLocationArrow} from "@fortawesome/free-solid-svg-icons";
import GroupVisualizer from "../../../graphs/GroupVisualizer";
const JoinAddressGroupCard = () => {
const {t} = useTranslation();
const {identifier} = useParams();
const {data, loadingError} = useDataRequest(fetchJoinAddressPie, [identifier]);
if (loadingError) return <ErrorViewCard error={loadingError}/>
if (!data) return <CardLoader/>;
return (
<Card>
<Card.Header>
<h6 className="col-black" style={{width: '100%'}}>
<Fa icon={faLocationArrow} className="col-amber"/> {t('html.label.latestJoinAddresses')}
</h6>
</Card.Header>
<GroupVisualizer groups={data.slices} colors={data.colors}/>
</Card>
)
};
export default JoinAddressGroupCard

View File

@ -0,0 +1,56 @@
import React, {useEffect} from 'react';
import {useTranslation} from "react-i18next";
import {useTheme} from "../../hooks/themeHook";
import {withReducedSaturation} from "../../util/colors";
import Highcharts from "highcharts";
import Accessibility from "highcharts/modules/accessibility";
const GroupBarGraph = ({id, groups, colors, horizontal, name}) => {
const {t} = useTranslation();
const {nightModeEnabled, graphTheming} = useTheme();
useEffect(() => {
const reduceColors = (colorsToReduce) => colorsToReduce.map(color => withReducedSaturation(color));
function getColors() {
const actualColors = colors ? colors : groups.map(group => group.color);
return nightModeEnabled ? reduceColors(actualColors) : actualColors;
}
const bars = groups.map(group => group.y);
const categories = groups.map(group => t(group.name));
const barSeries = {
name: name,
colorByPoint: true,
data: bars,
colors: getColors()
};
Accessibility(Highcharts);
Highcharts.setOptions(graphTheming);
Highcharts.chart(id, {
chart: {type: horizontal ? 'bar' : 'column'},
title: {text: ''},
xAxis: {
categories: categories,
title: {text: ''}
},
yAxis: {
min: 0,
title: {text: '', align: 'high'},
labels: {overflow: 'justify'}
},
legend: {enabled: false},
plotOptions: {
bar: {
dataLabels: {enabled: true}
}
},
series: [barSeries]
})
}, [id, groups, colors, horizontal, name, graphTheming, nightModeEnabled, t]);
return (<div id={id} className="chart-area"/>);
};
export default GroupBarGraph

View File

@ -1,28 +1,35 @@
import React, {useEffect} from "react";
import Highcharts from 'highcharts';
import React, {useEffect} from 'react';
import {useTranslation} from "react-i18next";
import {useTheme} from "../../hooks/themeHook";
import {withReducedSaturation} from "../../util/colors";
import {useTranslation} from "react-i18next";
import Accessibility from "highcharts/modules/accessibility";
import Highcharts from "highcharts";
const PlayerbasePie = ({series}) => {
const GroupPie = ({id, groups, colors, name}) => {
const {t} = useTranslation();
const {nightModeEnabled, graphTheming} = useTheme();
useEffect(() => {
const reduceColors = (slices) => slices.map(slice => {
return {...slice, color: withReducedSaturation(slice.color)}
});
const reduceColors = (colorsToReduce) => colorsToReduce.map(color => withReducedSaturation(color));
function getColors() {
const actualColors = colors ? colors : groups.map(group => group.color);
return nightModeEnabled ? reduceColors(actualColors) : actualColors;
}
const series = groups.map(group => {
return {name: t(group.name), y: group.y}
});
const pieSeries = {
name: t('html.label.players'),
name: name,
colorByPoint: true,
data: nightModeEnabled ? reduceColors(series) : series
colors: getColors(),
data: series
};
Accessibility(Highcharts);
Highcharts.setOptions(graphTheming);
Highcharts.chart('playerbase-pie', {
Highcharts.chart(id, {
chart: {
backgroundColor: 'transparent',
plotBorderWidth: null,
@ -40,11 +47,16 @@ const PlayerbasePie = ({series}) => {
showInLegend: true
}
},
tooltip: {
formatter: function () {
return '<b>' + this.point.name + ':</b> ' + this.y;
}
},
series: [pieSeries]
});
}, [series, graphTheming, nightModeEnabled, t]);
}, [id, colors, groups, name, graphTheming, nightModeEnabled, t]);
return (<div className="chart-area" id="playerbase-pie"/>);
}
return (<div className="chart-area" id={id}/>);
};
export default PlayerbasePie;
export default GroupPie;

View File

@ -0,0 +1,63 @@
import React, {useState} from 'react';
import GroupTable from "../table/GroupTable";
import GroupPie from "./GroupPie";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faBarChart, faChartColumn, faPieChart, faTable} from "@fortawesome/free-solid-svg-icons";
import {Col, Row} from "react-bootstrap-v5";
import GroupBarGraph from "./GroupBarGraph";
const options = {
BAR: 'bar',
COLUMN: 'column',
PIE: 'pie',
TABLE: 'table'
}
const Visualizer = ({option, groups, colors, name}) => {
switch (option) {
case options.TABLE:
return <GroupTable groups={groups} colors={colors}/>
case options.PIE:
return <GroupPie id={'group-pie-' + new Date()} groups={groups} colors={colors} name={name}/>
case options.BAR:
return <GroupBarGraph id={'group-bar-' + new Date()} groups={groups} colors={colors} name={name}
horizontal/>;
case options.COLUMN:
default:
return <GroupBarGraph id={'group-bar-' + new Date()} groups={groups} colors={colors} name={name}/>;
}
}
const VisualizerSelector = ({onClick, icon}) => {
return (
<button className="btn float-end" onClick={onClick}>
<FontAwesomeIcon icon={icon} className="col-gray"/>
</button>
)
}
const GroupVisualizer = ({groups, colors, name, horizontal}) => {
const [visualization, setVisualization] = useState(groups.length > 1 ? options.COLUMN : options.TABLE);
const selectorFloatStyle = {
height: "0",
zIndex: 100,
position: "absolute",
width: "100%",
right: "0",
top: "0.5rem"
};
return <Row>
<Col md={12} style={selectorFloatStyle}>
<VisualizerSelector icon={faPieChart} onClick={() => setVisualization(options.PIE)}/>
<VisualizerSelector icon={faTable} onClick={() => setVisualization(options.TABLE)}/>
<VisualizerSelector icon={horizontal ? faBarChart : faChartColumn}
onClick={() => setVisualization(horizontal ? options.BAR : options.COLUMN)}/>
</Col>
<Col md={12}>
<Visualizer option={visualization} groups={groups} colors={colors} name={name}/>
</Col>
</Row>
};
export default GroupVisualizer

View File

@ -0,0 +1,82 @@
import React, {useEffect} from 'react';
import {useTranslation} from "react-i18next";
import {useTheme} from "../../hooks/themeHook";
import {withReducedSaturation} from "../../util/colors";
import NoDataDisplay from "highcharts/modules/no-data-to-display";
import Highcharts from "highcharts/highstock";
import Accessibility from "highcharts/modules/accessibility";
import {linegraphButtons} from "../../util/graphs";
const JoinAddressGraph = ({id, data, colors}) => {
const {t} = useTranslation()
const {nightModeEnabled, graphTheming} = useTheme();
useEffect(() => {
const getColor = i => {
const color = colors[i % colors.length];
return nightModeEnabled ? withReducedSaturation(color) : color;
}
NoDataDisplay(Highcharts);
Accessibility(Highcharts);
Highcharts.setOptions({lang: {noData: t('html.label.noDataToDisplay')}})
Highcharts.setOptions(graphTheming);
const valuesByAddress = {};
const dates = []
for (const point of data) {
dates.push(point.date);
for (const address of point.joinAddresses) {
if (!valuesByAddress[address.joinAddress]) valuesByAddress[address.joinAddress] = [];
valuesByAddress[address.joinAddress].push([point.date, address.count]);
}
}
const labels = dates;
const series = Object.entries(valuesByAddress).map((entry, i) => {
if (i >= colors.length) return {name: entry[0], data: entry[1]};
return {name: entry[0], data: entry[1], color: getColor(i)};
});
Highcharts.stockChart(id, {
chart: {
type: "column"
},
rangeSelector: {
selected: 3,
buttons: linegraphButtons
},
xAxis: {
categories: labels,
tickmarkPlacement: 'on',
title: {
enabled: false
},
ordinal: false
},
yAxis: {
softMax: 2,
softMin: 0
},
title: {text: ''},
plotOptions: {
column: {
stacking: 'normal',
lineWidth: 1
}
},
legend: {
enabled: true
},
series: series
})
}, [data, colors, graphTheming, id, t, nightModeEnabled])
return (
<div className="chart-area" style={{height: "450px"}} id={id}>
<span className="loader"/>
</div>
)
};
export default JoinAddressGraph

View File

@ -9,6 +9,7 @@ import {useTranslation} from "react-i18next";
const LineGraph = ({id, series}) => {
const {t} = useTranslation()
const {graphTheming, nightModeEnabled} = useTheme();
console.log(series)
useEffect(() => {
NoDataDisplay(Highcharts);

View File

@ -7,6 +7,7 @@ import {withReducedSaturation} from "../../util/colors";
import Accessibility from "highcharts/modules/accessibility";
const PlayerbaseGraph = ({data}) => {
console.log(data);
const {t} = useTranslation()
const {nightModeEnabled, graphTheming} = useTheme();

View File

@ -0,0 +1,44 @@
import React from 'react';
import {useTranslation} from "react-i18next";
import {useTheme} from "../../hooks/themeHook";
import {withReducedSaturation} from "../../util/colors";
const GroupRow = ({group, color}) => {
return (
<tr>
<td style={{color}}>{group.name}</td>
<td>{group.y}</td>
</tr>
)
}
const GroupTable = ({groups, colors}) => {
const {t} = useTranslation();
const {nightModeEnabled} = useTheme();
function getColor(i) {
if (groups[i].color) {
return nightModeEnabled ? withReducedSaturation(groups[i].color) : groups[i].color;
}
return nightModeEnabled ? withReducedSaturation(colors[i]) : colors[i];
}
return (
<table className={"table mb-0" + (nightModeEnabled ? " table-dark" : '')}>
<tbody>
{groups.length ? groups.map((group, i) =>
<GroupRow key={i}
group={group}
color={getColor(i)}/>) :
<tr>
<td>{t('generic.noData')}</td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>}
</tbody>
</table>
)
};
export default GroupTable

View File

@ -114,3 +114,13 @@ export const fetchPingGraph = async (timestamp, identifier) => {
const url = `/v1/graph?type=aggregatedPing&server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchJoinAddressPie = async (timestamp, identifier) => {
const url = `/v1/graph?type=joinAddressPie&server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchJoinAddressByDay = async (timestamp, identifier) => {
const url = `/v1/graph?type=joinAddressByDay&server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}

View File

@ -10,6 +10,7 @@ import {
faCubes,
faGlobe,
faInfoCircle,
faLocationArrow,
faSearch,
faUserGroup,
faUsers
@ -68,6 +69,7 @@ const ServerSidebar = () => {
icon: faChartLine,
href: "playerbase"
},
{name: 'html.label.joinAddresses', icon: faLocationArrow, href: "join-addresses"},
// {name: 'html.label.playerRetention', icon: faUsersViewfinder, href: "retention"},
{name: 'html.label.playerList', icon: faUserGroup, href: "players"},
{name: 'html.label.geolocations', icon: faGlobe, href: "geolocations"},

View File

@ -0,0 +1,21 @@
import React from 'react';
import {Col, Row} from "react-bootstrap-v5";
import JoinAddressGroupCard from "../../components/cards/server/graphs/JoinAddressGroupCard";
import JoinAddressGraphCard from "../../components/cards/server/graphs/JoinAddressGraphCard";
const ServerJoinAddresses = () => {
return (
<Row>
<Col lg={8}>
<JoinAddressGraphCard/>
</Col>
<Col lg={4}>
<JoinAddressGroupCard/>
</Col>
</Row>
)
};
export default ServerJoinAddresses