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 `