From 0dac9008e9aa0d139eee4a3e8492e790c8ba5d4a Mon Sep 17 00:00:00 2001 From: Tristan Payne Date: Mon, 17 Aug 2026 14:50:31 -0500 Subject: [PATCH 1/2] Add equipped, in-bag, and Great Vault indicators on the trinkets chart. --- .../TrinketAnalysis/Charts/VerticalChart.css | 17 ++ .../TrinketAnalysis/Charts/VerticalChart.js | 221 ++++++++++++++++-- .../Modules/TrinketAnalysis/TrinketChart.js | 1 + 3 files changed, 216 insertions(+), 23 deletions(-) diff --git a/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.css b/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.css index 80db7e1859..e68227b068 100644 --- a/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.css +++ b/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.css @@ -3,3 +3,20 @@ font-size: 0.9rem; } +.ResponsiveContainer2 .recharts-legend-item-text { + color: inherit !important; +} + +.ResponsiveContainer2 .recharts-default-legend { + line-height: 22px; +} + +.ResponsiveContainer2 .recharts-legend-item { + padding-top: 2px; + padding-bottom: 2px; +} + +.ResponsiveContainer2 .recharts-default-legend + .recharts-default-legend { + margin-top: 8px; +} + diff --git a/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.js b/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.js index bf9438359d..9dc50808b4 100644 --- a/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.js +++ b/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.js @@ -1,5 +1,5 @@ import React, { PureComponent } from "react"; -import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Legend, CartesianGrid, Tooltip } from "recharts"; +import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, CartesianGrid, Tooltip, ReferenceLine, Customized, Legend, DefaultLegendContent } from "recharts"; // import chroma from "chroma-js"; import { getItemIcon, getTranslatedItemName } from "../../../Engine/ItemUtilities"; import MuiTooltip from '@mui/material/Tooltip'; @@ -89,10 +89,124 @@ function getInitials(str) { .map(word => word[0].toUpperCase()) .join(''); } + +const Indicator = { + EQUIPPED: { id: "equipped", color: "#4FC3F7", label: "Equipped" }, + OWNED: { id: "owned", color: "#CE93D8", label: "In bags" }, + VAULT: { id: "vault", color: "#00FFFF", label: "Great Vault" }, + key(id, ilvl) { + return `${id}:${ilvl}`; + }, + of(trinket, current) { + if (trinket.isEquipped) return this.EQUIPPED; + if (trinket.vaultItem && current !== this.EQUIPPED) return this.VAULT; + return current || this.OWNED; + }, + fromPlayer(trinkets, itemLevels) { + const indicators = {}; + (trinkets || []).forEach((t) => { + if (!t) return; + const key = this.key(t.id, snapIlvl(t.level, itemLevels)); + indicators[key] = this.of(t, indicators[key]); + }); + return indicators; + }, + legend() { + return [this.EQUIPPED, this.OWNED, this.VAULT]; + }, +}; + +const snapIlvl = (level, itemLevels) => { + if (!itemLevels || !itemLevels.length) return level; + return itemLevels.reduce((best, ilvl) => + Math.abs(ilvl - level) < Math.abs(best - level) ? ilvl : best + ); +}; + +const sliceScore = (rows, id, ilvl) => { + const row = (rows || []).find((entry) => entry.id === id); + return row ? row["i" + ilvl] || 0 : 0; +}; + +const playerTrinkets = (player) => (player && player.getActiveItems("Trinket")) || []; + +const chartSignature = ({ itemLevels = [], data = [], theme = [], breakdown, player }) => { + const top = itemLevels[itemLevels.length - 1] || ""; + return [ + itemLevels.join(), + !!breakdown, + theme.join(), + playerTrinkets(player).map((t) => `${t.id}:${t.level}:${!!t.isEquipped}:${!!t.vaultItem}`).join(), + data.map((row) => `${row.id}:${row["i" + top] || 0}`).join(), + ].join("|"); +}; + +function IndicatorOverlay({ formattedGraphicalItems, indicators, hoverSlice }) { + if (!formattedGraphicalItems) return null; + const overlays = []; + let hoverRect = null; + formattedGraphicalItems.forEach((entry) => { + const ilvl = entry.item && entry.item.props && entry.item.props.dataKey; + const rects = entry.props && entry.props.data; + if (ilvl == null || !rects) return; + rects.forEach((rect) => { + const id = rect.payload && rect.payload.name; + if (id == null || !(rect.width > 0) || !(rect.height > 0)) return; + const indicator = indicators[Indicator.key(id, ilvl)]; + if (indicator) { + overlays.push( + + ); + } + if (hoverSlice && hoverSlice.id === id && hoverSlice.ilvl === ilvl) hoverRect = rect; + }); + }); + return ( + + {hoverRect ? ( + + ) : null} + {overlays} + + ); +} + +function IndicatorLegend({ items }) { + return ( + + ); +} export default class VerticalChart extends PureComponent { constructor() { super(); - this.state = { focusBar: null, mouseLeave: true, width: window.innerWidth, height: window.innerHeight }; + this.state = { focusBar: null, mouseLeave: true, width: window.innerWidth, height: window.innerHeight, hoverSlice: null, compare: null }; } updateDimensions = () => { @@ -101,6 +215,11 @@ export default class VerticalChart extends PureComponent { componentDidMount() { window.addEventListener('resize', this.updateDimensions); } + componentDidUpdate(prevProps) { + if (this.state.compare && chartSignature(prevProps) !== chartSignature(this.props)) { + this.setState({ compare: null, hoverSlice: null }); + } + } componentWillUnmount() { window.removeEventListener('resize', this.updateDimensions); } @@ -120,6 +239,14 @@ export default class VerticalChart extends PureComponent { const barColours = this.props.theme; const breakdown = this.props.breakdown ?? false; + const indicators = breakdown ? {} : Indicator.fromPlayer(playerTrinkets(this.props.player), itemLevels); + const { hoverSlice, compare } = this.state; + const guide = compare || (hoverSlice && hoverSlice.indicator ? hoverSlice : null); + const readSlice = (entry, ilvl) => { + const id = entry && entry.payload && entry.payload.name; + if (id == null) return null; + return { id, ilvl, score: sliceScore(data, id, ilvl), indicator: indicators[Indicator.key(id, ilvl)] }; + }; let arr = []; let cleanedArray = []; @@ -212,12 +339,11 @@ export default class VerticalChart extends PureComponent { barCategoryGap="15%" data={cleanedArray} layout="vertical" - onMouseMove={(state) => { if (state.isTooltipActive) { this.setState({ focusBar: state.activeTooltipIndex, mouseLeave: false }); } else { - this.setState({ focusBar: null, mouseLeave: true }); + this.setState({ focusBar: null, mouseLeave: true, hoverSlice: null }); } }} > @@ -247,24 +373,21 @@ export default class VerticalChart extends PureComponent { ]; }} /> - {breakdown ? null : } - {breakdown ? ( - ( -
- - - Passive Stats - - - - Effect - -
- )} - /> - ) : null} + ( +
+ + +
+ ) + } + /> , ] : itemLevels.map((key, i) => ( - + { + const slice = readSlice(entry, key); + if (!slice) return; + this.setState((prev) => ({ + compare: prev.compare && prev.compare.id === slice.id && prev.compare.ilvl === slice.ilvl ? null : slice, + })); + }} + onMouseEnter={(entry) => { + const slice = readSlice(entry, key); + if (slice) this.setState({ hoverSlice: slice }); + }} + onMouseLeave={() => this.setState({ hoverSlice: null })} + /> ))} + {breakdown ? null : ( + ( + + )} + /> + )} + {breakdown || !guide ? null : ( + ( + this.setState({ compare: null })} + /> + ) + : undefined + } + /> + )} ); diff --git a/src/General/Modules/TrinketAnalysis/TrinketChart.js b/src/General/Modules/TrinketAnalysis/TrinketChart.js index 9f6eab6593..ab0d750f97 100644 --- a/src/General/Modules/TrinketAnalysis/TrinketChart.js +++ b/src/General/Modules/TrinketAnalysis/TrinketChart.js @@ -362,6 +362,7 @@ export default function TrinketChart({ player }) { theme={themeSelection(theme ? "candidate2" : "candidate21")} gameType={gameType} breakdown={breakdown} + player={player} /> From 5363dccaa0ead5e396bee9bad4a8370fd35b8929 Mon Sep 17 00:00:00 2001 From: Tristan Payne Date: Mon, 17 Aug 2026 16:00:12 -0500 Subject: [PATCH 2/2] Mark the hovered item level in the trinkets chart tooltip. --- .../TrinketAnalysis/Charts/VerticalChart.css | 15 +++++ .../TrinketAnalysis/Charts/VerticalChart.js | 55 ++++++++++++------- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.css b/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.css index e68227b068..19b9e44e8a 100644 --- a/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.css +++ b/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.css @@ -20,3 +20,18 @@ margin-top: 8px; } +.trinket-tooltip { + background: #1b1b1b; + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 8px 10px; +} + +.trinket-tooltip-label { + color: #fff; + margin-bottom: 6px; +} + +.trinket-tooltip-row.is-hovered { + font-weight: 700; +} + diff --git a/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.js b/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.js index 9dc50808b4..8dffebaa8a 100644 --- a/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.js +++ b/src/General/Modules/TrinketAnalysis/Charts/VerticalChart.js @@ -189,6 +189,32 @@ function IndicatorOverlay({ formattedGraphicalItems, indicators, hoverSlice }) { ); } +function TrinketTooltip({ active, payload, label, hoverSlice, breakdown, data, currentLanguage }) { + if (!active || !payload || !payload.length) return null; + const itemId = payload[0].payload && payload[0].payload.name; + if (!breakdown && (!hoverSlice || hoverSlice.id !== itemId)) return null; + return ( +
+
{getTranslatedItemName(label, currentLanguage)}
+ {payload.map((entry) => { + const name = entry.dataKey; + const hovered = !breakdown && hoverSlice && hoverSlice.id === itemId && hoverSlice.ilvl == name; + let text; + if (entry.value <= 0) text = "Unobtainable"; + else if (breakdown) text = Math.round(entry.value); + else text = data.filter((row) => row.id === itemId).map((row) => row["i" + name]).toString(); + const displayName = breakdown ? (name === "passive" ? "Passive Stats" : "Effect") : name; + return ( +
+ {hovered ? "▸ " : ""} + {displayName} : {text} +
+ ); + })} +
+ ); +} + function IndicatorLegend({ items }) { return (
    @@ -351,27 +377,16 @@ export default class VerticalChart extends PureComponent { getTranslatedItemName(timeStr, currentLanguage)} - formatter={(value, name, props) => { - if (value <= 0) return ["Unobtainable", name]; - if (breakdown) { - const isPassive = name === "passive"; - return [Math.round(value), isPassive ? "Passive Stats" : "Effect"]; - } - return [ - data - .filter((filter) => filter.id === props["payload"].name) - .map((key) => key["i" + name]) - .toString(), - name, - ]; - }} + wrapperStyle={{ pointerEvents: "none" }} + content={ + + } />