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} />