Plan/Plan/react/dashboard/src/components/cards/common/PlayerRetentionGraphCard.jsx

330 lines
16 KiB
JavaScript

import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Card, Col, Row} from "react-bootstrap";
import CardHeader from "../CardHeader";
import {faUsersViewfinder} from "@fortawesome/free-solid-svg-icons";
import {useTranslation} from "react-i18next";
import ExtendableCardBody from "../../layout/extension/ExtendableCardBody";
import {BasicDropdown} from "../../input/BasicDropdown";
import {useDataRequest} from "../../../hooks/dataFetchHook";
import {fetchPlayerJoinAddresses, fetchRetentionData} from "../../../service/serverService";
import {ErrorViewCard} from "../../../views/ErrorView";
import {CardLoader} from "../../navigation/Loader";
import {tooltip} from "../../../util/graphs";
import {hsvToRgb, randomHSVColor, rgbToHexString, withReducedSaturation} from "../../../util/colors";
import LineGraph from "../../graphs/LineGraph";
import FunctionPlotGraph from "../../graphs/FunctionPlotGraph";
import {useTheme} from "../../../hooks/themeHook";
import {useNavigation} from "../../../hooks/navigationHook";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faQuestionCircle} from "@fortawesome/free-regular-svg-icons";
import {useJoinAddressListContext} from "../../../hooks/context/joinAddressListContextHook.jsx";
const dayMs = 24 * 3600000;
const getWeek = (date) => {
const onejan = new Date(date.getFullYear(), 0, 1);
const today = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const dayOfYear = ((today - onejan + 86400000) / 86400000);
return Math.ceil(dayOfYear / 7)
};
const PlayerRetentionGraphCard = ({identifier, selectedGroupBy, setSelectedGroupBy}) => {
const {t} = useTranslation();
const {nightModeEnabled} = useTheme();
const {setHelpModalTopic} = useNavigation();
const openHelp = useCallback(() => setHelpModalTopic('player-retention-graph'), [setHelpModalTopic]);
const time = useMemo(() => new Date().getTime(), []);
const {data, loadingError} = useDataRequest(fetchRetentionData, [identifier]);
const {
data: joinAddressData,
loadingError: joinAddressLoadingError
} = useDataRequest(fetchPlayerJoinAddresses, [identifier]);
const {list, playerAddresses} = useJoinAddressListContext();
const [selectedWindow, setSelectedWindow] = useState('days');
const windowOptions = useMemo(() => [
{name: 'hours', displayName: t('html.label.time.hours'), increment: 3600000},
{name: 'days', displayName: t('html.label.time.days'), increment: dayMs},
{name: 'weeks', displayName: t('html.label.time.weeks'), increment: 7 * dayMs},
{name: 'months', displayName: t('html.label.time.months'), increment: 30 * dayMs},
], [t]);
const [selectedGroup, setSelectedGroup] = useState('registered-7d');
const groupOptions = useMemo(() => [
{name: 'registered-7d', displayName: t('html.label.retention.inLast7d'), start: time - 7 * dayMs},
{name: 'registered-30d', displayName: t('html.label.retention.inLast30d'), start: time - 30 * dayMs},
{name: 'registered-3m', displayName: t('html.label.retention.inLast90d'), start: time - 3 * 30 * dayMs},
{name: 'registered-6m', displayName: t('html.label.retention.inLast180d'), start: time - 6 * 30 * dayMs},
{name: 'registered-1y', displayName: t('html.label.retention.inLast365d'), start: time - 365 * dayMs},
{name: 'registered-2y', displayName: t('html.label.retention.inLast730d'), start: time - 2 * 365 * dayMs},
{name: 'registered-ever', displayName: t('html.label.retention.inAnytime'), start: 0},
], [t, time]);
// State moved to higher level for join address group selection
const groupByOptions = useMemo(() => [
{name: 'none', displayName: t('html.label.retention.groupByNone')},
{name: 'days', displayName: t('html.label.time.day')},
{name: 'weeks', displayName: t('html.label.time.week')},
{name: 'months', displayName: t('html.label.time.month')},
{name: 'years', displayName: t('html.label.time.year')},
{name: 'joinAddress', displayName: t('html.label.joinAddress')},
], [t]);
const [selectedYAxis, setSelectedYAxis] = useState('percentage');
const yAxisOptions = useMemo(() => [
{name: 'percentage', displayName: t('html.label.unit.percentage')},
{name: 'count', displayName: t('html.label.unit.playerCount')},
{name: 'count-stacked', displayName: t('html.label.unit.playerCount') + ' (' + t('html.label.stacked') + ')'},
], [t]);
const [selectedAxis, setSelectedAxis] = useState('time');
const axisOptions = useMemo(() => [
{name: 'time', displayName: t('html.label.retention.timeSinceRegistered')},
{name: 'playtime', displayName: t('html.label.playtime')},
{name: 'date', displayName: t('html.label.time.date')},
{name: 'deltas', displayName: t('html.label.time.date') + ' > ' + t('html.label.registered')},
], [t]);
const [series, setSeries] = useState([]);
const [graphOptions, setGraphOptions] = useState({title: {text: ''},});
const mapToData = useCallback(async (dataToMap, start) => {
const total = dataToMap.length;
let seriesData;
const increment = windowOptions.find(option => option.name === selectedWindow).increment;
const xAxis = axisOptions.find(option => option.name === selectedAxis).name;
switch (xAxis) {
case 'deltas':
const retainedBasedOnDeltas = [];
const firstRegisterDeltasStart = dataToMap[0].registerDate - dataToMap[0].registerDate % increment;
let previousRetained = -1;
for (let date = firstRegisterDeltasStart; date < time; date += increment) {
const filter = player => player.registerDate <= date && player.lastSeenDate >= date;
const retainedSince = dataToMap.filter(filter).length;
retainedBasedOnDeltas.push([date, selectedYAxis === 'percentage' ? retainedSince * 100.0 / total : retainedSince]);
if (previousRetained === retainedSince && retainedSince <= 0.5) break;
if (previousRetained !== -1 || retainedSince > 0) previousRetained = retainedSince;
}
seriesData = retainedBasedOnDeltas;
break;
case 'date':
const retainedBasedOnDate = [];
const firstRegisterDateStart = dataToMap[0].registerDate - dataToMap[0].registerDate % increment;
for (let date = firstRegisterDateStart; date < time; date += increment) {
const filter = player => player.lastSeenDate >= date;
const retainedSince = dataToMap.filter(filter).length;
retainedBasedOnDate.push([date, selectedYAxis === 'percentage' ? retainedSince * 100.0 / total : retainedSince]);
if (retainedSince < 0.5) break;
}
seriesData = retainedBasedOnDate;
break;
case 'time':
const retainedBasedOnTime = [];
for (let i = 0; i < time; i += increment) {
const retainedSince = dataToMap.filter(point => point.timeDifference > i).length;
retainedBasedOnTime.push([(i) / increment, selectedYAxis === 'percentage' ? retainedSince * 100.0 / total : retainedSince]);
if (retainedSince < 0.5) break;
}
seriesData = retainedBasedOnTime;
break;
case 'playtime':
default:
const retainedBasedOnPlaytime = [];
for (let i = start; i < time; i += increment) {
const retainedSince = dataToMap.filter(point => point.playtime > i - start).length;
retainedBasedOnPlaytime.push([(i - start) / increment, selectedYAxis === 'percentage' ? retainedSince * 100.0 / total : retainedSince]);
if (retainedSince < 0.5) break;
}
seriesData = retainedBasedOnPlaytime;
break;
}
return seriesData;
}, [selectedWindow, windowOptions, selectedAxis, axisOptions, selectedYAxis, time])
const group = useCallback(async (filtered, joinAddressData) => {
const grouped = {};
const groupBy = groupByOptions.find(option => option.name === selectedGroupBy).name;
for (const point of filtered) {
const date = new Date();
date.setTime(point.registerDate);
switch (groupBy) {
case 'days':
const day = date.toISOString().substring(0, 10);
if (!grouped[day]) grouped[day] = [];
grouped[day].push(point);
break;
case 'weeks':
const week = date.getUTCFullYear() + '-week-' + getWeek(date);
if (!grouped[week]) grouped[week] = [];
grouped[week].push(point);
break;
case 'months':
const month = date.toISOString().substring(0, 7);
if (!grouped[month]) grouped[month] = [];
grouped[month].push(point);
break;
case 'years':
const year = date.getUTCFullYear();
if (!grouped[year]) grouped[year] = [];
grouped[year].push(point);
break;
case 'joinAddress':
const joinAddress = joinAddressData[point.playerUUID];
const joinAddressGroups = list.filter(g => g.addresses.includes(joinAddress)).map(g => g.name);
for (const joinAddressGroup of joinAddressGroups) {
if (!grouped[joinAddressGroup]) grouped[joinAddressGroup] = [];
grouped[joinAddressGroup].push(point);
}
break;
case 'none':
default:
grouped['all'] = filtered;
break;
}
}
return grouped;
}, [groupByOptions, selectedGroupBy, list]);
const createSeries = useCallback(async (retentionData, joinAddressData) => {
const start = groupOptions.find(option => option.name === selectedGroup).start;
const filtered = retentionData.filter(point => point.registerDate > start)
.sort((a, b) => a.registerDate - b.registerDate);
const grouped = await group(filtered, joinAddressData);
let colorIndex = 1;
return Promise.all(Object.entries(grouped).map(async group => {
const name = group[0];
const groupData = group[1];
const color = rgbToHexString(hsvToRgb(randomHSVColor(colorIndex)));
colorIndex++;
const mapped = await mapToData(groupData, start);
if (mapped.filter(point => point[1] === 0).length === mapped.length) {
// Don't include all zeros series
return [];
}
return [{
name: name,
type: selectedYAxis === 'count-stacked' ? 'areaspline' : 'spline',
tooltip: tooltip.twoDecimals,
data: mapped,
color: nightModeEnabled ? withReducedSaturation(color) : color
}];
}));
}, [nightModeEnabled, mapToData, groupOptions, selectedGroup, selectedYAxis, group]);
useEffect(() => {
if (!data || !playerAddresses) return;
createSeries(data.player_retention, playerAddresses).then(series => setSeries(series.flat()));
}, [data, playerAddresses, createSeries, setSeries]);
useEffect(() => {
const windowName = windowOptions.find(option => option.name === selectedWindow).displayName;
const unitLabel = selectedYAxis === 'percentage' ? t('html.label.retention.retainedPlayersPercentage') : t('html.label.players');
const axisName = axisOptions.find(option => option.name === selectedAxis).displayName;
setGraphOptions({
title: {text: ''},
rangeSelector: selectedAxis === 'date' ? {
selected: 2,
buttons: [{
type: 'day',
count: 7,
text: '7d'
}, {
type: 'month',
count: 1,
text: '30d'
}, {
type: 'all',
text: 'All'
}]
} : undefined,
chart: {
zooming: {
type: 'xy'
}
},
plotOptions: {
areaspline: {
fillOpacity: nightModeEnabled ? 0.2 : 0.4,
stacking: 'normal'
}
},
legend: {
enabled: selectedGroupBy !== 'none',
},
xAxis: {
zoomEnabled: true,
title: {
text: selectedAxis === 'date' || selectedAxis === 'deltas' ? t('html.label.time.date') : axisName + ' (' + windowName + ')'
}
},
yAxis: {
zoomEnabled: true,
title: {text: unitLabel},
max: selectedYAxis === 'percentage' ? 100 : undefined,
min: 0
},
tooltip: selectedAxis === 'date' || selectedAxis === 'deltas' ? {
enabled: true,
shared: series.length <= 10,
valueDecimals: 2,
pointFormat: (selectedGroupBy !== 'none' ? '{series.name} - ' : '') + '<b>{point.y} ' + (selectedYAxis === 'percentage' ? '%' : t('html.label.players')) + '</b><br>'
} : {
enabled: true,
shared: series.length <= 10,
valueDecimals: 2,
headerFormat: '{point.x} ' + windowName + '<br>',
pointFormat: (selectedGroupBy !== 'none' ? '{series.name} - ' : '') + '<b>{point.y} ' + (selectedYAxis === 'percentage' ? '%' : t('html.label.players')) + '</b><br>'
},
series: series
})
}, [t, nightModeEnabled, series, selectedGroupBy, axisOptions, selectedAxis, windowOptions, selectedWindow, selectedYAxis]);
if (loadingError) return <ErrorViewCard error={loadingError}/>
if (joinAddressLoadingError) return <ErrorViewCard error={joinAddressLoadingError}/>
if (!data || !joinAddressData) return <CardLoader/>;
return (
<Card>
<CardHeader icon={faUsersViewfinder} color={'indigo'} label={t('html.label.playerRetention')}>
<button className={"float-end"} onClick={openHelp}>
<Fa className={"col-blue"}
icon={faQuestionCircle}/>
</button>
</CardHeader>
<ExtendableCardBody id={'card-body-' + (identifier ? 'server-' : 'network-') + 'player-retention'}>
<Row>
<Col>
<label>{t('html.label.retention.timeStep')}</label>
<BasicDropdown selected={selectedWindow} options={windowOptions} onChange={setSelectedWindow}/>
</Col>
<Col>
<label>{t('html.label.retention.playersRegisteredInTime')}</label>
<BasicDropdown selected={selectedGroup} options={groupOptions} onChange={setSelectedGroup}/>
</Col>
<Col>
<label>{t('html.label.retention.groupByTime')}</label>
<BasicDropdown selected={selectedGroupBy} options={groupByOptions}
onChange={setSelectedGroupBy}/>
</Col>
<Col>
<label>{t('html.label.xAxis')}</label>
<BasicDropdown selected={selectedAxis} options={axisOptions} onChange={setSelectedAxis}/>
</Col>
<Col>
<label>{t('html.label.yAxis')}</label>
<BasicDropdown selected={selectedYAxis} options={yAxisOptions} onChange={setSelectedYAxis}/>
</Col>
</Row>
<hr/>
{(selectedAxis !== 'date' && selectedAxis !== 'deltas') &&
<FunctionPlotGraph id={'retention-graph'} options={graphOptions} tall/>}
{(selectedAxis === 'date' || selectedAxis === 'deltas') &&
<LineGraph id={'retention-graph'} options={graphOptions} tall/>}
</ExtendableCardBody>
</Card>
)
};
export default PlayerRetentionGraphCard