diff --git a/i18n/USen.json b/i18n/USen.json
index 4f5ea42..3ff78b2 100644
--- a/i18n/USen.json
+++ b/i18n/USen.json
@@ -92,16 +92,22 @@
"modes": "Modes",
"region": "Region",
"column_total_x_power_title": "Total X Power",
+ "no_data": "No data available",
+ "errors.503": "Service Unavailable, please try again later."
+ },
+ "weapon_leaderboard": {
"weapon_select_main": "Weapon Select",
"weapon_select_alt": "Weapon Select (Alt)",
"weapon_title": "Top Weapon Wielders",
"threshold_select": "Minimum Percent Usage",
- "weapon_leaderboard.peak_x_power": "Peak X Power",
- "weapon_leaderboard.final_x_power": "Season End X Power",
+ "peak_x_power": "Peak X Power",
+ "final_x_power": "Season End X Power",
+ "show_all": "Multiple Entries Per Player",
+ "dedupe_data": "Best Entry Per Player",
+ "select_season": "Select Season",
+ "all_seasons": "All Seasons",
"select_weapon": "Select Weapon",
- "null_selection": "No Alt Weapon",
- "no_data": "No data available",
- "errors.503": "Service Unavailable, please try again later."
+ "null_selection": "No Alt Weapon"
},
"navigation": {
"top500": "Top 500",
diff --git a/src/react_app/src/components/leaderboards_components/season_selector.jsx b/src/react_app/src/components/leaderboards_components/season_selector.jsx
new file mode 100644
index 0000000..7f7beeb
--- /dev/null
+++ b/src/react_app/src/components/leaderboards_components/season_selector.jsx
@@ -0,0 +1,94 @@
+import React, { useState, useEffect, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { calculateSeasonNow, getSeasonName } from "../utils/season_utils";
+import { FaTimes } from "react-icons/fa";
+
+const SeasonSelector = ({ selectedSeason, setSelectedSeason }) => {
+ const { t } = useTranslation("weapon_leaderboard");
+ const { t: gameT } = useTranslation("game");
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ const currentSeason = calculateSeasonNow();
+ const seasons = Array.from({ length: currentSeason }, (_, i) => i + 1).sort(
+ (a, b) => a - b
+ );
+
+ const handleSeasonSelect = (season) => {
+ setSelectedSeason(season);
+ setIsOpen(false);
+ };
+
+ const handleClearSeason = (e) => {
+ e.stopPropagation();
+ setSelectedSeason(null);
+ };
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
+ const toggleDropdown = () => {
+ setIsOpen(!isOpen);
+ };
+
+ return (
+
+
+ {selectedSeason !== null ? (
+
+ {`${selectedSeason} ${getSeasonName(
+ selectedSeason,
+ gameT
+ )}`}
+
+
+ ) : (
+
{t("all_seasons")}
+ )}
+
+
+ {isOpen && (
+
+ {seasons.map((season) => (
+
handleSeasonSelect(season)}
+ >
+ {`${season} ${getSeasonName(season, gameT)}`}
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default SeasonSelector;
diff --git a/src/react_app/src/components/leaderboards_components/threshold_selector.jsx b/src/react_app/src/components/leaderboards_components/threshold_selector.jsx
index eb357ac..50aa068 100644
--- a/src/react_app/src/components/leaderboards_components/threshold_selector.jsx
+++ b/src/react_app/src/components/leaderboards_components/threshold_selector.jsx
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
const ThresholdSelector = ({ threshold, setThreshold }) => {
- const { t } = useTranslation();
+ const { t } = useTranslation("weapon_leaderboard");
const [isDragging, setIsDragging] = useState(false);
const [dragValue, setDragValue] = useState(threshold);
const containerRef = useRef(null);
diff --git a/src/react_app/src/components/leaderboards_components/weapon_controls.jsx b/src/react_app/src/components/leaderboards_components/weapon_controls.jsx
index a3c5551..646015a 100644
--- a/src/react_app/src/components/leaderboards_components/weapon_controls.jsx
+++ b/src/react_app/src/components/leaderboards_components/weapon_controls.jsx
@@ -12,6 +12,7 @@ const ModeSelector = React.lazy(() =>
);
const WeaponSelector = React.lazy(() => import("./weapon_selector"));
const ThresholdSelector = React.lazy(() => import("./threshold_selector"));
+const SeasonSelector = React.lazy(() => import("./season_selector"));
const WeaponLeaderboardControls = ({
selectedRegion,
@@ -27,8 +28,12 @@ const WeaponLeaderboardControls = ({
finalResults,
toggleFinalResults,
handleSwapWeapons,
+ dedupePlayers,
+ toggleDedupePlayers,
+ selectedSeason,
+ setSelectedSeason,
}) => {
- const { t } = useTranslation("main_page");
+ const { t } = useTranslation("weapon_leaderboard");
const { t: pl } = useTranslation("player");
const {
weaponTranslations,
@@ -126,7 +131,7 @@ const WeaponLeaderboardControls = ({
!finalResults ? "highlighted-option" : ""
}`}
>
- {t("weapon_leaderboard.peak_x_power")}
+ {t("peak_x_power")}
- {t("weapon_leaderboard.final_x_power")}
+ {t("final_x_power")}
+
+
+
+
);
diff --git a/src/react_app/src/components/leaderboards_components/weapon_selector.jsx b/src/react_app/src/components/leaderboards_components/weapon_selector.jsx
index ace861a..322faa4 100644
--- a/src/react_app/src/components/leaderboards_components/weapon_selector.jsx
+++ b/src/react_app/src/components/leaderboards_components/weapon_selector.jsx
@@ -13,7 +13,7 @@ const WeaponSelector = ({
initialWeaponId,
allowNull = false,
}) => {
- const { t } = useTranslation("main_page");
+ const { t } = useTranslation("weapon_leaderboard");
const [selectedWeapon, setSelectedWeapon] = useState(
initialWeaponId !== undefined && initialWeaponId !== null
? initialWeaponId.toString()
diff --git a/src/react_app/src/components/player_components/season_results.jsx b/src/react_app/src/components/player_components/season_results.jsx
index df4ea04..01b8366 100644
--- a/src/react_app/src/components/player_components/season_results.jsx
+++ b/src/react_app/src/components/player_components/season_results.jsx
@@ -1,5 +1,5 @@
import React, { useState } from "react";
-import { getSeasonName, calculateSeasonNow } from "./xchart_helper_functions";
+import { getSeasonName, calculateSeasonNow } from "../utils/season_utils";
import { getImageFromId } from "./weapon_helper_functions";
import { useTranslation } from "react-i18next";
import SplatZonesIcon from "../../assets/icons/splat_zones.png";
diff --git a/src/react_app/src/components/player_components/season_selector.jsx b/src/react_app/src/components/player_components/season_selector.jsx
index e8b7181..918653e 100644
--- a/src/react_app/src/components/player_components/season_selector.jsx
+++ b/src/react_app/src/components/player_components/season_selector.jsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
-import { calculateSeasonNow, getSeasonName } from "./xchart_helper_functions";
+import { calculateSeasonNow, getSeasonName } from "../utils/season_utils";
import { useTranslation } from "react-i18next";
function SeasonSelector({ data, mode, onSeasonChange }) {
diff --git a/src/react_app/src/components/player_components/xchart.jsx b/src/react_app/src/components/player_components/xchart.jsx
index 4238a3e..d9a4141 100644
--- a/src/react_app/src/components/player_components/xchart.jsx
+++ b/src/react_app/src/components/player_components/xchart.jsx
@@ -1,10 +1,9 @@
import React, { useState, useEffect } from "react";
import HighchartsReact from "highcharts-react-official";
import Highcharts from "highcharts/highstock";
+import { getPercentageInSeason, getSeasonName } from "../utils/season_utils";
import {
- getPercentageInSeason,
filterAndProcessData,
- getSeasonName,
getSeasonColor,
getAccessibleColor,
getDefaultWidth,
diff --git a/src/react_app/src/components/player_components/xchart_helper_functions.js b/src/react_app/src/components/player_components/xchart_helper_functions.js
index e5e7baa..fcca3ab 100644
--- a/src/react_app/src/components/player_components/xchart_helper_functions.js
+++ b/src/react_app/src/components/player_components/xchart_helper_functions.js
@@ -1,48 +1,9 @@
-const getSeasonStartDate = (season) => {
- const yearOffset = Math.floor((season - 1) / 4);
- const monthIndex = (season - 1) % 4;
- const monthMap = [11, 2, 5, 8]; // December, March, June, September
- const year = monthIndex === 0 ? 2023 + yearOffset - 1 : 2023 + yearOffset;
- return new Date(Date.UTC(year, monthMap[monthIndex], 1));
-};
-
-const getSeasonEndDate = (season) => {
- const nextSeasonStart = getSeasonStartDate(season + 1);
- return new Date(
- Date.UTC(
- nextSeasonStart.getUTCFullYear(),
- nextSeasonStart.getUTCMonth(),
- nextSeasonStart.getUTCDate(),
- 0,
- 0,
- 0,
- nextSeasonStart.getUTCMilliseconds() - 1000
- )
- );
-};
-
-const getPercentageInSeason = (timestamp, season) => {
- const seasonStart = getSeasonStartDate(season);
- const seasonEnd = getSeasonEndDate(season);
- const totalDuration = seasonEnd - seasonStart;
- const elapsedDuration = new Date(timestamp) - seasonStart;
- return (elapsedDuration / totalDuration) * 100;
-};
-
-const calculateSeasonNow = () => {
- const now_utc = new Date();
- return calculateSeasonByTimestamp(now_utc);
-};
-
-const calculateSeasonByTimestamp = (timestamp) => {
- const timestamp_utc = new Date(timestamp);
- const timestamp_utc_month = (timestamp_utc.getUTCMonth() + 1) % 12;
- const timestamp_utc_year =
- timestamp_utc.getUTCFullYear() + (timestamp_utc_month === 0 ? 1 : 0);
- return (
- 4 * (timestamp_utc_year - 2022) + Math.floor(timestamp_utc_month / 3) - 3
- );
-};
+import {
+ getPercentageInSeason,
+ calculateSeasonNow,
+ calculateSeasonByTimestamp,
+ getSeasonName,
+} from "../utils/season_utils";
const dataWithNulls = (data, threshold, festsForSeason) => {
const result = [];
@@ -135,16 +96,6 @@ function filterAndProcessData(
};
}
-const getSeasonName = (season_number, t) => {
- const season_offset = season_number + 2;
- const season_index = season_offset % 4;
- const year = 2022 + Math.floor(season_offset / 4);
- const season_names = [t("spring"), t("summer"), t("autumn"), t("winter")];
- return t("format_short")
- .replace("%SEASON%", season_names[season_index])
- .replace("%YEAR%", year);
-};
-
const getSeasonColor = (season_number, isCurrent) => {
const saturation = 100;
const baseLightness = 25;
@@ -196,11 +147,6 @@ const getAvailableModes = (data) => {
};
export {
- getSeasonStartDate,
- getSeasonEndDate,
- getPercentageInSeason,
- calculateSeasonNow,
- calculateSeasonByTimestamp,
dataWithNulls,
filterAndProcessData,
getSeasonName,
diff --git a/src/react_app/src/components/utils/season_utils.js b/src/react_app/src/components/utils/season_utils.js
new file mode 100644
index 0000000..f810f5e
--- /dev/null
+++ b/src/react_app/src/components/utils/season_utils.js
@@ -0,0 +1,64 @@
+const getSeasonStartDate = (season) => {
+ const yearOffset = Math.floor((season - 1) / 4);
+ const monthIndex = (season - 1) % 4;
+ const monthMap = [11, 2, 5, 8]; // December, March, June, September
+ const year = monthIndex === 0 ? 2023 + yearOffset - 1 : 2023 + yearOffset;
+ return new Date(Date.UTC(year, monthMap[monthIndex], 1));
+};
+
+const getSeasonEndDate = (season) => {
+ const nextSeasonStart = getSeasonStartDate(season + 1);
+ return new Date(
+ Date.UTC(
+ nextSeasonStart.getUTCFullYear(),
+ nextSeasonStart.getUTCMonth(),
+ nextSeasonStart.getUTCDate(),
+ 0,
+ 0,
+ 0,
+ nextSeasonStart.getUTCMilliseconds() - 1000
+ )
+ );
+};
+
+const getPercentageInSeason = (timestamp, season) => {
+ const seasonStart = getSeasonStartDate(season);
+ const seasonEnd = getSeasonEndDate(season);
+ const totalDuration = seasonEnd - seasonStart;
+ const elapsedDuration = new Date(timestamp) - seasonStart;
+ return (elapsedDuration / totalDuration) * 100;
+};
+
+const calculateSeasonNow = () => {
+ const now_utc = new Date();
+ return calculateSeasonByTimestamp(now_utc);
+};
+
+const calculateSeasonByTimestamp = (timestamp) => {
+ const timestamp_utc = new Date(timestamp);
+ const timestamp_utc_month = (timestamp_utc.getUTCMonth() + 1) % 12;
+ const timestamp_utc_year =
+ timestamp_utc.getUTCFullYear() + (timestamp_utc_month === 0 ? 1 : 0);
+ return (
+ 4 * (timestamp_utc_year - 2022) + Math.floor(timestamp_utc_month / 3) - 3
+ );
+};
+
+const getSeasonName = (season_number, t) => {
+ const season_offset = season_number + 2;
+ const season_index = season_offset % 4;
+ const year = 2022 + Math.floor(season_offset / 4);
+ const season_names = [t("spring"), t("summer"), t("autumn"), t("winter")];
+ return t("format_short")
+ .replace("%SEASON%", season_names[season_index])
+ .replace("%YEAR%", year);
+};
+
+export {
+ getSeasonStartDate,
+ getSeasonEndDate,
+ getPercentageInSeason,
+ calculateSeasonNow,
+ calculateSeasonByTimestamp,
+ getSeasonName,
+};
diff --git a/src/react_app/src/components/weapon_leaderboard.jsx b/src/react_app/src/components/weapon_leaderboard.jsx
index 321f3b8..1e62118 100644
--- a/src/react_app/src/components/weapon_leaderboard.jsx
+++ b/src/react_app/src/components/weapon_leaderboard.jsx
@@ -30,7 +30,8 @@ const useFetchWeaponLeaderboardData = (
weaponId,
additionalWeaponId,
threshold,
- finalResults = false
+ finalResults = false,
+ selectedSeason
) => {
const apiUrl = getBaseApiUrl();
const pathUrl = `/api/weapon_leaderboard/${weaponId}`;
@@ -39,6 +40,7 @@ const useFetchWeaponLeaderboardData = (
region: selectedRegion,
min_threshold: threshold,
final_results: finalResults,
+ season: selectedSeason,
};
if (additionalWeaponId !== null) {
@@ -60,7 +62,8 @@ const processWeaponLeaderboardData = (
weaponId,
additionalWeaponId,
weaponReferenceData,
- finalResults = false
+ finalResults = false,
+ dedupePlayers = false
) => {
if (!data) return [];
const playersArray = Object.keys(data.players).reduce((acc, key) => {
@@ -87,11 +90,24 @@ const processWeaponLeaderboardData = (
});
playersArray.sort((a, b) => b.max_x_power - a.max_x_power);
- playersArray.forEach((player, index) => {
+
+ let processedPlayers = playersArray;
+ if (dedupePlayers) {
+ const seenPlayerIds = new Set();
+ processedPlayers = playersArray.filter((player) => {
+ if (seenPlayerIds.has(player.player_id)) {
+ return false;
+ }
+ seenPlayerIds.add(player.player_id);
+ return true;
+ });
+ }
+
+ processedPlayers.forEach((player, index) => {
player.rank = index + 1;
});
- return playersArray.slice(0, 500);
+ return processedPlayers.slice(0, 500);
};
const useWeaponLeaderboardData = (
@@ -101,7 +117,9 @@ const useWeaponLeaderboardData = (
additionalWeaponId,
weaponReferenceData,
threshold,
- finalResults = false
+ finalResults = false,
+ dedupePlayers = false,
+ selectedSeason
) => {
const weaponSetKey = useMemo(() => {
const weaponSet = new Set([weaponId, additionalWeaponId]);
@@ -114,7 +132,8 @@ const useWeaponLeaderboardData = (
weaponId,
additionalWeaponId,
threshold,
- finalResults
+ finalResults,
+ selectedSeason
);
const players = useMemo(
@@ -124,16 +143,17 @@ const useWeaponLeaderboardData = (
weaponId,
additionalWeaponId,
weaponReferenceData,
- finalResults
+ finalResults,
+ dedupePlayers
),
- [data, weaponSetKey, weaponReferenceData, finalResults] // eslint-disable-line react-hooks/exhaustive-deps
+ [data, weaponSetKey, weaponReferenceData, finalResults, dedupePlayers] // eslint-disable-line react-hooks/exhaustive-deps
);
return { players, error, isLoading };
};
const TopWeaponsContent = () => {
- const { t } = useTranslation("main_page");
+ const { t } = useTranslation("weapon_leaderboard");
const {
weaponReferenceData,
weaponTranslations,
@@ -152,8 +172,8 @@ const TopWeaponsContent = () => {
return cachedWeaponId !== null ? parseInt(cachedWeaponId) : 40;
});
const [additionalWeaponId, setAdditionalWeaponId] = useState(() => {
- const cached = getCache("weapons.additionalWeaponId");
- return cached ? parseInt(cached) : null;
+ const cachedWeaponId = getCache("weapons.additionalWeaponId");
+ return cachedWeaponId !== null ? parseInt(cachedWeaponId) : null;
});
const [threshold, setThreshold] = useState(() => {
return parseInt(getCache("weapons.threshold")) || 0;
@@ -164,6 +184,13 @@ const TopWeaponsContent = () => {
const [finalResults, setFinalResults] = useState(() => {
return getCache("weapons.finalResults") === "true";
});
+ const [dedupePlayers, setDedupePlayers] = useState(() => {
+ return getCache("weapons.dedupePlayers") === "true";
+ });
+ const [selectedSeason, setSelectedSeason] = useState(() => {
+ const cachedSeason = getCache("weapons.selectedSeason");
+ return cachedSeason !== null ? parseInt(cachedSeason) : null;
+ });
const itemsPerPage = 100;
useEffect(() => {
@@ -175,6 +202,8 @@ const TopWeaponsContent = () => {
setCache("weapons.threshold", threshold.toString());
setCache("weapons.currentPage", currentPage.toString());
setCache("weapons.finalResults", finalResults.toString());
+ setCache("weapons.dedupePlayers", dedupePlayers.toString());
+ setCache("weapons.selectedSeason", selectedSeason?.toString());
}, [
selectedRegion,
selectedMode,
@@ -183,6 +212,8 @@ const TopWeaponsContent = () => {
threshold,
currentPage,
finalResults,
+ dedupePlayers,
+ selectedSeason,
]);
const { players, error, isLoading } = useWeaponLeaderboardData(
@@ -192,7 +223,9 @@ const TopWeaponsContent = () => {
additionalWeaponId,
weaponReferenceData,
threshold,
- finalResults
+ finalResults,
+ dedupePlayers,
+ selectedSeason
);
const indexOfLastItem = currentPage * itemsPerPage;
@@ -210,6 +243,10 @@ const TopWeaponsContent = () => {
setFinalResults((prev) => !prev);
}, []);
+ const toggleDedupePlayers = useCallback(() => {
+ setDedupePlayers((prev) => !prev);
+ }, []);
+
const handleSwapWeapons = () => {
const tempWeaponId = weaponId;
setWeaponId(additionalWeaponId);
@@ -250,6 +287,10 @@ const TopWeaponsContent = () => {
weaponReferenceData={weaponReferenceData}
weaponTranslations={weaponTranslations}
handleSwapWeapons={handleSwapWeapons}
+ dedupePlayers={dedupePlayers}
+ toggleDedupePlayers={toggleDedupePlayers}
+ selectedSeason={selectedSeason}
+ setSelectedSeason={setSelectedSeason}
/>