diff --git a/public/assets/images/dota2/role-icons.png b/public/assets/images/dota2/role-icons.png new file mode 100644 index 0000000000..9758117f8c Binary files /dev/null and b/public/assets/images/dota2/role-icons.png differ diff --git a/src/actions/analyzeMatch.ts b/src/actions/analyzeMatch.ts index e9a1fef21e..cfe1db07be 100644 --- a/src/actions/analyzeMatch.ts +++ b/src/actions/analyzeMatch.ts @@ -2,6 +2,48 @@ import { skillshots } from "dotaconstants"; import { isSupport, getObsWardsPlaced, isRoshHero } from "../utility"; import store from "../store"; +/** + * Derives player advice using the active language dictionary from the store. + * @param match Match data used to evaluate advice checks. + * @param _pm Player data evaluated by each check. + * @returns An object keyed by check name: eff, farm_drought, skillshot, late_courier, + * wards, roshan, and rune_control. Each result contains a translated name, measured + * value, validity flag, reference target (top), score function, and optional grade flag. + * All checks are returned, including invalid ones. Score functions are not called here. + * @example + * ```ts + * const match = { duration: 1200 }; + * const _pm = { + * hero_id: 1, + * lane_role: 2, + * lane_efficiency: 0.65, + * gold_t: Array.from({ length: 12 }, (_, minute) => minute * 300), + * obs_log: [], + * purchase: {}, + * first_purchase_time: { flying_courier: 300 }, + * runes: { 0: 5 }, + * }; + * ``` + * With the store’s active language set to English: + * ```ts + * const advice = analyzeMatch(match, _pm); + * advice.eff; // { grade: true, name: "Lane efficiency", value: 0.65, top: 0.6, valid: true, score(raw) { return raw; } } + * advice.farm_drought.value; // 300 + * advice.farm_drought.valid; // true + * advice.skillshot.value; // undefined + * advice.skillshot.valid; // false + * advice.late_courier.value; // 120 + * advice.wards.value; // 0 + * advice.wards.valid; // false + * advice.roshan.value; // 0 + * advice.roshan.valid; // false + * advice.rune_control.value; // 5 + * advice.rune_control.top; // 5 + * advice.rune_control.valid; // true + * advice.eff.score(advice.eff.value); // 0.65 + * advice.rune_control.score(advice.rune_control.value); // 1 + * ``` + */ export default function analyzeMatch(match: any, _pm: any) { const { strings } = store.getState().app; // define condition check for each advice point diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx index f0d126ea46..70ec47a12d 100644 --- a/src/components/App/App.tsx +++ b/src/components/App/App.tsx @@ -32,6 +32,11 @@ const Explorer = React.lazy(() => import("../Explorer/Explorer")); const darkTheme = createTheme({ palette: { mode: "dark", + primary: { main: "#C8A45D", light: "#E0BF7D", dark: "#92733B" }, + secondary: { main: "#B8A4D9" }, + background: { default: "#101012", paper: "#1B1B1F" }, + text: { primary: "#E8E2D6", secondary: "#A6A29A" }, + divider: "#443A28", }, components: { MuiButton: { @@ -122,7 +127,7 @@ const Back2Top = styled.button` transition: opacity 0.3s ease-in-out; &:hover { - background-color: rgb(26, 108, 239); + background-color: var(--od-accent-hover); } #back2TopTxt { diff --git a/src/components/Container/Container.tsx b/src/components/Container/Container.tsx index d5250cd1bc..205f08666c 100644 --- a/src/components/Container/Container.tsx +++ b/src/components/Container/Container.tsx @@ -6,6 +6,7 @@ import { Spacer } from "../Spacer/Spacer"; type ContainerProps = { title?: string; + titleActions?: React.ReactNode; subtitle?: string | React.ReactNode; style?: any; className?: string; @@ -21,6 +22,7 @@ type ContainerProps = { const Container = ({ title, + titleActions, subtitle, style, className, @@ -35,7 +37,12 @@ const Container = ({ }: ContainerProps) => !hide ? (
- {title && } + {title && (titleActions ? ( +
+ + {titleActions} +
+ ) : )} {error && ( <> diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 12967f7949..db2a50790f 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -15,7 +15,7 @@ import SearchIcon from "@mui/icons-material/Search"; import { Toolbar } from "@mui/material"; import React, { useCallback, useEffect, useState, useRef } from "react"; import { useSelector } from "react-redux"; -import { Link } from "react-router-dom"; +import { NavLink } from "react-router-dom"; import styled from "styled-components"; import config from "../../config"; @@ -105,9 +105,17 @@ const MenuLogoWrapper = styled.div` padding: 24px 0; `; -const DrawerLink = styled(Link)` +const DrawerLink = styled(NavLink)` color: ${constants.textColorPrimary}; + &.active { + color: ${constants.primaryLinkColor}; + } + &.active li { + background: var(--od-accent-soft); + box-shadow: inset 3px 0 0 ${constants.primaryLinkColor}; + } + & li:hover { background-color: rgba(0, 0, 0, 0.08); transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; @@ -115,13 +123,25 @@ const DrawerLink = styled(Link)` span { font-family: ${constants.fontFamilyFuturistic}; - font-size: ${constants.fontSizeSmall}; + font-size: ${constants.fontSizeMedium}; } `; -const LinkGroupLink = styled(Link)` +const LinkGroupLink = styled(NavLink)` font-family: ${constants.fontFamilyFuturistic}; - font-size: ${constants.fontSizeSmall} !important; + font-size: ${constants.fontSizeMedium} !important; + padding: 8px 0; + border-bottom: 2px solid transparent; + + &.active { + color: ${constants.primaryLinkColor}; + border-bottom-color: ${constants.primaryLinkColor}; + } + + &.active:hover { + color: ${constants.primaryLinkColor}; + opacity: 1; + } `; const LinkGroup = ({ navbarPages }: { navbarPages: any[] }) => ( diff --git a/src/components/Home/Why.tsx b/src/components/Home/Why.tsx index eb219ad573..c328811045 100644 --- a/src/components/Home/Why.tsx +++ b/src/components/Home/Why.tsx @@ -32,7 +32,7 @@ const StyledDiv = styled.div` & svg { width: 48px; height: 48px; - fill: ${constants.colorBlue}; + fill: ${constants.colorGolden}; } & .headline { diff --git a/src/components/LoadingOverlay/LoadingOverlayBase.tsx b/src/components/LoadingOverlay/LoadingOverlayBase.tsx index 66b0321bfb..5e53984ba2 100644 --- a/src/components/LoadingOverlay/LoadingOverlayBase.tsx +++ b/src/components/LoadingOverlay/LoadingOverlayBase.tsx @@ -3,12 +3,14 @@ import Spinner from "../Spinner/Spinner"; interface LoadingOverlayBaseProps { text?: string; + textSize?: number; paddingTop?: string; alignItems?: "center" | "flex-start" | "flex-end"; } const LoadingOverlayBase: React.FC = ({ text, + textSize, paddingTop = "30%", alignItems = "flex-start", }) => ( @@ -21,7 +23,7 @@ const LoadingOverlayBase: React.FC = ({ justifyContent: "center", }} > - +
); diff --git a/src/components/LoadingOverlay/LoadingOverlayUpper30.tsx b/src/components/LoadingOverlay/LoadingOverlayUpper30.tsx index 29edeaf03b..d204e6af09 100644 --- a/src/components/LoadingOverlay/LoadingOverlayUpper30.tsx +++ b/src/components/LoadingOverlay/LoadingOverlayUpper30.tsx @@ -3,10 +3,12 @@ import { LoadingOverlayBase } from "./LoadingOverlayBase"; interface LoadingOverlayUpper30Props { text?: string; + textSize?: number; } const LoadingOverlayUpper30: React.FC = ({ text, -}) => ; + textSize, +}) => ; export { LoadingOverlayUpper30 }; diff --git a/src/components/Match/Laning/Graph.tsx b/src/components/Match/Laning/Graph.tsx index 40e2a84b8a..5c40188b45 100644 --- a/src/components/Match/Laning/Graph.tsx +++ b/src/components/Match/Laning/Graph.tsx @@ -1,199 +1,191 @@ -import { heroes, player_colors as playerColors } from "dotaconstants"; -import React from "react"; -import { - Brush, - CartesianGrid, - Legend, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; +/** + * @file Graph. + * @description Shows how each hero’s net worth or level changes over time. Filter by lane or role, select heroes in the legend, and save your tooltip preferences. + */ -import { getHeroIconUrlFromHeroKey } from "../../../utility"; -import Heading from "../../Heading/Heading"; -import { - StyledCustomizedTooltip, - StyledHolder, -} from "../../Visualizations/Graph/Styled"; +import { heroes, player_colors as playerColors } from "dotaconstants"; +import React, { useState } from "react"; +import { Switch } from "@mui/material"; +import { TooltipTotalToggle, RoleCaption, NoMatchingPlayers } from "./StyledGraph"; +import HeroLegendLabel from "../../Visualizations/Graph/HeroLegendLabel"; +import { Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import useStrings from "../../../hooks/useStrings.hook"; +import { localStorageKeys } from "../../../utility"; +import useLocalStorageBoolean from "../../../hooks/useLocalStorageBoolean"; +import Heading from "../../Heading/Heading"; import { Spacer } from "../../Spacer/Spacer"; import constants from "../../constants"; import Grid from "../../Visualizations/Graph/Grid"; +import { StyledHolder } from "../../Visualizations/Graph/Styled"; +import LaningPhaseCaption from "../../Visualizations/Graph/LaningPhaseCaption"; +import { GraphMetric, GraphGrouping, RoleSelection, getPlayerRoles, filterGraphPlayers, buildGraphData } from "./laning.utils"; +import GraphTooltip from "./GraphTooltip"; +import GraphFilters from "./GraphFilters"; +import GraphCaption from "./GraphCaption"; -const formatGraphTime = (minutes: number) => `${minutes}:00`; - -// asdf -const CustomizedTooltip = ({ - label, - payload, -}: { - label?: string; - payload?: any[]; -}) => { - const origOrderMap: Record = {}; - payload?.forEach((p, i) => { - origOrderMap[p.dataKey] = i; - }); - return ( - -
{label}
- {payload - ?.sort((a, b) => b.value - a.value) - ?.map((data, i) => ( -
- {data.dataKey}: {data.value} -
- ))} -
- ); -}; - -const CustomizedDot = (props: { - cx?: number; - cy?: number; - payload?: any; - killsLog: any[]; -}) => { - const { cx, cy, payload, killsLog } = props; - - const kills = killsLog.filter((l) => { - const t = (l.time - (l.time % 60)) / 60; - return formatGraphTime(t) === payload.time; - }); - - if (kills.length > 0) { - return ( - - {kills.map((k, i) => ( - - ))} - - ); - } - - return ; -}; type GraphProps = { match: Match; - selectedPlayer: number; + selectedPlayer?: number; + maxMinute?: number; + metric: GraphMetric; }; -const Graph = (props: GraphProps) => { +/** + * Renders a player net worth or level chart with role/lane filtering and legend selection. + * Tooltip preferences are persisted in localStorage. The timeline defaults to ten minutes; + * the Graphs page supplies the full match duration instead. + * @param props Match data, metric, optional selected player, and inclusive minute limit. + * @returns The interactive chart, or null when no timeline data is available. + */ +const Graph = ({ selectedPlayer, match, metric, maxMinute = 10 }: GraphProps) => { const strings = useStrings(); - const { selectedPlayer, match } = props; - const matchData: any[] = []; + const [showAdvantageSummary, setShowAdvantageSummary] = useLocalStorageBoolean(localStorageKeys.graphTooltipAdvantage); + const [showTooltipTeamTotals, setShowTooltipTeamTotals] = useLocalStorageBoolean( + metric === "networth" ? localStorageKeys.graphTooltipTeamNetWorth : localStorageKeys.graphTooltipTeamLevels, + ); + const [focusedHeroSelection, setFocusedHeroSelection] = useState(null); + const [hoverHero, setHoverHero] = useState(null); + const [groupBy, setGroupBy] = useState("role"); + const [lane, setLane] = useState("all"); + const [role, setRole] = useState("all"); const { players } = match; + const playerRoles = getPlayerRoles(players); + const visiblePlayers = filterGraphPlayers(players, playerRoles, groupBy, lane, role); + const matchData = buildGraphData(match, visiblePlayers, metric, maxMinute); + if(!matchData.length) return null; - if (players[0] && players[0].cs_t) { - players[0].lh_t.forEach((value, index) => { - if (index <= Math.floor(match.duration / 60)) { - const obj: Record = { time: formatGraphTime(index) }; - players.forEach((player) => { - const hero = heroes[player.hero_id] || {}; - obj[hero.localized_name] = player.cs_t[index]; - }); - matchData.push(obj); - } - }); - - return ( - - - - - + + + + setShowAdvantageSummary(event.target.checked)} + /> + {strings.laning_show_tooltip_advantage} + + + setShowTooltipTeamTotals(event.target.checked)} + /> + {(metric === "networth" ? strings.laning_show_tooltip_networth : strings.laning_show_tooltip_levels)} + + + + {visiblePlayers.length === 0 && ( + {strings.laning_empty} + )} + + + - - - - } /> - {match.players.map((player) => { - const hero = heroes[player.hero_id] || {}; - const playerColor = - playerColors[ - player.player_slot as unknown as keyof typeof playerColors - ]; - const isSelected = selectedPlayer === player.player_slot; - const opacity = isSelected ? 1 : 0.25; - const stroke = isSelected ? 4 : 2; - return ( - - ) : ( - false - ) - } - dataKey={hero.localized_name} - key={hero.localized_name} - stroke={playerColor} - strokeWidth={stroke} - strokeOpacity={opacity} - name={hero.localized_name} - /> - ); - })} - - - - - - ); - } - - return null; + axisLine={false} + tickLine={false} + /> + value.toLocaleString("en-US") : undefined} + allowDecimals={metric !== "levels"} + mirror + tick={{ + fontFamily: constants.fontFamilySerif, + fontSize: 10, + fill: constants.colorGreyMuted, + }} + axisLine={false} + tickLine={false} + /> + + + } + wrapperStyle={{ zIndex: 1000 }} + reverseDirection={{ x: true, y: false }} + allowEscapeViewBox={{ x: false, y: true }} + /> + {visiblePlayers.map((player) => { + const hero = heroes[player.hero_id] || {}; + const playerColor = + playerColors[ + player.player_slot as unknown as keyof typeof playerColors + ]; + const focusedHero = hoverHero ?? focusedHeroSelection; + const isSelected = focusedHero + ? focusedHero === hero.localized_name + : selectedPlayer === player.player_slot; + const opacity = !focusedHero || focusedHero === hero.localized_name ? 1 : 0.25; + const stroke = isSelected ? 2 : 1; + return ( + + ); + })} + { + const hero = String(entry.dataKey); + setFocusedHeroSelection((current) => current === hero ? null : hero); + }} + onMouseEnter={(entry) => setHoverHero(String(entry.dataKey))} + onMouseLeave={() => setHoverHero(null)} + formatter={(name) => } + wrapperStyle={{ + fontFamily: constants.fontFamilyFuturistic, + cursor: "pointer", + fontSize: 11, + color: constants.colorGreyMuted, + }} + /> + + + {strings.laning_level_legend_hint} + {metric === "levels" && } + + ); }; export default Graph; diff --git a/src/components/Match/Laning/GraphCaption.tsx b/src/components/Match/Laning/GraphCaption.tsx new file mode 100644 index 0000000000..ee52953848 --- /dev/null +++ b/src/components/Match/Laning/GraphCaption.tsx @@ -0,0 +1,248 @@ +/** + * @file Graph Caption. + * @description Builds localized lane and role result captions from end-of-laning stats and links to the level calculation source. + */ + +import { formatTemplateToString } from "../../../utility"; +import React from "react"; +import { heroes } from "dotaconstants"; +import TrendingUpIcon from "@mui/icons-material/TrendingUp"; +import { IconRadiant, IconDire } from "../../Icons"; +import AccessTimeIcon from "@mui/icons-material/AccessTime"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import MuiTooltip from "@mui/material/Tooltip"; +import useStrings from "../../../hooks/useStrings.hook"; +import { getHeroLevel } from "../../Visualizations/Graph/data"; +import { getRoleLabels, GraphMetric, GraphGrouping, RoleSelection } from "./laning.utils"; +import { RoleCaption, LevelCaption, LevelSourceLink } from "./StyledGraph"; + + +type GraphCaptionProps = { + match: Match; + metric: GraphMetric; + groupBy: GraphGrouping; + lane: string; + role: RoleSelection; + visiblePlayers: MatchPlayer[]; +}; + +type HistoryField = "gold_t" | "xp_t"; + +/** + * Adds a team's sampled values at a given minute, optionally transforming each value. + * + * Validate sample availability before calling: missing values must not count as zero. + * @param players Players belonging to one side of the comparison. + * @param field Gold or XP history to read. + * @param minute Index of the minute sample, starting at minute zero. + * @param transformValue Converts each sample before summing, such as XP to hero level. + * @returns The combined value for the supplied players. + */ +const sumAtMinute = ( + players: MatchPlayer[], + field: HistoryField, + minute: number, + transformValue: (value: number) => number = (value) => value, +) => players.reduce( + (total, player) => total + transformValue(player[field][minute]), + 0, +); + +/** + * Describes the selected lane or role at the end of the first ten minutes. + * Uses the last completed minute for shorter matches and localized fallback text + * when a comparison lacks data. Level captions also link to the XP threshold source. + * @returns A caption, or null for a net worth graph grouped by role. + */ +const GraphCaption = ({ + match, + metric, + groupBy, + lane, + role, + visiblePlayers, +}: GraphCaptionProps) => { + const strings = useStrings(); + const captionParts: React.ReactNode[] = []; + let hasXpValue = false; + /** + * Formats a translated caption and keeps styled time, team, and XP values inline. + * @param template Message from the active language dictionary. + * @param values Named placeholder replacements. + * @returns Caption text with markers for the styled values rendered below. + */ + const formatCaption = (template: string, values: Record = {}) => { + if (metric !== "levels") return formatTemplateToString(template, values); + const formattedValues = { ...values }; + Object.entries(values).forEach(([name, value]) => { + let content: React.ReactNode; + if (name === "minute") { + content = ; + } else if (["team", "radiant", "dire"].includes(name)) { + content = + {value === strings.general_radiant ? : }{value} + ; + } else if (name === "xp") { + hasXpValue = true; + content = + ; + } else return; + formattedValues[name] = `\uE000${captionParts.length}\uE001`; + captionParts.push(content); + }); + const text = formatTemplateToString(template, formattedValues); + return values.xp === undefined ? text : text.replace(`+${formattedValues.xp}`, String(formattedValues.xp)); + }; + const roleLabels = getRoleLabels(strings); + + if (metric !== "levels" && groupBy !== "lane") return null; + + // Summarize the end of the laning window, or the final minute of a shorter match. + const summaryMinute = Math.min(10, Math.floor(match.duration / 60)); + const radiantPlayers = visiblePlayers.filter((player) => player.isRadiant); + const direPlayers = visiblePlayers.filter((player) => !player.isRadiant); + + /** + * Checks that both sides have finite samples at the summary minute. + * @param field History required by the comparison. + * @param comparingAllPlayers Whether both teams must contain all five players. + * @returns Whether totals can be calculated without treating missing data as zero. + */ + const hasComparisonData = (field: HistoryField, comparingAllPlayers: boolean) => { + if (radiantPlayers.length === 0 || direPlayers.length === 0) return false; + + // Whole-team totals require all five players; lane and role groups can be smaller. + if (comparingAllPlayers && (radiantPlayers.length !== 5 || direPlayers.length !== 5)) { + return false; + } + + return visiblePlayers.every((player) => + Number.isFinite(player[field]?.[summaryMinute]), + ); + }; + + /** + * Compares combined gold or combined hero levels for the selected physical lane. + * Each hero's XP is converted to a level before calculating the lane's level total. + * @returns Localized team totals and the lead margin, tie, or missing-data message. + * @example + * // English net worth caption for a selected lane: + * // "At minute 10: Radiant 12,000 vs Dire 10,500 gold. Radiant +1,500 gold." + * @example + * // English levels caption for a selected lane: + * // "At minute 10: Radiant 15 vs Dire 13 combined levels. Radiant +2 levels." + */ + const getLaneCaption = () => { + const isNetWorthGraph = metric === "networth"; + const historyField = isNetWorthGraph ? "gold_t" : "xp_t"; + if (!hasComparisonData(historyField, lane === "all")) { + return formatCaption(strings.laning_insufficient, { minute: summaryMinute }); + } + + // Convert each hero's XP to a level before adding levels together. + // Converting the lane's combined XP would describe a single imaginary hero. + const valueToAdd = isNetWorthGraph ? (value: number) => value : getHeroLevel; + const radiantTotal = sumAtMinute(radiantPlayers, historyField, summaryMinute, valueToAdd); + const direTotal = sumAtMinute(direPlayers, historyField, summaryMinute, valueToAdd); + const signedDifference = radiantTotal - direTotal; + const advantage = Math.abs(signedDifference); + const totalUnit = (isNetWorthGraph ? strings.laning_gold : strings.laning_combined); + const advantageUnit = (isNetWorthGraph ? strings.laning_gold : advantage === 1 ? strings.laning_level : strings.laning_levels); + + const result = signedDifference === 0 + ? formatCaption(strings.laning_equal, { unit: totalUnit }) + : formatCaption(strings.laning_lead_margin, { + team: signedDifference > 0 ? strings.general_radiant : strings.general_dire, + value: advantage.toLocaleString(), + unit: advantageUnit, + }); + + return formatCaption(strings.laning_lane_result, { + minute: summaryMinute, + radiant: strings.general_radiant, + dire: strings.general_dire, + r: radiantTotal.toLocaleString(), + d: direTotal.toLocaleString(), + unit: totalUnit, + result, + }); + }; + + /** + * Compares XP for matching roles, or total team XP when all roles are selected. + * XP determines the leader; a role comparison also reports the hero level gap. + * @returns A localized result, tie, or explanation of why comparison is unavailable. + * @example + * // English caption when filtering to carry: + * // "At minute 10, Radiant — Carry (Sniper) led by +1,635 XP; level difference: 2." + * @example + * // English caption when all roles are selected: + * // "At minute 10, Radiant led by +3,000 total XP." + * @example + * // Missing minute-10 samples: + * // "Insufficient data to compare at minute 10." + */ + const getLevelCaption = () => { + // Unknown roles cannot be reliably paired with an opposing player. + if (role === "unknown") return strings.laning_unknown_comparison; + if (!hasComparisonData("xp_t", role === "all")) { + return formatCaption(strings.laning_insufficient, { minute: summaryMinute }); + } + + const radiantXp = sumAtMinute(radiantPlayers, "xp_t", summaryMinute); + const direXp = sumAtMinute(direPlayers, "xp_t", summaryMinute); + const xpDifference = radiantXp - direXp; + if (xpDifference === 0) return formatCaption(strings.laning_tie, { minute: summaryMinute }); + + // XP determines the leader even when both heroes are still at the same level. + const radiantAhead = xpDifference > 0; + const leadingTeam = radiantAhead ? strings.general_radiant : strings.general_dire; + const xpAdvantage = Math.abs(xpDifference).toLocaleString(); + if (role === "all") { + return formatCaption(strings.laning_team_xp, { minute: summaryMinute, team: leadingTeam, xp: xpAdvantage }); + } + + // A determined role has one player per side, unlike a whole-team comparison. + const leadingPlayer = radiantAhead ? radiantPlayers[0] : direPlayers[0]; + const radiantLevels = sumAtMinute(radiantPlayers, "xp_t", summaryMinute, getHeroLevel); + const direLevels = sumAtMinute(direPlayers, "xp_t", summaryMinute, getHeroLevel); + return formatCaption(strings.laning_role_result, { + minute: summaryMinute, + team: leadingTeam, + role: roleLabels[role], + hero: heroes[leadingPlayer.hero_id]?.localized_name ?? strings.general_no_hero, + xp: xpAdvantage, + levels: Math.abs(radiantLevels - direLevels), + }); + }; + + const Caption = metric === "levels" ? LevelCaption : RoleCaption; + const caption = groupBy === "lane" ? getLaneCaption() : getLevelCaption(); + const captionContent = metric !== "levels" ? caption : caption.split(/(\uE000\d+\uE001|XP)/g).map((part, index) => ( + + {part.startsWith("\uE000") ? captionParts[Number(part.slice(1, -1))] + : part === "XP" ? {!hasXpValue && : part} + + )); + + return ( + + {captionContent} + {metric === "levels" && ( + + + + + + )} + + ); +}; + +export default GraphCaption; diff --git a/src/components/Match/Laning/GraphFilters.tsx b/src/components/Match/Laning/GraphFilters.tsx new file mode 100644 index 0000000000..62cce716e4 --- /dev/null +++ b/src/components/Match/Laning/GraphFilters.tsx @@ -0,0 +1,137 @@ +/** + * @file Graph Filters. + * @description Controlled radio filters for chart grouping, roles, and physical lanes, hiding unused undetermined categories. + */ + +import React from "react"; +import { PhysicalLane } from "../../../types/matchResponse"; +import useStrings from "../../../hooks/useStrings.hook"; +import { Spacer } from "../../Spacer/Spacer"; +import { + getRoleLabels, + GraphMetric, + GraphGrouping, + RoleSelection, + LaneRole, +} from "./laning.utils"; +import { RoleFilter, RoleOptions } from "./StyledGraph"; + +type GraphFiltersProps = { + metric: GraphMetric; + players: MatchPlayer[]; + playerRoles: Map; + groupBy: GraphGrouping; + setGroupBy: (value: GraphGrouping) => void; + lane: string; + setLane: (value: string) => void; + role: RoleSelection; + setRole: (value: RoleSelection) => void; +}; + +/** + * Renders radio controls for choosing a grouping mode and a lane or role. + * Undetermined options appear only when a player lacks that classification. + * @param props Current selections, player classifications, and selection callbacks. + * @returns Controlled filters; selection state belongs to the parent graph. + */ +const GraphFilters = ({ + metric, + players, + playerRoles, + groupBy, + setGroupBy, + lane, + setLane, + role, + setRole, +}: GraphFiltersProps) => { + const strings = useStrings(); + const roleLabels = getRoleLabels(strings); + const hasUndeterminedRoles = players.some( + (player) => !playerRoles.has(player.player_slot), + ); + const laneLabels: Record = { + [PhysicalLane.Bottom]: strings.laning_bottom, + [PhysicalLane.Middle]: strings.laning_mid, + [PhysicalLane.Top]: strings.laning_top, + [PhysicalLane.Jungle]: strings.laning_jungle, + }; + const hasUndeterminedLanes = players.some( + (player) => !laneLabels[String(player.lane)], + ); + return ( + <> + + {strings.laning_group} + + {(["role", "lane"] as const).map((value) => ( + + ))} + + + + {groupBy === "lane" ? ( + + {strings.laning_filter_lane} + + {[ + ["all", strings.laning_all_lanes], + ...Object.entries(laneLabels).filter( + ([value]) => value !== String(PhysicalLane.Jungle), + ), + ["unknown", strings.laning_unknown_lane], + ] + .filter(([value]) => value !== "unknown" || hasUndeterminedLanes) + .map(([value, label]) => ( + + ))} + + + ) : ( + + {strings.laning_filter_role} + + {( + [ + ["all", strings.laning_all_roles], + ...Object.entries(roleLabels), + ["unknown", strings.laning_unknown_role], + ] as [typeof role, string][] + ) + .filter(([value]) => value !== "unknown" || hasUndeterminedRoles) + .map(([value, label]) => ( + + ))} + + + )} + + ); +}; + +export default GraphFilters; diff --git a/src/components/Match/Laning/GraphTooltip.tsx b/src/components/Match/Laning/GraphTooltip.tsx new file mode 100644 index 0000000000..a4c361f904 --- /dev/null +++ b/src/components/Match/Laning/GraphTooltip.tsx @@ -0,0 +1,238 @@ +/** + * @file Graph Tooltip. + * @description Displays hovered hero values and optional role, lane, or team comparisons for Net Worth and Levels charts. + */ + +import { formatTemplateToString } from "../../../utility"; +import React from "react"; +import config from "../../../config"; +import AccessTimeIcon from "@mui/icons-material/AccessTime"; +import { heroes } from "dotaconstants"; +import useStrings from "../../../hooks/useStrings.hook"; +import TooltipMetricIcon from "../../Visualizations/Graph/TooltipMetricIcon"; +import { getHeroLevel } from "../../Visualizations/Graph/data"; +import { getLaningRoles, LaneRole, getRoleLabels } from "./laning.utils"; + +import { LaningTooltip, TooltipColumns, TooltipDifferences, DifferenceRow, DifferenceValue, TeamIndicator, TooltipTeamTotals } from "./StyledGraph"; + +/** + * Displays hero values from highest to lowest with optional team, lane, or role comparisons. + * Team totals use the full roster at the hovered minute, regardless of hero filtering. + * Differences are calculated as Radiant minus Dire: green indicates a Radiant lead, + * red indicates a Dire lead, and ties are neutral. Nonzero differences display a + * plus sign and their absolute magnitude, with color identifying the leading team. + * @param props Component properties, including hover data supplied by Recharts. + * @param props.active Whether the chart tooltip is currently active. + * @param props.label The hovered timestamp, such as "10:00". Team totals require + * a whole-minute timestamp in this format to locate the recorded player values. + * @param props.payload Visible series entries containing hero names, values, and line colors. + * @param props.players The full match roster used for hero lookup, roles, and team totals. + * @param props.metric The statistic measured by the graph: "networth" uses recorded + * gold, while "levels" converts each hero's experience points (XP) to a level. + * Also selects the icon and unit displayed beside values. + * @param props.lanePlayers Players in the selected lane. When supplied, lane totals + * replace role comparisons in the advantage breakdown. + * @param props.showTeamTotals Whether the current filter permits full-team totals. + * Defaults to true. Also hides lane and role breakdowns, even when team totals are disabled. + * @param props.teamTotalsEnabled Whether the user's team-total preference is enabled. + * Defaults to true; totals appear only when showTeamTotals is also true. + * @param props.showAdvantageSummary Whether to show team differences or lane/role + * comparisons. Defaults to true; does not hide individual hero values or team totals. + * @returns A timestamp, sorted hero values, and enabled comparisons, or null when + * inactive or without hovered series entries. + */ +const GraphTooltip = ({ + active, + label, + payload, + players, + metric, + lanePlayers, + showTeamTotals = true, + teamTotalsEnabled = true, + showAdvantageSummary = true, +}: { + active?: boolean; + label?: string; + payload?: any[]; + players: MatchPlayer[]; + metric: "networth" | "levels"; + lanePlayers?: MatchPlayer[]; + showTeamTotals?: boolean; + teamTotalsEnabled?: boolean; + showAdvantageSummary?: boolean; +}) => { + const strings = useStrings(); + const roleLabels = getRoleLabels(strings); + const radiantRoles = getLaningRoles(players, true); + const direRoles = getLaningRoles(players, false); + const differences = (Object.keys(roleLabels) as LaneRole[]).flatMap((role) => { + const radiant = radiantRoles[role]; + const dire = direRoles[role]; + if(!radiant || !dire) return []; + const radiantValue = payload?.find( + (entry) => entry.dataKey === heroes[radiant.hero_id]?.localized_name, + )?.value; + const direValue = payload?.find( + (entry) => entry.dataKey === heroes[dire.hero_id]?.localized_name, + )?.value; + if(!Number.isFinite(radiantValue) || !Number.isFinite(direValue)) return []; + return [{ role, difference: radiantValue - direValue }]; + }); + /** + * Adds hovered values for one side of the selected lane. + * @param isRadiant True to total Radiant players in the selected lane; false for Dire. + * @returns The combined value, or null if the team or a value is missing. + */ + const laneTotal = (isRadiant: boolean) => { + const team = lanePlayers?.filter((player) => player.isRadiant === isRadiant); + if(!team?.length) return null; + const values = team.map((player) => payload?.find( + (entry) => entry.dataKey === heroes[player.hero_id]?.localized_name, + )?.value); + return values.every((value) => Number.isFinite(value)) + ? values.reduce((sum, value) => sum + value, 0) : null; + }; + const radiantTotal = laneTotal(true); + const direTotal = laneTotal(false); + const laneDifference = radiantTotal !== null && direTotal !== null + ? radiantTotal - direTotal : null; + /** Whole-minute index parsed from the timestamp for full-roster history lookups. */ + const hoveredMinute = label?.match(/^(\d+):00$/)?.[1]; + /** + * Calculates full-team totals independently of the visible hero filter. + * Gold is summed directly; XP is converted to each hero’s level before summing. + * @param isRadiant True to total the full Radiant roster; false for Dire. + * @returns The hovered minute’s total, or null without five valid player values. + */ + const teamTotal = (isRadiant: boolean): number | null => { + if (hoveredMinute === undefined) return null; + const minute = Number(hoveredMinute); + const team = players.filter((player) => player.isRadiant === isRadiant); + const field = metric === "networth" ? "gold_t" : "xp_t"; + if (team.length !== 5 || team.some((player) => !Number.isFinite(player[field]?.[minute]))) { + return null; + } + return team.reduce((total, player) => { + const value = player[field][minute]; + return total + (metric === "levels" ? getHeroLevel(value) : value); + }, 0); + }; + const radiantTeamTotal = teamTotal(true); + const direTeamTotal = teamTotal(false); + const totalDifference = radiantTeamTotal !== null && direTeamTotal !== null + ? radiantTeamTotal - direTeamTotal : null; + const teamTotals = ( + +

{(metric === "networth" ? strings.laning_team_networth : strings.laning_team_level_totals)}

+ {showAdvantageSummary && + {strings.laning_difference} + {totalDifference === null ? {strings.laning_unavailable} : ( + + + {totalDifference !== 0 && "+"}{Math.abs(totalDifference).toLocaleString("en-US")} {(metric === "networth" ? strings.laning_gold : Math.abs(totalDifference) === 1 ? strings.laning_level : strings.laning_levels)} + + )} + } + {[true, false].map((isRadiant) => { + const total = isRadiant ? radiantTeamTotal : direTeamTotal; + return ( + + + {isRadiant ? strings.general_radiant : strings.general_dire} + + + {total !== null && } + {total === null ? strings.laning_unavailable : `${total.toLocaleString("en-US")} ${(metric === "networth" ? strings.laning_gold : strings.laning_levels)}`} + + + ); + })} +
+ ); + const hideBreakdown = showTeamTotals; + if (!active || !payload?.length) return null; + return ( + +
+ + {showTeamTotals && teamTotalsEnabled && teamTotals} + {showAdvantageSummary && !hideBreakdown &&
+ {lanePlayers ? ( + +

{strings.laning_lane_totals}

+ + {strings.general_radiant} + {radiantTotal !== null && }{radiantTotal?.toLocaleString() ?? strings.laning_unavailable} {radiantTotal !== null && (metric === "networth" ? strings.laning_gold : strings.laning_levels)} + + + {strings.general_dire} + {direTotal !== null && }{direTotal?.toLocaleString() ?? strings.laning_unavailable} {direTotal !== null && (metric === "networth" ? strings.laning_gold : strings.laning_levels)} + + + {laneDifference === null || laneDifference === 0 ? strings.laning_advantage : formatTemplateToString(strings.laning_leads, { team: laneDifference > 0 ? strings.general_radiant : strings.general_dire })} + {laneDifference === null ? {strings.laning_unavailable} : ( + + + {laneDifference !== 0 && "+"}{Math.abs(laneDifference).toLocaleString()}{" "} + {metric === "networth" ? strings.laning_gold : Math.abs(laneDifference) === 1 ? strings.laning_level : strings.laning_levels} + + )} + +
+ ) : differences.length > 0 && ( + +

{strings.laning_advantage}

+ {differences.map(({ role, difference }) => ( + + {roleLabels[role]} + + + {difference !== 0 && "+"} + {Math.abs(difference).toLocaleString()}{" "} + {metric === "networth" + ? strings.laning_gold + : Math.abs(difference) === 1 ? strings.laning_level : strings.laning_levels} + + + ))} +
+ )} +
} +
+ {payload + ?.slice() + .sort((a, b) => b.value - a.value) + ?.map((data, i) => { + const player = players.find( + (entry) => heroes[entry.hero_id]?.localized_name === data.dataKey, + ); + const hero = player && heroes[player.hero_id]; + return ( +
+ {hero && ( + {hero.localized_name} + )} + + {typeof data.value === "number" ? data.value.toLocaleString("en-US") : data.value} + +
+ ); + })} +
+
+
+ ); +}; + +export default GraphTooltip; diff --git a/src/components/Match/Laning/HeroComparison.tsx b/src/components/Match/Laning/HeroComparison.tsx new file mode 100644 index 0000000000..3206dbcd1d --- /dev/null +++ b/src/components/Match/Laning/HeroComparison.tsx @@ -0,0 +1,480 @@ +/** + * @file Hero Comparison. + * @description Compares selected opposing heroes at minute ten using stacked bar charts and an optional same-role selection lock. + */ + +import { + formatTemplate, + formatTemplateToString, + localStorageKeys, +} from "../../../utility"; +import React, { useState } from "react"; +import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip } from "recharts"; +import constants from "../../constants"; +import AccessTimeIcon from "@mui/icons-material/AccessTime"; +import TrendingUpIcon from "@mui/icons-material/TrendingUp"; +import GpsFixedIcon from "@mui/icons-material/GpsFixed"; +import BlockIcon from "@mui/icons-material/Block"; +import config from "../../../config"; +import { Switch } from "@mui/material"; +import { heroes, player_colors as playerColors } from "dotaconstants"; +import useLocalStorageBoolean from "../../../hooks/useLocalStorageBoolean"; +import useStrings from "../../../hooks/useStrings.hook"; +import HeroSelect from "./HeroSelect"; +import { getReplayKDA } from "./replay.utils"; +import Heading from "../../Heading/Heading"; +import { + getStatAtMinute, + getLevelAtMinute, + formatStat, + StatPlayer, + HistoryField, + rolePositions, + getPlayerRoles, + getPlayerIdentity, + LaneRole, +} from "./laning.utils"; +import { TableCaption, ReplaySummary } from "./StyledLaning"; +import { + HeroMenus, + HeroComparisonTooltip, + HeroMenu, + SameRoleToggle, + ComparisonLead, + MetricList, + MetricRow, + MetricHeading, + MetricValues, + MetricSummary, +} from "./StyledHeroComparison"; + +/** + * Compares two selected opposing heroes using minute-ten levels, gold, last hits, and denies. + * Same-role mode synchronizes selections and excludes roles without an opponent. + * That mode is persisted in localStorage; hero selections remain local to the component. + * @param props The match whose players can be selected. + * @returns Hero menus and proportional metric bars, with unavailable values shown explicitly. + */ +const HeroComparison = ({ match }: { match: Match }) => { + const strings = useStrings(); + const [sameRolesOnly, setSameRolesOnly] = useLocalStorageBoolean( + localStorageKeys.heroComparisonSameRolesOnly, + true, + ); + const [opposingRolesOnly, setOpposingRolesOnly] = useState(false); + const rolesLocked = sameRolesOnly || opposingRolesOnly; + const opposingRoles: Record = { + carry: "offlane", offlane: "carry", midlane: "midlane", + hardSupport: "softSupport", softSupport: "hardSupport", + }; + const opponentRole = (role: LaneRole | undefined) => + role && opposingRolesOnly ? opposingRoles[role] : role; + const [radiantSlot, setRadiantSlot] = useState(null); + const [direSlot, setDireSlot] = useState(null); + const roles = getPlayerRoles(match.players); + /** + * Looks up a hero’s inferred position for menu sorting. + * @returns Position 1–5, or 6 for an undetermined role. + */ + const position = (player: MatchPlayer) => { + const role = roles.get(player.player_slot); + return role ? rolePositions[role] : 6; + }; + /** + * Orders players by inferred position, breaking ties by player slot. + * @returns A comparator result without modifying either player. + */ + const sortByPosition = (first: MatchPlayer, second: MatchPlayer) => + position(first) - position(second) || + first.player_slot - second.player_slot; + const radiantPlayers = match.players + .filter((player) => player.isRadiant) + .sort(sortByPosition); + const direPlayers = match.players + .filter((player) => !player.isRadiant) + .sort(sortByPosition); + // Only include roles with an identified player on both teams in locked mode. + /** + * Checks whether the other team has an identified player with the same role. + * @returns False for undetermined roles or roles without a matching opponent. + */ + const hasOpponent = (player: MatchPlayer, opponents: MatchPlayer[]) => { + const role = roles.get(player.player_slot); + return ( + role !== undefined && + opponents.some((opponent) => roles.get(opponent.player_slot) === opponentRole(role)) + ); + }; + const matchingRadiant = radiantPlayers.filter((player) => + hasOpponent(player, direPlayers), + ); + const matchingDire = direPlayers.filter((player) => + hasOpponent(player, radiantPlayers), + ); + const radiantOptions = rolesLocked ? matchingRadiant : radiantPlayers; + const direOptions = rolesLocked ? matchingDire : direPlayers; + const radiant = + radiantOptions.find((player) => player.player_slot === radiantSlot) ?? + radiantOptions[0]; + const dire = rolesLocked + ? direOptions.find( + (player) => + radiant && + roles.get(player.player_slot) === opponentRole(roles.get(radiant.player_slot)), + ) + : (direOptions.find((player) => player.player_slot === direSlot) ?? + direOptions[0]); + /** + * Selects a Radiant hero and, in same-role mode, synchronizes the Dire selection. + * @param slot Selected Radiant player slot. + */ + const selectRadiant = (slot: number) => { + setRadiantSlot(slot); + if (rolesLocked) + setDireSlot( + direOptions.find( + (player) => roles.get(player.player_slot) === opponentRole(roles.get(slot)), + )?.player_slot ?? null, + ); + }; + /** + * Selects a Dire hero and, in same-role mode, synchronizes the Radiant selection. + * @param slot Selected Dire player slot. + */ + const selectDire = (slot: number) => { + setDireSlot(slot); + if (rolesLocked) + setRadiantSlot( + radiantOptions.find( + (player) => roles.get(player.player_slot) === opponentRole(roles.get(slot)), + )?.player_slot ?? null, + ); + }; + /** Reads minute-ten data only after the match reaches ten minutes. */ + const valueAtTen = (player: StatPlayer | undefined, field: HistoryField) => + match.duration >= 600 ? getStatAtMinute(player, field) : null; + /** Returns the selected hero's level, preserving unavailable short-match data. */ + const level = (player: MatchPlayer | undefined) => + match.duration >= 600 ? getLevelAtMinute(player) : null; + const metrics = [ + { + icon: