diff --git a/app/components/Chart.tsx b/app/components/Chart.tsx index f0c7934e6..cd799060d 100644 --- a/app/components/Chart.tsx +++ b/app/components/Chart.tsx @@ -32,6 +32,8 @@ export default function Chart({ xTicksLimit, yTicksLimit, xAbilityLimit, + highlight, + crosshair = false, }: { options: [ { @@ -46,15 +48,23 @@ export default function Chart({ xTicksLimit?: number; yTicksLimit?: number; xAbilityLimit?: number; + /** Marks current positions on the curve, each at ability point `x` and stat value `y` (e.g. one per build being compared). */ + highlight?: Array<{ x: number; y: number }>; + /** When true, draws dashed guide lines from the hovered point to the x- and y-axes. */ + crosshair?: boolean; }) { const isHydrated = useHydrated(); // Ref to the Chart.js instance, allows proper cleanup between renders to prevent "Canvas is already in use" errors const chartRef = useRef | null>(null); const chartId = React.useId(); + // Chart.js re-fires the external tooltip on every redraw; track the last value to skip redundant state updates + const lastTooltipSignature = useRef(null); const [tooltipData, setTooltipData] = React.useState<{ x: number; y: number; + flipX: boolean; + flipY: boolean; items: Array<{ label: React.ReactNode; value: number | null; @@ -78,9 +88,11 @@ export default function Chart({ // Get the chart colors from CSS variables const [colors, setColors] = React.useState({ - accent: "", - base: "", - info: "", + accentHigh: "", + infoHigh: "", + secondHigh: "", + accentLow: "", + secondLow: "", border: "", borderHigh: "", text: "", @@ -91,9 +103,13 @@ export default function Chart({ const get = (v: string) => getComputedStyle(document.documentElement).getPropertyValue(v).trim(); setColors({ - accent: get("--color-text-accent"), - base: get("--color-accent"), - info: get("--color-info"), + // bright "high" variants for the curve lines so they stay legible on the dark chart + accentHigh: get("--color-text-accent"), + infoHigh: get("--color-info-high"), + secondHigh: get("--color-second-high"), + // low variants for the highlight marker fills (paired with a light border) + accentLow: get("--color-accent-low"), + secondLow: get("--color-second-low"), border: get("--color-border"), borderHigh: get("--color-border-high"), text: get("--color-text-high"), @@ -119,8 +135,14 @@ export default function Chart({ // Make a color list to use inside ChartData for the borderColor and the external tooltip const colorList = React.useMemo( - () => [colors.accent, colors.base, colors.info], - [colors.accent, colors.base, colors.info], + () => [colors.accentHigh, colors.infoHigh, colors.secondHigh], + [colors.accentHigh, colors.infoHigh, colors.secondHigh], + ); + + // Distinct accent/secondary pair so the highlight markers (e.g. build 1 vs build 2) stay tellable apart in both themes + const markerColors = React.useMemo( + () => [colors.accentLow, colors.secondLow], + [colors.accentLow, colors.secondLow], ); const datasetColors = React.useCallback( @@ -140,17 +162,65 @@ export default function Chart({ const chartData = React.useMemo( () => ({ labels: options[0].data.map((_, i) => i), - datasets: options.map((series, i) => ({ - label: String(i), - data: series.data.map((d) => d.secondary), - ...datasetColors(i), - pointRadius: 0, - pointHoverRadius: 5, - hitRadius: 50, - backgroundColor: "transparent", - })), + datasets: [ + ...options.map((series, i) => ({ + label: String(i), + data: series.data.map((d) => d.secondary), + ...datasetColors(i), + pointRadius: 0, + pointHoverRadius: 5, + hitRadius: 50, + backgroundColor: "transparent", + })), + ...(highlight ?? []).map((point, i) => ({ + label: "highlight", + data: options[0].data.map((_, j) => (j === point.x ? point.y : null)), + borderColor: "transparent", + backgroundColor: "transparent", + pointBackgroundColor: markerColors[i % markerColors.length], + pointBorderColor: colors.text, + pointBorderWidth: 2, + pointRadius: 5, + pointHoverRadius: 5, + pointHitRadius: 0, + showLine: false, + // lower order draws on top, so the marker sits above the curves + order: -1, + })), + ], }), - [options, datasetColors], + [options, datasetColors, highlight, markerColors, colors.text], + ); + + // Draws dashed guide lines from the hovered point to the y-axis (left) and x-axis (bottom) + const crosshairPlugin = React.useMemo( + () => ({ + id: "crosshair", + afterDatasetsDraw: (chart: ChartType<"line">) => { + const active = chart.tooltip?.getActiveElements() ?? []; + if (active.length === 0) return; + + const { ctx, chartArea } = chart; + const { x, y } = active[0].element as { x: number; y: number }; + + ctx.save(); + ctx.beginPath(); + ctx.setLineDash([4, 4]); + ctx.lineWidth = 1; + ctx.strokeStyle = colors.borderHigh || "#888"; + ctx.moveTo(chartArea.left, y); + ctx.lineTo(x, y); + ctx.lineTo(x, chartArea.bottom); + ctx.stroke(); + ctx.restore(); + }, + }), + [colors.borderHigh], + ); + + const plugins = React.useMemo( + () => (crosshair ? [crosshairPlugin] : []), + [crosshair, crosshairPlugin], ); if (!isHydrated) { @@ -166,6 +236,7 @@ export default function Chart({ ref={chartRef} id={chartId} data={chartData} + plugins={plugins} options={{ animation: false, maintainAspectRatio: false, @@ -200,27 +271,48 @@ export default function Chart({ plugins: { tooltip: { enabled: false, - external: ({ tooltip }) => { + external: ({ chart, tooltip }) => { if (tooltip.opacity === 0) { + lastTooltipSignature.current = null; setTooltipData(null); return; } - const items = tooltip.dataPoints.map((dp) => ({ + // the highlight marker has no entry in `options` and is non-interactive + const dataPoints = tooltip.dataPoints.filter( + (dp) => dp.datasetIndex < options.length, + ); + if (dataPoints.length === 0) { + lastTooltipSignature.current = null; + setTooltipData(null); + return; + } + const x = tooltip.caretX; + const y = tooltip.caretY; + const items = dataPoints.map((dp) => ({ label: options[dp.datasetIndex].label, value: dp.parsed.y, color: colorList[dp.datasetIndex % colorList.length], })); + const rawPrimary = + options[0].data[dataPoints[0].dataIndex]?.primary; + const header = + rawPrimary instanceof Date + ? headerFormatter.format(rawPrimary) + (headerSuffix ?? "") + : `${dataPoints[0].parsed.x}${headerSuffix ?? ""}`; + + // skip redundant updates from repeated redraws to avoid an infinite render loop + const signature = `${x}|${y}|${header}|${items + .map((i) => `${i.value}:${i.color}`) + .join(",")}`; + if (signature === lastTooltipSignature.current) return; + lastTooltipSignature.current = signature; + setTooltipData({ - x: tooltip.caretX, - y: tooltip.caretY, - header: (() => { - const raw = - options[0].data[tooltip.dataPoints[0].dataIndex]?.primary; - if (raw instanceof Date) { - return headerFormatter.format(raw) + (headerSuffix ?? ""); - } - return `${tooltip.dataPoints[0].parsed.x}${headerSuffix ?? ""}`; - })(), + x, + y, + flipX: x > chart.width / 2, + flipY: y > chart.height / 2, + header, items, }); }, @@ -235,6 +327,9 @@ export default function Chart({ position: "absolute", left: tooltipData.x, top: tooltipData.y, + transform: `translate(${ + tooltipData.flipX ? "calc(-100% - 8px)" : "8px" + }, ${tooltipData.flipY ? "-100%" : "0"})`, pointerEvents: "none", }} > diff --git a/app/features/build-analyzer/routes/analyzer.module.css b/app/features/build-analyzer/routes/analyzer.module.css index f7f4c10a6..4d7dd7163 100644 --- a/app/features/build-analyzer/routes/analyzer.module.css +++ b/app/features/build-analyzer/routes/analyzer.module.css @@ -267,6 +267,29 @@ --chart-bg: transparent; --chart-height: 250px; --chart-width: 340px; + + /* Let the chart shrink to whatever height react-aria allots the popover + (so it never gets clipped) by threading a flex column down to it. */ + display: flex; + flex-direction: column; + + /* The chart shrinks to fit, so no scroll is needed here; keep visible so the + hover tooltip can overlay the chart instead of being clipped. */ + overflow: visible; +} + +.statPopover [role="dialog"] { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + min-height: 0; +} + +.statChartContainer { + flex: 1 1 auto; + min-height: 0; + max-width: 100%; } @media screen and (min-width: 700px) { diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx index a49195bb6..3a9970c75 100644 --- a/app/features/build-analyzer/routes/analyzer.tsx +++ b/app/features/build-analyzer/routes/analyzer.tsx @@ -158,6 +158,7 @@ function BuildAnalyzerPage() { isComparing: !buildIsEmpty(build) && !buildIsEmpty(build2), mainWeaponId, abilityPoints, + abilityPoints2, }; const mainWeaponCategoryItems = [ @@ -999,6 +1000,8 @@ interface StatChartProps { valueSuffix?: string; mainWeaponId: MainWeaponId; simple?: boolean; + /** Marks where the current build(s) sit on the curve: `x` ability points, `y` stat value. */ + highlight?: Array<{ x: number; y: number }>; } function StatChartPopover(props: StatChartProps) { @@ -1026,6 +1029,7 @@ function StatChart({ valueSuffix, mainWeaponId, subWeaponId, + highlight, }: StatChartProps) { const { t } = useTranslation(["analyzer"]); @@ -1058,10 +1062,13 @@ function StatChart({ return ( ); } @@ -1412,7 +1419,7 @@ function StatCard({ suffix, popoverInfo, testId, - context: { mainWeaponId, abilityPoints, isComparing }, + context: { mainWeaponId, abilityPoints, abilityPoints2, isComparing }, }: { title: string; stat: StatTuple | StatTuple | number | string; @@ -1422,6 +1429,7 @@ function StatCard({ context: { mainWeaponId: MainWeaponId; abilityPoints: AbilityPoints; + abilityPoints2: AbilityPoints; isComparing: boolean; }; }) { @@ -1462,6 +1470,28 @@ function StatCard({ return isStaticValue ? [] : [stat[0].modifiedBy].flat(); }, [memoKey]); + const stackableAbility = modifiedBy.find(isStackableAbility); + const highlight = (() => { + if (isStaticValue || !stackableAbility || !showBuildValue()) + return undefined; + + const builds = [ + [abilityPoints, stat[0].value], + ...(isComparing ? ([[abilityPoints2, stat[1].value]] as const) : []), + ] as const; + + const points: Array<{ x: number; y: number }> = []; + for (const [ap, value] of builds) { + if (typeof value !== "number") continue; + points.push({ + x: Math.min(ap.get(stackableAbility) ?? 0, MAX_AP), + y: value, + }); + } + + return points.length > 0 ? points : undefined; + })(); + return (
)}