mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-24 12:06:54 -05:00
Show currently selected value(s) on the ability curve chart, crosshair
Closes #1806
This commit is contained in:
@@ -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<ChartType<"line"> | 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<string | null>(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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<Chart
|
||||
options={chartOptions as any}
|
||||
containerClassName={styles.statChartContainer}
|
||||
headerSuffix={t("analyzer:abilityPoints.short")}
|
||||
valueSuffix={valueSuffix}
|
||||
xAxis="linear"
|
||||
xAbilityLimit={57}
|
||||
highlight={highlight}
|
||||
crosshair
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1412,7 +1419,7 @@ function StatCard({
|
||||
suffix,
|
||||
popoverInfo,
|
||||
testId,
|
||||
context: { mainWeaponId, abilityPoints, isComparing },
|
||||
context: { mainWeaponId, abilityPoints, abilityPoints2, isComparing },
|
||||
}: {
|
||||
title: string;
|
||||
stat: StatTuple | StatTuple<string> | 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 (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -1529,6 +1559,7 @@ function StatCard({
|
||||
title={title}
|
||||
valueSuffix={suffix}
|
||||
mainWeaponId={mainWeaponId}
|
||||
highlight={highlight}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user