diff --git a/DECISIONS.md b/DECISIONS.md index ee857d2..cadfe58 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -974,9 +974,9 @@ in both themes. The y axis is not zero-based: `niceDomain()` rounds outward from the data, with 2.5 in its progression so a 5-to-26 spread does not step by 10. -Each dot is a plain SVG ``, not `next/link`. Below ~560px the chart scrolls inside -`overflow-x-auto`. The card renders when empty, unlike `ServiceDueCard`: missing pressures or an -average depth are something the diver can fix. +Each dot is a plain SVG ``, not `next/link`. Below 560px the chart narrows its viewBox rather +than scrolling (`fittedChartWidth`). The card renders when empty, unlike `ServiceDueCard`: missing +pressures or an average depth are something the diver can fix. ## The chart windows to All/Year/Month, but scales itself from the whole series @@ -1004,8 +1004,8 @@ point: the same state drives the dot's enlarge-and-brighten, so dot and card can It is positioned in percentages of the chart box (the SVG scales uniformly in a wrapper of its own size) through the `style` prop, an inline attribute the CSP allows (`style-src-attr 'unsafe-inline'`). It flips to stay inside the box — below the dot in the top -third, edge-aligned within 18% of either side — because `overflow-x: auto` on the scroll container -computes `overflow-y` to `auto` and clips. +third, edge-aligned within 18% of either side — and `useKeepInside` pulls in whatever a phone-width +chart still leaves past an edge, where it would scroll the page sideways. Each dot has an invisible `r=7` hit circle with `fill="transparent"`, not `fill="none"`, which takes no pointer events. The card is `pointer-events-none` so it cannot steal the hover, has no accessible @@ -1050,8 +1050,7 @@ maps the cursor to an instant on the profile's millisecond axis once and each ch own sample with `nearestSampleIndex`. Readouts are real readings, never interpolations. `tooltipVerticalAnchor` pins the card to the plot's top or bottom edge, whichever keeps it off the -readings: card height depends on channel count, so offsetting from a point overflows the clipping -scroll container. +readings: card height depends on channel count, so offsetting from a point overflows the chart. Keyboard scrubbing is out of scope. The `aria-label` uses `formatDurationHoursMinutes`, not `MM:SS`. `--pressure` is a third theme-stable token in `globals.css`, violet. @@ -1923,8 +1922,9 @@ The anchor is the start of a day with diving in it, so `periodRange`, `periodLab `availablePeriods`, `stepPeriod` and `resolveAnchor` live in `lib/chart-period.ts`. `GET /user/dive-activity` returns one row per day and `activityBars` sums into months and years client-side, so scope switches need no request (reasoning under _"Dives-per-day is counted in -Python"_ in `opendiving-api/DECISIONS.md`). `MAX_X_LABELS` is 20 for years, 12 for months, 31 for -days. +Python"_ in `opendiving-api/DECISIONS.md`). `X_LABEL_SPACING` gives a label 33 units for years, 26 +for months and 21 for days: twenty years, all twelve months and every day at the design width, fewer +on a phone. ## Every chart control names its own card, because the two cards draw the same row @@ -1974,11 +1974,10 @@ label size reads as a rendering fault. ## The two chart cards stack, and gas leads - both measured, not assumed -Each plot carries `min-w-[560px]`, which keeps twelve month labels and a y axis legible. -`lg:grid-cols-2` on the dashboard's `max-w-6xl` gives 482px (546px at `max-w-7xl`), and three things -break: both charts clip and grow a horizontal scrollbar, the axis text halves (16.6px to 8.6px, -since the svg scales uniformly), and the gas header goes from 50px to 114px as its toggle and -stepper drop below the description. Clearing all three needs about 1220px. +Below 560px each plot narrows its viewBox instead of shrinking, so its axis text stops at 8.6px +(`fittedChartWidth`). `lg:grid-cols-2` on the dashboard's `max-w-6xl` gives 482px (546px at +`max-w-7xl`), and two things break: the axis text halves (16.6px to 8.6px), and the gas header goes +from 50px to 114px as its toggle and stepper drop below the description. `RecentDivesCard`/`RecentTripsCard` pair up fine below: their content reflows instead of scaling. Gas consumption leads because it can change how you dive tomorrow; activity records what already diff --git a/src/components/dashboard/dashboard-page-frame.tsx b/src/components/dashboard/dashboard-page-frame.tsx index 7cb5155..1e6c597 100644 --- a/src/components/dashboard/dashboard-page-frame.tsx +++ b/src/components/dashboard/dashboard-page-frame.tsx @@ -247,11 +247,10 @@ export function DashboardPageFrame({ already happened - and it's the harder-won number, since it needs dives that recorded pressures and an average depth. - Stacked, not side by side, and that was measured rather than assumed: each - plot needs 560px to keep twelve month labels legible, and a two-column grid - gives it 482px even on a widened page. Both charts clip, their axis text - halves, and the gas card's header doubles in height when its controls can no - longer share a line with its description. See DECISIONS.md. */} + Stacked, not side by side, and that was measured rather than assumed: a + two-column grid gives each plot 482px even on a widened page, where their + axis text halves and the gas card's header doubles in height when its + controls can no longer share a line with its description. See DECISIONS.md. */} {hasDives && } {hasDives && } diff --git a/src/components/dives/chart-skeleton.tsx b/src/components/dives/chart-skeleton.tsx index 3e9e0ce..7a42e9c 100644 --- a/src/components/dives/chart-skeleton.tsx +++ b/src/components/dives/chart-skeleton.tsx @@ -5,7 +5,8 @@ import { Skeleton } from "@/components/ui/skeleton"; * summary figures over a `720 x 240` SVG drawn at `w-full h-auto`, so the * placeholder reserves the same `3:1` box - a card that collapsed to a spinner * and then grew back to chart height was most of what made arriving at the - * dashboard feel jumpy. + * dashboard feel jumpy. Below 560px the charts keep the height they have there + * (see `fittedChartWidth`), and so does this. * * `legend` covers the one difference between them: the gas chart carries a * `text-xs` legend under its plot (it doubles as the control for which series @@ -29,7 +30,7 @@ export function ChartSkeleton({ ))} - + {legend && } ); diff --git a/src/components/dives/dive-activity-chart.browser.test.tsx b/src/components/dives/dive-activity-chart.browser.test.tsx new file mode 100644 index 0000000..5354b3c --- /dev/null +++ b/src/components/dives/dive-activity-chart.browser.test.tsx @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { fireEvent, render } from "@testing-library/react"; + +import { DiveActivityChart } from "./dive-activity-chart"; +import type { DiveActivityPoint } from "@/lib/api/dive-stats"; +import type { ChartScope } from "@/lib/chart-period"; +import { activityBars, barCeiling } from "@/lib/dive-activity"; +import { + overlappingLabels, + sidewaysScrollers, + withinSides, +} from "@/test/chart-layout"; + +// Load-bearing, as in `dive-profile-chart.browser.test.tsx`, whose first test +// fails without it: without the app's Tailwind a `min-w-*` class computes to +// nothing, so a chart wider than its container would pass everything below. +import "@/app/globals.css"; + +// Twenty-five years of diving, and a 2025 with a dive in every month and on +// every day of August: the widest each scope's axis gets. +const POINTS: DiveActivityPoint[] = [ + ...Array.from({ length: 25 }, (_, index) => ({ + year: 2002 + index, + month: 6, + day: 15, + dives: 1 + (index % 4), + })), + ...Array.from({ length: 12 }, (_, index) => ({ + year: 2025, + month: index + 1, + day: 3, + dives: 2, + })), + ...Array.from({ length: 31 }, (_, index) => ({ + year: 2025, + month: 8, + day: index + 1, + dives: 1 + (index % 3), + })), +]; + +const ANCHOR = Date.UTC(2025, 7, 10); + +// The chart's width on the dashboard at a 375 px and a 320 px viewport, and at +// 1024 px. +const WIDTHS = { phone: 293, smallPhone: 238, desktop: 910 }; +const SCOPES: ChartScope[] = ["all", "year", "month"]; + +const CASES = Object.entries(WIDTHS).flatMap(([name, width]) => + SCOPES.map((scope) => [name, scope, width] as const), +); + +function renderAt(width: number, scope: ChartScope) { + const { container } = render( +
+ +
, + ); + const frame = container.firstElementChild as HTMLElement; + const svg = frame.querySelector("svg") as SVGSVGElement; + return { frame, svg }; +} + +describe("the dive activity chart's layout", () => { + it.each(CASES)( + "draws no label over another at %s width, scope %s", + (_, scope, width) => { + const { svg } = renderAt(width, scope); + + expect(overlappingLabels(svg)).toEqual([]); + }, + ); + + it.each(CASES)( + "fits %s width without scrolling sideways, scope %s", + (_, scope, width) => { + const { frame, svg } = renderAt(width, scope); + + expect(sidewaysScrollers(svg, frame)).toEqual([]); + }, + ); + + it.each(SCOPES)( + "keeps the hover card inside a phone-width chart, scope %s", + (scope) => { + const { frame, svg } = renderAt(WIDTHS.smallPhone, scope); + const columns = [...svg.querySelectorAll('rect[fill="transparent"]')]; + + for (const column of [columns[0], columns[columns.length - 1]]) { + fireEvent.mouseEnter(column); + const card = frame.querySelector('[role="presentation"]') as Element; + + expect(withinSides(card, frame)).toBe(true); + fireEvent.mouseLeave(column); + } + }, + ); +}); diff --git a/src/components/dives/dive-activity-chart.tsx b/src/components/dives/dive-activity-chart.tsx index 3c168bc..eaac714 100644 --- a/src/components/dives/dive-activity-chart.tsx +++ b/src/components/dives/dive-activity-chart.tsx @@ -1,11 +1,13 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { barPath } from "@/lib/chart-path"; -import { axisTicks, countDomain } from "@/lib/chart-scale"; +import { axisTicks, countDomain, labelCapacity } from "@/lib/chart-scale"; import type { ChartScope } from "@/lib/chart-period"; import type { ActivityBar } from "@/lib/dive-activity"; import { cn } from "@/lib/utils"; +import { useChartWidth } from "@/hooks/useChartWidth"; +import { useKeepInside } from "@/hooks/useKeepInside"; // Hand-rolled SVG, for the reasons `gas-use-chart.tsx` sets out at length: the // app ships a strict nonce-based CSP that a `